diff --git a/package.json b/package.json index 50ac0b6..ef5193a 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,9 @@ "review:wp7-02": "node scripts/record-wp7-02-manual-review.mjs", "test:wp7-03": "node scripts/run-wp7-03-validation.mjs --phase green", "test:wp7-03:red": "node scripts/run-wp7-03-validation.mjs --phase red", - "test:wp7-04": "node scripts/run-wp7-04-validation.mjs" + "test:wp7-04": "node scripts/run-wp7-04-validation.mjs", + "test:wp7-05": "node scripts/run-wp7-05-validation.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/scripts/lib/wp7-05-ui-gate.mjs b/scripts/lib/wp7-05-ui-gate.mjs new file mode 100644 index 0000000..3900f9f --- /dev/null +++ b/scripts/lib/wp7-05-ui-gate.mjs @@ -0,0 +1,71 @@ +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 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.candidate_package?.sha256 || !record.candidate_package?.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 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 }); + 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-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/); +}); 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..6bf7731 --- /dev/null +++ b/tests/package/wp7-05-ui-gate.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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: [] }); + 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 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', + evidence: null, + dependencies: { 'TASK-WP7-03': 'externally_blocked' }, + }); + assert.equal(result.status, 'externally_blocked'); + assert.equal(result.code, 'candidate_record_missing'); +});