From 33f87f8db33cf9560a7458a8b1902079ec2a5667 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 16:58:15 +0800 Subject: [PATCH 1/4] test: scaffold WP7-05 candidate UI gate --- package.json | 4 +- scripts/lib/wp7-05-ui-gate.mjs | 66 +++++++++++++++++++++++++++ scripts/run-wp7-05-validation.mjs | 13 ++++++ tests/package/wp7-05-ui-gate.test.mjs | 20 ++++++++ 4 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/wp7-05-ui-gate.mjs create mode 100644 scripts/run-wp7-05-validation.mjs create mode 100644 tests/package/wp7-05-ui-gate.test.mjs diff --git a/package.json b/package.json index 95cc0b0..094102e 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,9 @@ "test:wp5-02:red": "node scripts/run-wp5-02-validation.mjs --phase red", "preview:wp5-02": "node scripts/run-wp5-02-manual-preview.mjs", "test:wp5-03": "node scripts/run-wp5-03-validation.mjs", - "test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red" + "test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red", + "test:wp7-05": "node scripts/run-wp7-05-validation.mjs", + "test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/lib/wp7-05-ui-gate.mjs b/scripts/lib/wp7-05-ui-gate.mjs new file mode 100644 index 0000000..52c2ad3 --- /dev/null +++ b/scripts/lib/wp7-05-ui-gate.mjs @@ -0,0 +1,66 @@ +import fs from 'node:fs'; + +export const REQUIRED_COVERAGE_UNITS = Object.freeze([ + 'support-gate', 'user-auth', 'workspace', 'current-task', 'projects', + 'project-detail', 'editor', 'export', 'credits', 'settings', + 'preview-user-variant', 'admin-auth', 'admin-overview', 'admin-users', + 'admin-invites', 'admin-models', 'admin-assets', 'admin-preview', + 'admin-generations', 'admin-services-storage', 'admin-audit', 'system-ui', +]); + +const REQUIRED_VIEWPORTS = Object.freeze([ + { width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 }, + { width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 }, +]); + +function blocked(code, details = {}) { + return { status: 'externally_blocked', code, ...details }; +} + +export function loadCandidateRecord(path) { + if (!path || !fs.existsSync(path)) return blocked('candidate_record_missing'); + try { + const record = JSON.parse(fs.readFileSync(path, 'utf8')); + if (!Array.isArray(record.browsers) || record.browsers.length !== 2) { + return blocked('candidate_browser_record_incomplete'); + } + const names = new Set(record.browsers.map((browser) => browser.name)); + if (names.size !== 2 || !names.has('Chrome') || !names.has('Edge')) { + return blocked('candidate_browser_pair_invalid'); + } + if (record.windows?.build == null || !record.artifact?.sha256 || !record.fixed_port) { + return blocked('candidate_identity_incomplete'); + } + if (record.browsers.some((browser) => !browser.full_version || !browser.major)) { + return blocked('candidate_full_version_missing'); + } + return { status: 'ready', record }; + } catch { + return blocked('candidate_record_invalid'); + } +} + +export function validateCoverageEvidence(evidence) { + if (!evidence || !Array.isArray(evidence.coverage_units)) { + return blocked('coverage_evidence_missing'); + } + const actual = new Set(evidence.coverage_units.map((unit) => unit.page_id)); + const missing = REQUIRED_COVERAGE_UNITS.filter((unit) => !actual.has(unit)); + if (missing.length) return blocked('coverage_units_incomplete', { missing }); + const viewportKeys = new Set((evidence.viewports ?? []).map((viewport) => JSON.stringify(viewport))); + const missingViewports = REQUIRED_VIEWPORTS.filter((viewport) => !viewportKeys.has(JSON.stringify(viewport))); + if (missingViewports.length) return blocked('candidate_viewports_incomplete', { missingViewports }); + return { status: 'ready' }; +} + +export function runWp705Gate({ candidatePath, evidence, dependencies = {} }) { + const candidate = loadCandidateRecord(candidatePath); + if (candidate.status !== 'ready') return candidate; + const coverage = validateCoverageEvidence(evidence); + if (coverage.status !== 'ready') return coverage; + const externalBlockers = Object.entries(dependencies) + .filter(([, status]) => status === 'externally_blocked') + .map(([task]) => task); + if (externalBlockers.length) return blocked('upstream_external_blocked', { externalBlockers }); + return { status: 'ready_for_execution' }; +} diff --git a/scripts/run-wp7-05-validation.mjs b/scripts/run-wp7-05-validation.mjs new file mode 100644 index 0000000..691cf95 --- /dev/null +++ b/scripts/run-wp7-05-validation.mjs @@ -0,0 +1,13 @@ +import { runWp705Gate } from './lib/wp7-05-ui-gate.mjs'; + +const result = runWp705Gate({ + candidatePath: process.env.WP7_01_CANDIDATE_RECORD, + evidence: null, + dependencies: { + 'TASK-WP7-03': 'externally_blocked', + 'TASK-WP7-04': 'externally_blocked', + }, +}); + +console.log(JSON.stringify({ task: 'TASK-WP7-05', ...result })); +process.exitCode = result.status === 'ready_for_execution' ? 0 : 3; diff --git a/tests/package/wp7-05-ui-gate.test.mjs b/tests/package/wp7-05-ui-gate.test.mjs new file mode 100644 index 0000000..4c3f753 --- /dev/null +++ b/tests/package/wp7-05-ui-gate.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateCoverageEvidence, runWp705Gate, REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs'; + +test('WP7-05 requires all 22 coverage units and both exact candidate viewports', () => { + const result = validateCoverageEvidence({ coverage_units: [], viewports: [] }); + assert.equal(result.status, 'externally_blocked'); + assert.equal(result.code, 'coverage_units_incomplete'); + assert.equal(result.missing.length, REQUIRED_COVERAGE_UNITS.length); +}); + +test('WP7-05 preserves upstream external blockers instead of declaring Green', () => { + const result = runWp705Gate({ + candidatePath: 'missing-release-candidate.json', + evidence: null, + dependencies: { 'TASK-WP7-03': 'externally_blocked' }, + }); + assert.equal(result.status, 'externally_blocked'); + assert.equal(result.code, 'candidate_record_missing'); +}); From e0e101ef284ca6eee3d4d1ac54fd5b13f7d62a02 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 16:59:31 +0800 Subject: [PATCH 2/4] fix: align WP7-05 candidate record schema --- scripts/lib/wp7-05-ui-gate.mjs | 6 +++--- tests/package/wp7-05-ui-gate.test.mjs | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/lib/wp7-05-ui-gate.mjs b/scripts/lib/wp7-05-ui-gate.mjs index 52c2ad3..9d61fa3 100644 --- a/scripts/lib/wp7-05-ui-gate.mjs +++ b/scripts/lib/wp7-05-ui-gate.mjs @@ -24,11 +24,11 @@ export function loadCandidateRecord(path) { if (!Array.isArray(record.browsers) || record.browsers.length !== 2) { return blocked('candidate_browser_record_incomplete'); } - const names = new Set(record.browsers.map((browser) => browser.name)); - if (names.size !== 2 || !names.has('Chrome') || !names.has('Edge')) { + const brands = new Set(record.browsers.map((browser) => browser.brand)); + if (brands.size !== 2 || !brands.has('Google Chrome') || !brands.has('Microsoft Edge')) { return blocked('candidate_browser_pair_invalid'); } - if (record.windows?.build == null || !record.artifact?.sha256 || !record.fixed_port) { + if (record.windows?.build == null || !record.candidate_package?.sha256 || !record.candidate_package?.fixed_port) { return blocked('candidate_identity_incomplete'); } if (record.browsers.some((browser) => !browser.full_version || !browser.major)) { diff --git a/tests/package/wp7-05-ui-gate.test.mjs b/tests/package/wp7-05-ui-gate.test.mjs index 4c3f753..694f6ff 100644 --- a/tests/package/wp7-05-ui-gate.test.mjs +++ b/tests/package/wp7-05-ui-gate.test.mjs @@ -1,6 +1,24 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { validateCoverageEvidence, runWp705Gate, REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadCandidateRecord, validateCoverageEvidence, runWp705Gate, REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs'; + +test('WP7-05 accepts the WP7-01 candidate record schema', () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'wp7-05-')); + const file = path.join(directory, 'release-candidate.json'); + fs.writeFileSync(file, JSON.stringify({ + browsers: [ + { brand: 'Google Chrome', full_version: '150.0.0', major: 150 }, + { brand: 'Microsoft Edge', full_version: '151.0.0', major: 151 }, + ], + windows: { build: '26200.8875' }, + candidate_package: { fixed_port: 43121, sha256: 'A'.repeat(64) }, + })); + assert.equal(loadCandidateRecord(file).status, 'ready'); + fs.rmSync(directory, { recursive: true, force: true }); +}); test('WP7-05 requires all 22 coverage units and both exact candidate viewports', () => { const result = validateCoverageEvidence({ coverage_units: [], viewports: [] }); From 8abf1397a6c262eaf81c1d0389dd3887087ae811 Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 17:02:52 +0800 Subject: [PATCH 3/4] test: require WP7-05 state evidence --- scripts/lib/wp7-05-ui-gate.mjs | 5 +++++ tests/package/wp7-05-ui-gate.test.mjs | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/scripts/lib/wp7-05-ui-gate.mjs b/scripts/lib/wp7-05-ui-gate.mjs index 9d61fa3..3900f9f 100644 --- a/scripts/lib/wp7-05-ui-gate.mjs +++ b/scripts/lib/wp7-05-ui-gate.mjs @@ -47,6 +47,11 @@ export function validateCoverageEvidence(evidence) { const actual = new Set(evidence.coverage_units.map((unit) => unit.page_id)); const missing = REQUIRED_COVERAGE_UNITS.filter((unit) => !actual.has(unit)); if (missing.length) return blocked('coverage_units_incomplete', { missing }); + const missingStates = evidence.coverage_units + .filter((unit) => REQUIRED_COVERAGE_UNITS.includes(unit.page_id)) + .filter((unit) => !Array.isArray(unit.states) || unit.states.length === 0) + .map((unit) => unit.page_id); + if (missingStates.length) return blocked('coverage_states_incomplete', { missingStates }); const viewportKeys = new Set((evidence.viewports ?? []).map((viewport) => JSON.stringify(viewport))); const missingViewports = REQUIRED_VIEWPORTS.filter((viewport) => !viewportKeys.has(JSON.stringify(viewport))); if (missingViewports.length) return blocked('candidate_viewports_incomplete', { missingViewports }); diff --git a/tests/package/wp7-05-ui-gate.test.mjs b/tests/package/wp7-05-ui-gate.test.mjs index 694f6ff..6bf7731 100644 --- a/tests/package/wp7-05-ui-gate.test.mjs +++ b/tests/package/wp7-05-ui-gate.test.mjs @@ -27,6 +27,15 @@ test('WP7-05 requires all 22 coverage units and both exact candidate viewports', assert.equal(result.missing.length, REQUIRED_COVERAGE_UNITS.length); }); +test('WP7-05 requires state evidence for every listed coverage unit', () => { + const result = validateCoverageEvidence({ + coverage_units: REQUIRED_COVERAGE_UNITS.map((page_id) => ({ page_id, states: [] })), + viewports: [], + }); + assert.equal(result.code, 'coverage_states_incomplete'); + assert.equal(result.missingStates.length, REQUIRED_COVERAGE_UNITS.length); +}); + test('WP7-05 preserves upstream external blockers instead of declaring Green', () => { const result = runWp705Gate({ candidatePath: 'missing-release-candidate.json', From 55646ba1b413c4b53364865cd78d9a40f662379f Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 17:08:44 +0800 Subject: [PATCH 4/4] feat: build sanitized WP7-05 coverage evidence --- package.json | 2 +- scripts/lib/wp7-05-coverage.mjs | 45 ++++++++++++++++++++++++++ tests/package/wp7-05-coverage.test.mjs | 41 +++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/wp7-05-coverage.mjs create mode 100644 tests/package/wp7-05-coverage.test.mjs diff --git a/package.json b/package.json index 094102e..d4af68a 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "test:wp5-03": "node scripts/run-wp5-03-validation.mjs", "test:wp5-03:red": "node scripts/run-wp5-03-validation.mjs --phase red", "test:wp7-05": "node scripts/run-wp7-05-validation.mjs", - "test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs" + "test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs tests/package/wp7-05-coverage.test.mjs" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/lib/wp7-05-coverage.mjs b/scripts/lib/wp7-05-coverage.mjs new file mode 100644 index 0000000..a721bbe --- /dev/null +++ b/scripts/lib/wp7-05-coverage.mjs @@ -0,0 +1,45 @@ +import path from 'node:path'; +import { REQUIRED_COVERAGE_UNITS } from './wp7-05-ui-gate.mjs'; + +const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\\\\)/; + +function assertSafeRelative(value, field) { + if (typeof value !== 'string' || !value || ABSOLUTE_PATH.test(value) || path.isAbsolute(value)) { + throw new Error(`WP7_05_UNSAFE_${field}`); + } + const normalized = value.replaceAll('\\', '/'); + if (normalized.split('/').includes('..')) throw new Error(`WP7_05_UNSAFE_${field}`); + return normalized; +} + +export function buildCoverageEvidence({ runId, candidateSha256, coverageUnits, viewports }) { + if (!runId || !/^[A-Za-z0-9._-]+$/.test(runId)) throw new Error('WP7_05_INVALID_RUN_ID'); + if (!/^[A-Fa-f0-9]{64}$/.test(candidateSha256 ?? '')) throw new Error('WP7_05_INVALID_CANDIDATE_HASH'); + if (!Array.isArray(coverageUnits)) throw new Error('WP7_05_COVERAGE_UNITS_REQUIRED'); + + const byPage = new Map(); + for (const unit of coverageUnits) { + if (!REQUIRED_COVERAGE_UNITS.includes(unit.page_id)) throw new Error('WP7_05_UNKNOWN_PAGE'); + if (byPage.has(unit.page_id)) throw new Error('WP7_05_DUPLICATE_PAGE'); + if (!Array.isArray(unit.states) || unit.states.length === 0) throw new Error('WP7_05_STATES_REQUIRED'); + const states = unit.states.map((state) => ({ + state: assertSafeRelative(state.state, 'STATE'), + screenshot_100pct: assertSafeRelative(state.screenshot_100pct, 'SCREENSHOT'), + screenshot_200pct: assertSafeRelative(state.screenshot_200pct, 'SCREENSHOT'), + trace: assertSafeRelative(state.trace, 'TRACE'), + })); + byPage.set(unit.page_id, { page_id: unit.page_id, states }); + } + const missing = REQUIRED_COVERAGE_UNITS.filter((page) => !byPage.has(page)); + if (missing.length) throw new Error(`WP7_05_MISSING_PAGES:${missing.join(',')}`); + if (!Array.isArray(viewports) || viewports.length !== 2) throw new Error('WP7_05_VIEWPORTS_REQUIRED'); + + return { + schema_version: '1.0', + task: 'TASK-WP7-05', + run_id: runId, + candidate_sha256: candidateSha256.toUpperCase(), + viewports, + coverage_units: REQUIRED_COVERAGE_UNITS.map((page) => byPage.get(page)), + }; +} diff --git a/tests/package/wp7-05-coverage.test.mjs b/tests/package/wp7-05-coverage.test.mjs new file mode 100644 index 0000000..2f58a01 --- /dev/null +++ b/tests/package/wp7-05-coverage.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildCoverageEvidence } from '../../scripts/lib/wp7-05-coverage.mjs'; +import { REQUIRED_COVERAGE_UNITS } from '../../scripts/lib/wp7-05-ui-gate.mjs'; + +const viewports = [ + { width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 }, + { width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 }, +]; + +function completeUnits() { + return REQUIRED_COVERAGE_UNITS.map((page_id) => ({ + page_id, + states: [{ + state: 'normal', + screenshot_100pct: `ui/${page_id}/normal/100pct.png`, + screenshot_200pct: `ui/${page_id}/normal/200pct.png`, + trace: `ui/${page_id}/normal/trace.zip`, + }], + })); +} + +test('builds ordered, sanitized evidence for all 22 coverage units', () => { + const evidence = buildCoverageEvidence({ + runId: 'wp7-05-red-001', + candidateSha256: 'a'.repeat(64), + coverageUnits: completeUnits(), + viewports, + }); + assert.equal(evidence.coverage_units.length, 22); + assert.equal(evidence.coverage_units[0].page_id, 'support-gate'); + assert.equal(evidence.candidate_sha256, 'A'.repeat(64)); +}); + +test('rejects absolute evidence paths and incomplete page state', () => { + const units = completeUnits(); + units[0].states[0].trace = 'C:\\secret\\trace.zip'; + assert.throws(() => buildCoverageEvidence({ + runId: 'wp7-05-red-001', candidateSha256: 'a'.repeat(64), coverageUnits: units, viewports, + }), /WP7_05_UNSAFE_TRACE/); +});