233 lines
11 KiB
JavaScript
233 lines
11 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
import { WP4_07_SOURCE_HASHES, wp407FixtureSha256 } from "../tests/visual-performance/wp4-07-fixture.mjs";
|
|
import { readWp5RemoteGate, validateWp407FrozenInputs } from "./lib/wp4-07-gate.mjs";
|
|
|
|
const phaseIndex = process.argv.indexOf("--phase");
|
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
|
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
|
|
|
if (phase === "green") {
|
|
const commands = [
|
|
["visual", "pnpm test:visual"],
|
|
["performance", "pnpm test:performance"],
|
|
["tdd-trace", "pnpm validate:tdd-trace"],
|
|
].map(([name, command]) => {
|
|
const started_at = new Date().toISOString();
|
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
|
|
encoding: "utf8",
|
|
env: process.env,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
return {
|
|
command,
|
|
exit_code: result.status ?? 1,
|
|
finished_at: new Date().toISOString(),
|
|
name,
|
|
started_at,
|
|
};
|
|
});
|
|
const status = commands.every((command) => command.exit_code === 0) ? "automated_green_pending_manual" : "failed";
|
|
console.log(JSON.stringify({ commands, phase, status }, null, 2));
|
|
if (status === "failed") process.exit(1);
|
|
process.exit(0);
|
|
}
|
|
|
|
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-07-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
|
const runDirectory = resolve("artifacts", "tdd", runId);
|
|
const casesDirectory = resolve(runDirectory, "cases");
|
|
const visualDirectory = resolve(casesDirectory, "TDD-WP4-VIS-001-browser-diff");
|
|
const performanceDirectory = resolve(casesDirectory, "TDD-WP4-PERF-001-budget");
|
|
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
|
mkdirSync(visualDirectory, { recursive: true });
|
|
mkdirSync(performanceDirectory, { recursive: true });
|
|
|
|
const fixture = validateWp407FrozenInputs();
|
|
const remoteGate = readWp5RemoteGate();
|
|
const environment = {
|
|
...process.env,
|
|
DADA_TDD_RUN_ID: runId,
|
|
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"),
|
|
DADA_WP4_07_EVIDENCE_DIR: casesDirectory,
|
|
...(phase === "red" ? { DADA_WP4_07_HARNESS_MODE: "red_contract" } : {}),
|
|
};
|
|
|
|
function run(name, command, expected) {
|
|
const started_at = new Date().toISOString();
|
|
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
|
|
encoding: "utf8",
|
|
env: environment,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
return {
|
|
command,
|
|
exit_code: result.status ?? 1,
|
|
expected,
|
|
finished_at: new Date().toISOString(),
|
|
name,
|
|
started_at,
|
|
};
|
|
}
|
|
|
|
function runDirect(name, executable, args, expected) {
|
|
const started_at = new Date().toISOString();
|
|
const result = spawnSync(executable, args, {
|
|
encoding: "utf8",
|
|
env: environment,
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
return {
|
|
command: [executable, ...args].join(" "),
|
|
exit_code: result.status ?? 1,
|
|
expected,
|
|
finished_at: new Date().toISOString(),
|
|
name,
|
|
started_at,
|
|
};
|
|
}
|
|
|
|
const commands = [
|
|
run("fixture-contract", "node --test tests/visual-performance/wp4-07-fixture.test.mjs tests/visual-performance/wp4-07-gate.test.mjs", "zero"),
|
|
run("build-browser-dependencies", "pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer build", "zero"),
|
|
run("browser-harness", "pnpm exec playwright test --config playwright.wp4-07.config.ts", "zero"),
|
|
runDirect("visual-diff", process.execPath, ["scripts/compare-wp4-07-screenshots.mjs", "--evidence", visualDirectory, "--phase", "red"], "zero"),
|
|
runDirect("performance-aggregation", process.execPath, ["scripts/aggregate-wp4-07-performance.mjs", "--evidence", performanceDirectory, "--phase", "red"], "zero"),
|
|
run("visual-green-gate", "pnpm test:visual", "nonzero_wp5_gate"),
|
|
run("performance-green-gate", "pnpm test:performance", "nonzero_wp5_gate"),
|
|
run("tdd-trace", "pnpm validate:tdd-trace", "zero"),
|
|
];
|
|
|
|
function findFiles(directory, name) {
|
|
if (!existsSync(directory)) return [];
|
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
const path = resolve(directory, entry.name);
|
|
return entry.isDirectory() ? findFiles(path, name) : entry.name === name ? [path] : [];
|
|
});
|
|
}
|
|
|
|
if (phase === "red") {
|
|
const traces = findFiles(resolve(runDirectory, "playwright-output"), "trace.zip");
|
|
for (const [caseDirectory, familyNeedle] of [[visualDirectory, "editor-export-evidence"], [performanceDirectory, "budget-without-dilution"]]) {
|
|
for (const browser of ["chrome", "edge"]) {
|
|
const evidenceTrace = resolve(caseDirectory, browser, "trace.zip");
|
|
if (existsSync(evidenceTrace)) continue;
|
|
const trace = traces.find((path) => path.toLowerCase().includes(familyNeedle) && path.toLowerCase().includes(browser));
|
|
if (trace) {
|
|
mkdirSync(resolve(caseDirectory, browser), { recursive: true });
|
|
copyFileSync(trace, resolve(caseDirectory, browser, "trace.zip"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const commandExpected = commands.every((command) => command.expected === "zero" ? command.exit_code === 0 : command.exit_code !== 0);
|
|
const redConfirmed = phase === "red" && !remoteGate.complete && commandExpected;
|
|
const observation = {
|
|
eligible_for_green: false,
|
|
expected_failure: "Final WP-5 task SHAs, immutable release inputs, real fonts, and the final renderer are unavailable, so Chrome/Edge visual and performance results cannot become Green.",
|
|
fixture_sha256: fixture.fixture_sha256,
|
|
candidate_baseline_branch: remoteGate.candidate_baseline_branch,
|
|
candidate_baseline_sha: remoteGate.candidate_baseline_sha,
|
|
missing_remote_tasks: remoteGate.missing_tasks,
|
|
observed_remote_heads: remoteGate.heads,
|
|
observed_task_shas: remoteGate.task_shas,
|
|
placeholder_policy: "red_contract resources are harness smoke inputs only and are rejected by the Green gate",
|
|
status: redConfirmed ? "red_confirmed" : "failed",
|
|
};
|
|
for (const directory of [visualDirectory, performanceDirectory]) {
|
|
writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`);
|
|
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId }, null, 2)}\n`);
|
|
}
|
|
|
|
const commit = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
|
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
|
const manifestSha = createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase();
|
|
const definitions = [
|
|
{
|
|
acceptance_criteria: ["AC-19", "AC-23", "AC-32"],
|
|
automation: ["automated", "manual_review"],
|
|
directory: visualDirectory,
|
|
evidence_refs: [
|
|
"red-observation.json", "pixel-diff.json", "layout-boxes.json", "manual-review.json",
|
|
"chrome/editor.png", "chrome/canvas.png", "chrome/export-dialog.png", "chrome/layout-boxes.json", "chrome/trace.zip",
|
|
"edge/editor.png", "edge/canvas.png", "edge/export-dialog.png", "edge/layout-boxes.json", "edge/trace.zip",
|
|
],
|
|
green_assertions: ["Chrome/Edge structure, fonts, wrapping, color, stroke, and decoration remain within the fixed section 10.1 thresholds", "known substitutions receive manual review"],
|
|
layer: ["VIS-PERF", "MANUAL"],
|
|
red_reason: "Chrome/Edge 白名单结构或导出漂移",
|
|
requirements: ["NFR-02"],
|
|
test_id: "TDD-WP4-VIS-001-browser-diff",
|
|
},
|
|
{
|
|
acceptance_criteria: ["AC-27", "AC-32"],
|
|
automation: ["automated"],
|
|
directory: performanceDirectory,
|
|
evidence_refs: [
|
|
"red-observation.json", "performance.json", "memory.json", "dom-count.json", "environment.json",
|
|
"chrome/performance-raw.json", "chrome/trace.zip", "edge/performance-raw.json", "edge/trace.zip",
|
|
],
|
|
green_assertions: ["all section 10.2 budgets pass in both real browsers", "export failure leaves the project and latest export unchanged"],
|
|
layer: ["VIS-PERF"],
|
|
red_reason: "50 元素、自动保存、资源面板或导出超过预算",
|
|
requirements: ["NFR-03"],
|
|
test_id: "TDD-WP4-PERF-001-budget",
|
|
},
|
|
];
|
|
|
|
const summaries = definitions.map((item) => {
|
|
const missing = item.evidence_refs.filter((path) => !existsSync(resolve(item.directory, path)));
|
|
const status = phase === "red"
|
|
? redConfirmed && missing.length === 0 ? "red_confirmed" : "failed"
|
|
: commandExpected && missing.length === 0 ? "passed" : "failed";
|
|
writeFileSync(resolve(item.directory, "result.json"), `${JSON.stringify({
|
|
acceptance_criteria: item.acceptance_criteria,
|
|
automation: item.automation,
|
|
commit,
|
|
evidence_refs: item.evidence_refs,
|
|
fixture_ids: ["FX-CANVAS-50"],
|
|
green_assertions: item.green_assertions,
|
|
layer: item.layer,
|
|
manifest: { path: "tasks.manifest.json", sha256: manifestSha },
|
|
missing_evidence: missing,
|
|
phase,
|
|
red_reason: item.red_reason,
|
|
release_gate: ["work_package:WP-4", "release:P0-A"],
|
|
requirements: item.requirements,
|
|
run_id: runId,
|
|
status,
|
|
task_id: "TASK-WP4-07",
|
|
test_id: item.test_id,
|
|
work_package: "WP-4",
|
|
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
|
}, null, 2)}\n`);
|
|
return { missing_evidence: missing, status, test_id: item.test_id };
|
|
});
|
|
const expectedStatus = phase === "red" ? "red_confirmed" : "passed";
|
|
const status = summaries.every((summary) => summary.status === expectedStatus) ? expectedStatus : "failed";
|
|
const evidence = {
|
|
automation: ["automated", "manual_review"],
|
|
cases: summaries,
|
|
commit,
|
|
fixture_sha256: wp407FixtureSha256(),
|
|
phase,
|
|
redaction_scan: "passed",
|
|
release_gate: ["work_package:WP-4", "release:P0-A"],
|
|
remote_gate: remoteGate,
|
|
run_id: runId,
|
|
source_hashes: WP4_07_SOURCE_HASHES,
|
|
status,
|
|
};
|
|
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId }, null, 2)}\n`);
|
|
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(evidence, null, 2)}\n`);
|
|
console.log(JSON.stringify({ cases: summaries, phase, remote_gate: remoteGate, run_id: runId, status }, null, 2));
|
|
if (status === "failed") process.exit(1);
|