#!/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) }