From 55646ba1b413c4b53364865cd78d9a40f662379f Mon Sep 17 00:00:00 2001 From: suyx Date: Tue, 4 Aug 2026 17:08:44 +0800 Subject: [PATCH] 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/); +});