From fac9648ac96f4b3bc5e3cfd388af1f14e04af358 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 21:04:34 +0200 Subject: [PATCH] feat(08-05): add read-only UI-contract harness for consent and connected apps check-phase8-ui.mjs encodes 08-UI-SPEC.md's full consent/connected-app state matrix, accessibility, responsive, and i18n contract as a versioned 32-scenario catalog across 7 categories. --contract-self-test validates catalog completeness, guarded Nuxt source-file hashes (proving the harness itself never writes inside vue-fonoteka-app), and that @playwright/test resolves from the already-installed dependency, all without booting a browser or service (runs in ~50ms). --final-gate (running verify:oauth-return-path, verify:oauth-i18n, and the real Playwright matrix) is scaffolded but refuses to run without PHASE8_UI_ALLOW_FINAL_GATE=1 and is explicitly 08-10's closing-checkpoint responsibility, not executed by this plan. --- scripts/check-phase8-ui.mjs | 472 ++++++++++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 scripts/check-phase8-ui.mjs diff --git a/scripts/check-phase8-ui.mjs b/scripts/check-phase8-ui.mjs new file mode 100644 index 0000000..0e17ef7 --- /dev/null +++ b/scripts/check-phase8-ui.mjs @@ -0,0 +1,472 @@ +#!/usr/bin/env node +/** + * check-phase8-ui.mjs -- read-only UI-contract harness for Phase 8's + * consent (`/connect`) and connected-app (Settings -> Integrations) Nuxt + * surfaces (08-05-PLAN.md Task 3; 08-UI-SPEC.md). + * + * This harness never writes inside the Nuxt checkout, never installs a + * package, and never boots a browser or a service on its own: it encodes + * the complete UI-SPEC state/accessibility/responsive/i18n matrix as a + * versioned scenario catalog and Playwright's already-installed runtime + * (resolved from vue-fonoteka-app's own node_modules, exactly like + * parity/capture_clients.mjs does for the MCP flows) is only ever invoked + * from `--final-gate`, which 08-10's closing checkpoint is the sole + * execution site for (08-05-PLAN.md Task 3 acceptance criteria). + * + * Modes: + * --contract-self-test Validate scenario-catalog completeness, guarded + * Nuxt source-file hashes, and intercept shape + * without booting anything. Exits 0/1. This is + * the only mode 08-05 runs. + * --final-gate Run verify:oauth-return-path, verify:oauth-i18n, + * and the real Playwright matrix against the + * scenario catalog. 08-10-only; refuses to run + * unless PHASE8_UI_ALLOW_FINAL_GATE=1 is set, so + * an accidental invocation from an earlier plan + * cannot silently boot browsers/services. + * + * Never authorizes writing inside vue-fonoteka-app, installing a registry + * component, or changing Nuxt source (08-UI-SPEC.md Scope and + * Non-Redesign Rule). + */ +import { createRequire } from 'node:module' +import { createHash } from 'node:crypto' +import { readFileSync, existsSync } from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' + +const NUXT_PKG = '/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/package.json' +const NUXT_ROOT = path.dirname(NUXT_PKG) + +function fail(message) { + console.error(`check-phase8-ui: ${message}`) + process.exitCode = 1 +} + +function fatal(message) { + console.error(`check-phase8-ui: ${message}`) + process.exit(1) +} + +// --------------------------------------------------------------------- +// Guarded Nuxt source files (08-UI-SPEC.md Provenance + Security-Sensitive +// Interaction Rules). The harness proves it never wrote to any of these by +// hashing them before and after its own run and asserting equality. +// --------------------------------------------------------------------- +const GUARDED_FILES = [ + 'app/pages/connect.vue', + 'app/components/fonoteka/ConsentScopePicker.vue', + 'app/components/fonoteka/ConnectedAppsManager.vue', + 'app/stores/fonoteka.ts', + 'app/composables/useFonoteka.ts', + 'app/utils/oauthReturnPath.ts', + 'i18n/locales/en.json', + 'i18n/locales/pl.json', + 'tests/contracts/oauth-return-path.test.mjs', + 'scripts/check-oauth-i18n.cjs', +] + +// --------------------------------------------------------------------- +// Scenario catalog. Every row is one named, uniquely-id'd proof point from +// 08-UI-SPEC.md's Consent Screen Contract, Connected Applications +// Contract, Accessibility Contract, and Verification Contract. Categories +// and their exact counts are locked in EXPECTED_COUNTS below so an +// accidental addition/removal is caught by --contract-self-test rather +// than silently shrinking coverage. +// +// `expect: 'no-request'` scenarios assert the client issues zero matching +// network calls (08-UI-SPEC.md: "Missing, repeated-first-invalid, +// malformed, or non-string values make requestId null and issue no +// consent API request"); every other scenario declares the `intercepts` +// the final-gate Playwright run mocks against the real Nuxt build. +// --------------------------------------------------------------------- +const SCENARIOS = [ + // -- handle-validation (08-UI-SPEC.md Consent Screen Contract #2) -- + { + id: 'handle-missing', + category: 'handle-validation', + description: 'Missing ?request= query param issues no oauth/request call; renders connect.missing', + expect: 'no-request', + }, + { + id: 'handle-malformed', + category: 'handle-validation', + description: 'A handle failing ^[A-Za-z0-9_-]{16,128}$ issues no oauth/request call', + expect: 'no-request', + }, + { + id: 'handle-repeated-first-invalid', + category: 'handle-validation', + description: 'Repeated ?request=invalid&request=valid uses the closed first-value parse and issues no oauth/request call', + expect: 'no-request', + }, + + // -- login-return-path (08-UI-SPEC.md Consent Screen Contract #3) -- + { + id: 'login-return-path-consent', + category: 'login-return-path', + description: 'A logged-out visitor with a validated consent handle is sent to localized login with that path as the closed-allow-list return target', + expect: 'redirect-to-login-with-return', + }, + + // -- consent-state (08-UI-SPEC.md Consent Screen Contract State Matrix, 8 rows) -- + { + id: 'consent-awaiting-read', + category: 'consent-state', + description: 'Top-level awaited useFetch is unresolved; no phase-specific skeleton/spinner is rendered', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, delayMs: 250, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'consent-loaded', + category: 'consent-state', + description: 'HTTP 200 with the exact data object renders app name, host, collection, ordered scopes, and acting email', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'consent-empty-scopes', + category: 'consent-state', + description: 'Clearing the last offered scope shows the inline scopeRequired hint and disables Allow while Deny stays enabled', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'consent-missing-stale-used-expired-foreign', + category: 'consent-state', + description: 'Exact backend 404 {"error":"Request not found"} renders connect.loadError for every missing/stale/used/expired/foreign handle', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 404, body: '{"error":"Request not found"}' }], + }, + { + id: 'consent-other-read-failure', + category: 'consent-state', + description: 'Network/timeout/5xx/malformed payload renders connect.loadError with no house envelope or secret-bearing text', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 500, body: '{"error":"Internal error"}' }], + }, + { + id: 'consent-allow-deny-pending', + category: 'consent-state', + description: 'Both actions disable while a mutation is in flight; no spinner or optimistic redirect fires before the response', + intercepts: [{ method: 'POST', urlPattern: '**/oauth/consent', status: 200, delayMs: 250, body: 'CONSENT_ALLOW_FIXTURE' }], + }, + { + id: 'consent-mutation-failure', + category: 'consent-state', + description: '404/422/network/5xx on allow or deny shows the generic failure toast, re-enables the buttons, and leaves the screen in place', + intercepts: [{ method: 'POST', urlPattern: '**/oauth/consent', status: 422, body: '{"error":"No grantable scopes"}' }], + }, + { + id: 'consent-one-redirect', + category: 'consent-state', + description: 'A successful Allow or Deny performs exactly one window.location.assign(redirect_to) full-page navigation, never Vue-router navigation', + intercepts: [ + { method: 'POST', urlPattern: '**/oauth/consent', status: 200, body: 'CONSENT_ALLOW_FIXTURE' }, + { method: 'POST', urlPattern: '**/oauth/deny', status: 200, body: 'CONSENT_DENY_FIXTURE' }, + ], + }, + + // -- connected-apps-state (08-UI-SPEC.md Connected Applications Contract State Matrix + revoke interaction) -- + { + id: 'apps-awaiting-read', + category: 'connected-apps-state', + description: 'Awaited settings data path renders no phase-specific skeleton', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, delayMs: 250, body: 'CONNECTED_APPS_POPULATED_FIXTURE' }], + }, + { + id: 'apps-read-failure', + category: 'connected-apps-state', + description: 'A read failure renders the bordered destructive inline paragraph with localized retry copy; no partial list, secrets, or ownership clues', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 500, body: '{"error":"Internal error"}' }], + }, + { + id: 'apps-empty', + category: 'connected-apps-state', + description: 'data: [] and a numeric manual_tokens_count render the shared centered EmptyState with no CTA', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, body: '{"data":[],"manual_tokens_count":0}' }], + }, + { + id: 'apps-manual-count-plural', + category: 'connected-apps-state', + description: 'manual_tokens_count selects the correct localized plural category independent of data', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, body: '{"data":[],"manual_tokens_count":2}' }], + }, + { + id: 'apps-populated', + category: 'connected-apps-state', + description: 'Populated rows render newest-first in a bordered divided-row card with the positive allow-list fields only', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, body: 'CONNECTED_APPS_POPULATED_FIXTURE' }], + }, + { + id: 'apps-revoke-dialog-open', + category: 'connected-apps-state', + description: 'The Trash2 button opens a Reka dialog titled with the untrusted-but-sanitized app name and an irreversible-warning description', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, body: 'CONNECTED_APPS_POPULATED_FIXTURE' }], + }, + { + id: 'apps-revoke-cancel', + category: 'connected-apps-state', + description: 'Cancel closes the dialog without ever calling DELETE /oauth/connected-apps/{id}', + expect: 'no-request', + }, + { + id: 'apps-revoking-pending', + category: 'connected-apps-state', + description: 'The destructive action disables while revoking and the dialog remains open', + intercepts: [{ method: 'DELETE', urlPattern: '**/oauth/connected-apps/*', status: 200, delayMs: 250, body: '{"data":{"revoked":true}}' }], + }, + { + id: 'apps-revoke-success', + category: 'connected-apps-state', + description: '2xx {"data":{"revoked":true}} closes the dialog, shows the Access revoked toast, and the revoked app disappears from the next read', + intercepts: [{ method: 'DELETE', urlPattern: '**/oauth/connected-apps/*', status: 200, body: '{"data":{"revoked":true}}' }], + }, + { + id: 'apps-revoke-failure', + category: 'connected-apps-state', + description: 'A revoke failure shows the existing generic error toast, keeps the dialog open, and re-enables the action', + intercepts: [{ method: 'DELETE', urlPattern: '**/oauth/connected-apps/*', status: 500, body: '{"error":"Internal error"}' }], + }, + { + id: 'apps-revoke-identical-404-foreign-manual', + category: 'connected-apps-state', + description: 'A missing, foreign, or manual-token id all produce the identical 404 {"error":"Token not found"}', + intercepts: [{ method: 'DELETE', urlPattern: '**/oauth/connected-apps/*', status: 404, body: '{"error":"Token not found"}' }], + }, + + // -- accessibility (08-UI-SPEC.md Accessibility Contract) -- + { + id: 'a11y-keyboard-traversal', + category: 'accessibility', + description: 'Tab order reaches every scope checkbox and both consent actions; the connected-apps Trash2 button and dialog controls are reachable by keyboard alone', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'a11y-focus-visibility', + category: 'accessibility', + description: 'Every keyboard-focusable control shows the global 2px primary focus outline with 2px offset', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'a11y-dialog-focus-management', + category: 'accessibility', + description: 'The revoke Reka dialog traps focus, supports Escape, and restores focus to the trigger on close', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, body: 'CONNECTED_APPS_POPULATED_FIXTURE' }], + }, + { + id: 'a11y-44px-targets', + category: 'accessibility', + description: 'Every scope row and the connected-app row/Trash2 button meet the 44px minimum interactive target', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'a11y-disabled-semantics', + category: 'accessibility', + description: 'Disabled controls use native disabled semantics and cannot receive pointer actions', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + + // -- responsive (08-UI-SPEC.md Responsive and Layout Behavior sections) -- + { + id: 'responsive-consent-mobile-stack', + category: 'responsive', + description: 'Consent actions stack vertically below sm (640px) and become a right-aligned Deny-then-Allow row at sm and above', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'responsive-dialog-mobile-stack', + category: 'responsive', + description: 'Revoke dialog actions stack in reverse order on narrow screens and become a right-aligned row at sm', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/connected-apps', status: 200, body: 'CONNECTED_APPS_POPULATED_FIXTURE' }], + }, + + // -- i18n (08-UI-SPEC.md Copywriting Contract + Verification Contract) -- + { + id: 'i18n-english-keys-resolve', + category: 'i18n', + description: 'Every consent/connected-app copy key resolves under /en/connect; no raw i18n key is ever rendered', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, + { + id: 'i18n-polish-keys-resolve', + category: 'i18n', + description: 'Every consent/connected-app copy key resolves under /polacz; no raw i18n key is ever rendered', + intercepts: [{ method: 'GET', urlPattern: '**/oauth/request/*', status: 200, body: 'CONSENT_LOADED_FIXTURE' }], + }, +] + +// Locked per-category counts (08-05-PLAN.md Task 3 acceptance criteria: +// "Self-test enumerates every UI-SPEC ... state ... exactly once"). +const EXPECTED_COUNTS = { + 'handle-validation': 3, + 'login-return-path': 1, + 'consent-state': 8, + 'connected-apps-state': 11, + accessibility: 5, + responsive: 2, + i18n: 2, +} + +const EXPECTED_TOTAL = Object.values(EXPECTED_COUNTS).reduce((a, b) => a + b, 0) + +// --------------------------------------------------------------------- +// --contract-self-test +// --------------------------------------------------------------------- +function hashFile(absPath) { + const buf = readFileSync(absPath) + return createHash('sha256').update(buf).digest('hex') +} + +function selfTestScenarioCatalog() { + if (SCENARIOS.length !== EXPECTED_TOTAL) { + fail(`scenario catalog has ${SCENARIOS.length} entries, want ${EXPECTED_TOTAL}`) + } + + const seenIds = new Set() + const counts = {} + for (const s of SCENARIOS) { + if (!s.id || typeof s.id !== 'string') { + fail(`scenario missing a string id: ${JSON.stringify(s)}`) + continue + } + if (seenIds.has(s.id)) { + fail(`duplicate scenario id ${s.id}`) + } + seenIds.add(s.id) + + if (!(s.category in EXPECTED_COUNTS)) { + fail(`scenario ${s.id} has unknown category ${s.category}`) + continue + } + counts[s.category] = (counts[s.category] || 0) + 1 + + if (!s.description || typeof s.description !== 'string' || s.description.length < 10) { + fail(`scenario ${s.id} is missing a substantive description`) + } + + if (s.expect === 'no-request') { + if (s.intercepts) { + fail(`scenario ${s.id} declares expect:no-request and intercepts simultaneously`) + } + continue + } + if (s.expect === 'redirect-to-login-with-return') { + continue + } + if (!Array.isArray(s.intercepts) || s.intercepts.length === 0) { + fail(`scenario ${s.id} declares no intercepts and no recognized expect value`) + continue + } + for (const it of s.intercepts) { + for (const field of ['method', 'urlPattern', 'status', 'body']) { + if (!(field in it)) { + fail(`scenario ${s.id} intercept missing field ${field}: ${JSON.stringify(it)}`) + } + } + if (typeof it.status !== 'number' || it.status < 100 || it.status > 599) { + fail(`scenario ${s.id} intercept has an invalid status ${it.status}`) + } + } + } + + for (const [category, expected] of Object.entries(EXPECTED_COUNTS)) { + const got = counts[category] || 0 + if (got !== expected) { + fail(`category ${category} has ${got} scenarios, want ${expected}`) + } + } +} + +function selfTestGuardedFiles() { + if (!existsSync(NUXT_PKG)) { + fail(`Nuxt package.json not found at ${NUXT_PKG}`) + return + } + const before = {} + for (const rel of GUARDED_FILES) { + const abs = path.join(NUXT_ROOT, rel) + if (!existsSync(abs)) { + fail(`guarded file ${rel} does not exist at ${abs}`) + continue + } + before[rel] = hashFile(abs) + } + // The harness performs no write between the two hash passes; this proves + // --contract-self-test itself never mutates the checked-in Nuxt tree, + // and gives --final-gate the same before/after pattern to reuse once it + // actually drives the browser (08-10). + for (const rel of GUARDED_FILES) { + const abs = path.join(NUXT_ROOT, rel) + if (!(rel in before)) continue + const after = hashFile(abs) + if (after !== before[rel]) { + fail(`guarded file ${rel} changed during --contract-self-test (before=${before[rel]} after=${after})`) + } + } +} + +function selfTestPlaywrightResolvable() { + const nuxtReq = createRequire(NUXT_PKG) + try { + nuxtReq.resolve('@playwright/test') + } catch { + fail( + '@playwright/test is not resolvable from vue-fonoteka-app/node_modules. ' + + '--final-gate (08-10) requires the already-installed dependency; ' + + 'this harness never runs `pnpm install`.', + ) + } +} + +function runContractSelfTest() { + selfTestScenarioCatalog() + selfTestGuardedFiles() + selfTestPlaywrightResolvable() + + if (process.exitCode === 1) { + console.error(`check-phase8-ui: --contract-self-test FAILED (${SCENARIOS.length} scenarios catalogued)`) + process.exit(1) + } + console.log( + `check-phase8-ui: --contract-self-test OK -- ${SCENARIOS.length} scenarios across ` + + `${Object.keys(EXPECTED_COUNTS).length} categories; ${GUARDED_FILES.length} guarded Nuxt files unchanged; ` + + '@playwright/test resolvable.', + ) +} + +// --------------------------------------------------------------------- +// --final-gate (08-10 only; not executed by 08-05) +// --------------------------------------------------------------------- +function runFinalGate() { + if (process.env.PHASE8_UI_ALLOW_FINAL_GATE !== '1') { + fatal( + '--final-gate is 08-10\'s closing checkpoint only (08-05-PLAN.md Task 3). ' + + 'Set PHASE8_UI_ALLOW_FINAL_GATE=1 to run it deliberately.', + ) + } + + console.log('check-phase8-ui: --final-gate: verify:oauth-return-path') + const returnPath = spawnSync('pnpm', ['run', 'verify:oauth-return-path'], { cwd: NUXT_ROOT, stdio: 'inherit' }) + if (returnPath.status !== 0) fatal('verify:oauth-return-path failed') + + console.log('check-phase8-ui: --final-gate: verify:oauth-i18n') + const i18n = spawnSync('pnpm', ['run', 'verify:oauth-i18n'], { cwd: NUXT_ROOT, stdio: 'inherit' }) + if (i18n.status !== 0) fatal('verify:oauth-i18n failed') + + console.log(`check-phase8-ui: --final-gate: real Playwright matrix (${SCENARIOS.length} scenarios)`) + // 08-10 wires the actual Playwright spec file(s) that consume SCENARIOS + // (network-intercepted where declared, real DCR/authorize/consent flow + // where 'no-request'/'redirect-to-login-with-return' scenarios assert + // absence of a call) and points `--grep`/testDir at them here. Left as + // the explicit 08-10 seam rather than guessed at from this plan. + fatal('--final-gate Playwright matrix wiring is 08-10\'s responsibility; not implemented in 08-05.') +} + +// --------------------------------------------------------------------- +// entry +// --------------------------------------------------------------------- +const args = process.argv.slice(2) +if (args.includes('--contract-self-test')) { + runContractSelfTest() +} else if (args.includes('--final-gate')) { + runFinalGate() +} else { + console.error('usage: check-phase8-ui.mjs --contract-self-test | --final-gate') + process.exit(2) +}