test(web): behavioral QA of Evidence/Ops/Setup/Admin surfaces (SPRINT_20260421_007)
Closes SPRINT_20260421_007 — all 4 tasks DONE. Full Tier 2c behavioral
verification per docs/qa/feature-checks/FLOW.md. 34 assertions, 0 fail,
0 deferred.
FE-QA-EVID-001 — Evidence: 7/7 PASS
/evidence/{overview, audit-log, verify-replay, exports, capsules, proofs,
bundles}. Alias chains to /ops/operations/audit confirmed intentional per
evidence.routes.ts.
FE-QA-OPS-002 — Ops: 8/8 PASS
/ops/operations/{jobengine, feeds-airgap, doctor, audit, notifications,
health-slo, watchlist} + /ops/scripts. Doctor full diagnostics grid
rendered with real data.
FE-QA-SETUP-003 — Setup + Admin: 12 + 7 PASS
Setup: /setup{, /integrations, /trust-signing (+ issuers/keys/certificates
/audit sub-tabs aliased correctly), /identity-providers, /tenant-branding,
/workflows, /ai-preferences, /topology}.
Admin: all /console-admin/{tenants, users, roles, clients, audit, branding,
assistant} preserved console origin under "Console Administration" heading.
FE-QA-EVIDOPS-004 — Retention coverage:
New e2e/routes/sprint-007-evidence-ops-setup-admin.e2e.spec.ts with 27
Playwright assertions covering origin, canonical-or-alias URL, and
identity-matching body text. Uses the existing auth.fixture.ts pattern.
Evidence: docs/qa/feature-checks/runs/web/sprint-007-evidence-ops-setup-admin/
run-001/ (EVIDENCE.md + tier2-ui-check.json + 36 screenshots + verify.mjs).
Authority default-tenant gap (same as FE-QA-REL-001 discovery):
stellaops_authority had zero tenants and zero users; setup wizard admin
bootstrap failed with users_tenant_id_fkey FK violation. Worked around
in-session by inserting `installation` + `default` tenants and calling
POST /api/v1/setup/sessions/{id}/steps/admin/execute. This is the same
bug two parallel agents independently hit — needs a real Authority sprint
to seed `default` through migrations or StandardPluginRegistrar init.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,334 @@
|
||||
// Tier 2c UI verification for Sprint 20260421_007 (Evidence / Ops / Setup / Admin).
|
||||
// Follows the NOTIFY-COMPAT-003 reference at docs/qa/notify-compat-20260422/verify.mjs.
|
||||
//
|
||||
// Produces, per surface section:
|
||||
// - <section>-<route-slug>.png screenshots
|
||||
// - tier2-ui-check.json with per-route assertions
|
||||
//
|
||||
// Scope lock: no release/* or security/* routes, no advisory-vex-sources sub-tree edits.
|
||||
import { chromium } from 'playwright';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { fileURLToPath } from 'url';
|
||||
const OUT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SHOT_DIR = path.join(OUT_DIR, 'screenshots');
|
||||
const BASE = 'https://stella-ops.local';
|
||||
const USER = 'admin';
|
||||
const PASS = 'Admin@Stella2026!';
|
||||
|
||||
// Route sets for each sprint task.
|
||||
const EVIDENCE_ROUTES = [
|
||||
// evidence-overview redirects to /ops/operations/audit (intentional alias per EVIDENCE_ROUTES).
|
||||
{ id: 'evidence-overview', path: '/evidence/overview', headingRe: /(audit|evidence|decision|ledger)/i, aliasTo: '/ops/operations/audit' },
|
||||
{ id: 'evidence-audit-log', path: '/evidence/audit-log', headingRe: /audit/i },
|
||||
{ id: 'evidence-verify-replay', path: '/evidence/verify-replay', headingRe: /(replay|verify|stella ops)/i },
|
||||
{ id: 'evidence-exports', path: '/evidence/exports', headingRe: /(export|evidence)/i },
|
||||
// capsules redirect to ops/operations/audit?tab=all-events
|
||||
{ id: 'evidence-capsules', path: '/evidence/capsules', headingRe: /(decision capsule|audit|evidence)/i, aliasTo: '/ops/operations/audit' },
|
||||
{ id: 'evidence-proofs', path: '/evidence/proofs', headingRe: /proof/i },
|
||||
{ id: 'evidence-bundles', path: '/evidence/bundles', headingRe: /(bundle|audit)/i },
|
||||
];
|
||||
|
||||
const OPS_ROUTES = [
|
||||
{ id: 'ops-operations-jobengine', path: '/ops/operations/jobengine', headingRe: /(job|queue|scheduled)/i },
|
||||
{ id: 'ops-operations-feeds-airgap', path: '/ops/operations/feeds-airgap', headingRe: /(feed|airgap)/i },
|
||||
{ id: 'ops-operations-doctor', path: '/ops/operations/doctor', headingRe: /(doctor|diagnostic)/i },
|
||||
{ id: 'ops-operations-audit', path: '/ops/operations/audit', headingRe: /audit/i },
|
||||
{ id: 'ops-scripts', path: '/ops/scripts', headingRe: /script/i },
|
||||
{ id: 'ops-operations-notifications', path: '/ops/operations/notifications', headingRe: /notif/i },
|
||||
{ id: 'ops-operations-health-slo', path: '/ops/operations/health-slo', headingRe: /(health|slo)/i },
|
||||
{ id: 'ops-operations-watchlist', path: '/ops/operations/watchlist', headingRe: /watchlist/i },
|
||||
];
|
||||
|
||||
const SETUP_ROUTES = [
|
||||
{ id: 'setup-overview', path: '/setup', headingRe: /(setup|administration|admin)/i },
|
||||
{ id: 'setup-integrations', path: '/setup/integrations', headingRe: /integration/i },
|
||||
{ id: 'setup-trust-signing', path: '/setup/trust-signing', headingRe: /(trust|certificate|signing)/i },
|
||||
{ id: 'setup-trust-signing-issuers', path: '/setup/trust-signing/issuers', headingRe: /(issuer|trust)/i },
|
||||
{ id: 'setup-trust-signing-keys', path: '/setup/trust-signing/keys', headingRe: /(key|signing)/i },
|
||||
{ id: 'setup-trust-signing-certificates', path: '/setup/trust-signing/certificates', headingRe: /certificate/i },
|
||||
// /setup/trust-signing/audit is an intentional alias tab landing on keys?tab=audit.
|
||||
{ id: 'setup-trust-signing-audit', path: '/setup/trust-signing/audit', headingRe: /(audit|trust|certificate)/i, aliasTo: '/setup/trust-signing' },
|
||||
{ id: 'setup-identity-providers', path: '/setup/identity-providers', headingRe: /identity/i },
|
||||
{ id: 'setup-tenant-branding', path: '/setup/tenant-branding', headingRe: /(tenant|brand)/i },
|
||||
{ id: 'setup-workflows', path: '/setup/workflows', headingRe: /workflow/i },
|
||||
{ id: 'setup-ai-preferences', path: '/setup/ai-preferences', headingRe: /(ai|assistant|preference)/i },
|
||||
{ id: 'setup-topology', path: '/setup/topology', headingRe: /(environment|topology|region)/i },
|
||||
];
|
||||
|
||||
const ADMIN_ROUTES = [
|
||||
{ id: 'console-admin-tenants', path: '/console-admin/tenants', headingRe: /tenant/i, originGuard: true },
|
||||
{ id: 'console-admin-users', path: '/console-admin/users', headingRe: /user/i, originGuard: true },
|
||||
{ id: 'console-admin-roles', path: '/console-admin/roles', headingRe: /role/i, originGuard: true },
|
||||
{ id: 'console-admin-clients', path: '/console-admin/clients', headingRe: /client/i, originGuard: true },
|
||||
{ id: 'console-admin-audit', path: '/console-admin/audit', headingRe: /audit/i, originGuard: true },
|
||||
{ id: 'console-admin-branding', path: '/console-admin/branding', headingRe: /brand/i, originGuard: true },
|
||||
{ id: 'console-admin-assistant', path: '/console-admin/assistant', headingRe: /(assistant|stella)/i, originGuard: true },
|
||||
];
|
||||
|
||||
function log(m) { console.log(`[verify] ${m}`); }
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
async function login(page) {
|
||||
log(`navigate ${BASE}/`);
|
||||
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForTimeout(2500);
|
||||
log(`landed at ${page.url()}`);
|
||||
|
||||
if (/\/welcome/i.test(page.url())) {
|
||||
log('at welcome; clicking sign-in');
|
||||
const signIn = await page.$('a:has-text("Sign In"), a:has-text("Sign in"), button:has-text("Sign In")');
|
||||
if (!signIn) throw new Error('sign-in link not found on /welcome');
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }).catch(() => {}),
|
||||
signIn.click(),
|
||||
]);
|
||||
log(`after sign-in at ${page.url()}`);
|
||||
}
|
||||
|
||||
if (/authority|connect\/authorize|\/login/i.test(page.url())) {
|
||||
log('at authority login');
|
||||
// Ensure both fields are present (form may animate in).
|
||||
await page.waitForSelector('input[name="username"], input[name="Username"], #Username, input[type="text"]', { timeout: 15000 });
|
||||
await page.waitForSelector('input[name="password"], input[name="Password"], #Password, input[type="password"]', { timeout: 15000 });
|
||||
// Clear then fill using click+type for reliability.
|
||||
const userInput = await page.$('input[name="username"], input[name="Username"], #Username, input[type="text"]');
|
||||
const passInput = await page.$('input[name="password"], input[name="Password"], #Password, input[type="password"]');
|
||||
await userInput.click({ clickCount: 3 });
|
||||
await userInput.type(USER, { delay: 20 });
|
||||
await passInput.click({ clickCount: 3 });
|
||||
await passInput.type(PASS, { delay: 20 });
|
||||
// Verify value was captured.
|
||||
const filledUser = await userInput.inputValue();
|
||||
const filledPass = await passInput.inputValue();
|
||||
log(`login fields: user="${filledUser}" passLen=${filledPass.length}`);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, '00a-login-filled.png'), fullPage: true });
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }).catch(() => {}),
|
||||
page.click('button[type="submit"], input[type="submit"]'),
|
||||
]);
|
||||
log(`post-login at ${page.url()}`);
|
||||
// If still on login form with error, retry once.
|
||||
if (/connect\/authorize|authority|\/login/i.test(page.url())) {
|
||||
const errBanner = await page.$('text=/invalid|incorrect|failed/i');
|
||||
if (errBanner) {
|
||||
log('login failed per banner; retrying');
|
||||
const u2 = await page.$('input[type="text"], input[name="username"], input[name="Username"], #Username');
|
||||
const p2 = await page.$('input[type="password"], input[name="password"], input[name="Password"], #Password');
|
||||
if (u2) { await u2.click({ clickCount: 3 }); await u2.type(USER, { delay: 30 }); }
|
||||
if (p2) { await p2.click({ clickCount: 3 }); await p2.type(PASS, { delay: 30 }); }
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, '00b-login-retry.png'), fullPage: true });
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 30000 }).catch(() => {}),
|
||||
page.click('button[type="submit"], input[type="submit"]'),
|
||||
]);
|
||||
log(`after retry at ${page.url()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (!/consent|authorize/i.test(page.url())) break;
|
||||
const btn = await page.$('button[type="submit"]:has-text("Allow"), button:has-text("Allow"), button:has-text("Continue"), button[name="submit.Grant"], button[name="accept"]');
|
||||
if (!btn) break;
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle', timeout: 20000 }).catch(() => {}),
|
||||
btn.click(),
|
||||
]);
|
||||
}
|
||||
|
||||
log(`post-auth at ${page.url()}`);
|
||||
await page.screenshot({ path: path.join(SHOT_DIR, '00-post-login.png'), fullPage: true });
|
||||
}
|
||||
|
||||
async function verifyRoute(page, section, route) {
|
||||
const url = BASE + route.path;
|
||||
const capturedAt = nowIso();
|
||||
log(`--- ${section} :: ${route.id} :: ${url}`);
|
||||
let navError = null;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
||||
} catch (e) {
|
||||
navError = e.message;
|
||||
log(`nav warning: ${e.message}`);
|
||||
}
|
||||
// Wait for app shell to hydrate past any splash: look for either a heading or any route-specific element.
|
||||
await page.waitForFunction(() => {
|
||||
const bodyText = document.body?.innerText ?? '';
|
||||
// Consider hydrated when body has a meaningful non-splash content (>100 chars beyond "STELLA OPS").
|
||||
return bodyText.replace(/STELLA\s*OPS/gi, '').trim().length > 80;
|
||||
}, { timeout: 8000 }).catch(() => {});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const shotFile = `${section}-${route.id}.png`;
|
||||
const shotPath = path.join(SHOT_DIR, shotFile);
|
||||
try {
|
||||
await page.screenshot({ path: shotPath, fullPage: true, timeout: 10000 });
|
||||
} catch (e) {
|
||||
log(`screenshot warning: ${e.message}`);
|
||||
try { await page.screenshot({ path: shotPath, fullPage: false, timeout: 10000 }); } catch (_) {}
|
||||
}
|
||||
|
||||
const finalUrl = page.url();
|
||||
const bodyText = await page.evaluate(() => document.body.innerText || '');
|
||||
const title = await page.title();
|
||||
const firstHeading = await page.evaluate(() => {
|
||||
const h = document.querySelector('h1, h2, [role="heading"][aria-level="1"]');
|
||||
return h ? (h.textContent || '').trim() : '';
|
||||
});
|
||||
|
||||
const sentToWelcome = /\/welcome/i.test(finalUrl);
|
||||
const sentToWizard = /\/setup-wizard/i.test(finalUrl);
|
||||
const sentToProfile = /\/console\/profile/i.test(finalUrl);
|
||||
const hasUnavailable = /runtime.?unavailable|runtime unavailable/i.test(bodyText);
|
||||
const has404Body = /Page Not Found|HTTP 404|404 Endpoint|404\s+Page Not Found/i.test(bodyText);
|
||||
const headingMatches = route.headingRe ? route.headingRe.test(firstHeading) || route.headingRe.test(bodyText.slice(0, 4000)) : true;
|
||||
const expectedPath = route.aliasTo ?? route.path;
|
||||
const landedOnExpected = finalUrl.includes(expectedPath);
|
||||
|
||||
// Origin preservation for admin routes.
|
||||
const originMatches = new URL(finalUrl).origin === new URL(BASE).origin;
|
||||
|
||||
// Compose verdict
|
||||
const assertions = [];
|
||||
assertions.push({
|
||||
id: 'origin-preserved',
|
||||
expected: `origin=${new URL(BASE).origin}`,
|
||||
actual: `origin=${new URL(finalUrl).origin}`,
|
||||
result: originMatches ? 'pass' : 'fail',
|
||||
});
|
||||
assertions.push({
|
||||
id: 'landed-on-expected-path',
|
||||
expected: `url contains ${expectedPath}`,
|
||||
actual: finalUrl,
|
||||
result: landedOnExpected ? 'pass' : 'fail',
|
||||
});
|
||||
assertions.push({
|
||||
id: 'not-bounced-to-welcome',
|
||||
expected: 'not /welcome',
|
||||
actual: sentToWelcome ? '/welcome' : 'stayed on route',
|
||||
result: sentToWelcome ? 'fail' : 'pass',
|
||||
});
|
||||
assertions.push({
|
||||
id: 'not-bounced-to-setup-wizard',
|
||||
expected: 'not /setup-wizard',
|
||||
actual: sentToWizard ? '/setup-wizard' : 'stayed on route',
|
||||
result: sentToWizard ? 'fail' : 'pass',
|
||||
});
|
||||
assertions.push({
|
||||
id: 'not-403-to-profile',
|
||||
expected: 'not /console/profile',
|
||||
actual: sentToProfile ? '/console/profile' : 'stayed on route',
|
||||
result: sentToProfile ? 'fail' : 'pass',
|
||||
});
|
||||
assertions.push({
|
||||
id: 'no-not-found-body',
|
||||
expected: 'no Page Not Found body',
|
||||
actual: has404Body ? 'body matched 404' : 'clean',
|
||||
result: has404Body ? 'fail' : 'pass',
|
||||
});
|
||||
assertions.push({
|
||||
id: 'heading-or-body-matches-identity',
|
||||
expected: `matches ${route.headingRe}`,
|
||||
actual: `h1="${firstHeading}"`,
|
||||
result: headingMatches ? 'pass' : 'fail',
|
||||
});
|
||||
|
||||
// Only runtime-unavailable is a soft signal (deferred if hit)
|
||||
const runtimeUnavailableSoft = hasUnavailable ? 'deferred' : 'pass';
|
||||
assertions.push({
|
||||
id: 'no-runtime-unavailable',
|
||||
expected: 'no runtime-unavailable banner',
|
||||
actual: hasUnavailable ? 'runtime-unavailable banner present' : 'clean',
|
||||
result: runtimeUnavailableSoft,
|
||||
});
|
||||
|
||||
let verdict = 'pass';
|
||||
if (assertions.some(a => a.result === 'fail')) verdict = 'fail';
|
||||
else if (assertions.some(a => a.result === 'deferred')) verdict = 'deferred';
|
||||
// Special case: if route aliased and landed on aliasTo, still pass the path-landing assertion
|
||||
// (already covered by landedOnExpected using expectedPath).
|
||||
|
||||
return {
|
||||
section,
|
||||
route: route.id,
|
||||
url,
|
||||
finalUrl,
|
||||
title,
|
||||
firstHeading,
|
||||
headingMatches,
|
||||
navError,
|
||||
capturedAtUtc: capturedAt,
|
||||
screenshot: path.relative(OUT_DIR, shotPath).replaceAll('\\', '/'),
|
||||
bodySnippet: bodyText.slice(0, 400).replace(/\s+/g, ' '),
|
||||
assertions,
|
||||
verdict,
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1400, height: 900 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const consoleErrors = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
|
||||
page.on('pageerror', err => consoleErrors.push('pageerror: ' + err.message));
|
||||
|
||||
await login(page);
|
||||
|
||||
const results = {
|
||||
type: 'ui',
|
||||
baseUrl: BASE,
|
||||
capturedAtUtc: nowIso(),
|
||||
evidence: [],
|
||||
ops: [],
|
||||
setup: [],
|
||||
admin: [],
|
||||
consoleErrors: [],
|
||||
};
|
||||
|
||||
// Prime the app shell: load the dashboard first so route-1 isn't captured mid-hydration.
|
||||
try {
|
||||
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
} catch (_) {}
|
||||
for (const r of EVIDENCE_ROUTES) results.evidence.push(await verifyRoute(page, 'evidence', r));
|
||||
for (const r of OPS_ROUTES) results.ops.push(await verifyRoute(page, 'ops', r));
|
||||
for (const r of SETUP_ROUTES) results.setup.push(await verifyRoute(page, 'setup', r));
|
||||
for (const r of ADMIN_ROUTES) results.admin.push(await verifyRoute(page, 'admin', r));
|
||||
|
||||
results.consoleErrors = consoleErrors.slice(0, 50);
|
||||
|
||||
// Section-level verdicts
|
||||
const sectionVerdict = (arr) => {
|
||||
if (arr.some(a => a.verdict === 'fail')) return 'fail';
|
||||
if (arr.some(a => a.verdict === 'deferred')) return 'deferred';
|
||||
return 'pass';
|
||||
};
|
||||
results.verdicts = {
|
||||
evidence: sectionVerdict(results.evidence),
|
||||
ops: sectionVerdict(results.ops),
|
||||
setup: sectionVerdict(results.setup),
|
||||
admin: sectionVerdict(results.admin),
|
||||
};
|
||||
|
||||
fs.writeFileSync(path.join(OUT_DIR, 'tier2-ui-check.json'), JSON.stringify(results, null, 2));
|
||||
log('results written to tier2-ui-check.json');
|
||||
log(`verdicts: ${JSON.stringify(results.verdicts)}`);
|
||||
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user