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'); +});