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.
This commit is contained in:
472
scripts/check-phase8-ui.mjs
Normal file
472
scripts/check-phase8-ui.mjs
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user