import fs from "node:fs"; import path from "node:path"; import { execFileSync, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const planPath = path.join(root, "tests/golden/WBV2/feature-smoke-plan.json"); const reportPath = path.join(root, "tests/golden/WBV2/feature-smoke-report.json"); const plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const report = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/WBV2-P1424-001/product-implementation-report.json"), "utf8")); const args = new Set(process.argv.slice(2)); const execute = args.has("--execute"); function fail(message) { throw new Error(`feature-smoke-plan-invalid: ${message}`); } if (plan.operation !== "ONE_PASS_FEATURE_SMOKE" || plan.policy.oneSmokePerFeature !== true) fail("missing one-pass policy"); if (plan.cases.length !== report.implementationTasks.length) fail(`case count ${plan.cases.length} != task count ${report.implementationTasks.length}`); const ids = new Set(); for (const smoke of plan.cases) { if (ids.has(smoke.featureId) || ids.has(smoke.smokeId)) fail(`duplicate feature or smoke id ${smoke.featureId}`); ids.add(smoke.featureId); ids.add(smoke.smokeId); if (smoke.runOnce !== true || smoke.maxAttempts !== 1) fail(`non-idempotent case ${smoke.featureId}`); if (smoke.kind === "playwright") { const file = path.join(root, "web", smoke.testFile); if (!fs.existsSync(file)) fail(`${smoke.featureId} test file missing: ${smoke.testFile}`); if (!fs.readFileSync(file, "utf8").includes(smoke.testTitle)) fail(`${smoke.featureId} test title missing: ${smoke.testTitle}`); } if (smoke.kind === "command" && !smoke.command) fail(`${smoke.featureId} command missing`); } const expected = new Set(report.implementationTasks.map((task) => task.id)); for (const id of expected) if (!ids.has(id)) fail(`implementation task has no smoke case: ${id}`); const runCheck = (command, cwd = root) => { const result = spawnSync("bash", ["-lc", command], { cwd, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 }); return { command, cwd: path.relative(root, cwd) || ".", exitCode: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" }; }; if (!execute) { process.stdout.write(`feature-smoke-plan-ok cases=${plan.cases.length} onePass=true playwright=${plan.cases.filter((item) => item.kind === "playwright").length} command=${plan.cases.filter((item) => item.kind === "command").length}\n`); process.exit(0); } if (fs.existsSync(reportPath)) { const previous = JSON.parse(fs.readFileSync(reportPath, "utf8")); if (previous.planId === plan.planId && Array.isArray(previous.cases) && previous.cases.some((item) => Number(item.attempts ?? 0) > 0)) { process.stderr.write(`feature-smoke-once-only: plan ${plan.planId} already has recorded attempts; create a new focused-fix batch instead of rerunning\n`); process.exit(2); } } const startedAt = new Date().toISOString(); const smokeReport = { schemaVersion: 1, planId: plan.planId, operation: "ONE_PASS_FEATURE_SMOKE_EXECUTION", browser: "chromium", startedAt, policy: plan.policy, status: "RUNNING", cases: plan.cases.map((item) => ({ featureId: item.featureId, smokeId: item.smokeId, ordinal: item.ordinal, status: "PENDING", attempts: 0 })), checks: [], }; const flush = () => fs.writeFileSync(reportPath, `${JSON.stringify(smokeReport, null, 2)}\n`); flush(); // These are global preconditions and run once; they are not counted as feature cases. for (const command of [ "node tools/web/check-blender-feature-route-catalog.mjs", "node tools/web/check-web-engine-command-registry.mjs", ]) { const result = runCheck(command); smokeReport.checks.push(result); if (result.exitCode !== 0) { smokeReport.status = "BLOCKED"; smokeReport.finishedAt = new Date().toISOString(); flush(); process.stderr.write(result.stderr || result.stdout); process.exit(1); } } // Group only cases that share one Playwright file. Each title is still selected // exactly once, and a failed group is never retried in this execution. const playwrightGroups = new Map(); for (const item of plan.cases.filter((candidate) => candidate.kind === "playwright")) { const list = playwrightGroups.get(item.testFile) ?? []; list.push(item); playwrightGroups.set(item.testFile, list); } for (const [testFile, items] of playwrightGroups) { const pattern = `(?:${items.map((item) => item.testTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`; const result = runCheck(`npx playwright test --config playwright.config.ts ${testFile} --grep '${pattern}' --workers=1`, path.join(root, "web")); smokeReport.checks.push(result); for (const item of items) { const entry = smokeReport.cases.find((candidate) => candidate.featureId === item.featureId); entry.attempts = 1; entry.status = result.exitCode === 0 ? "PASS" : "FAIL"; entry.evidence = { testFile, testTitle: item.testTitle, exitCode: result.exitCode }; } flush(); } for (const item of plan.cases.filter((candidate) => candidate.kind === "command")) { const result = runCheck(item.command); smokeReport.checks.push(result); const entry = smokeReport.cases.find((candidate) => candidate.featureId === item.featureId); entry.attempts = 1; entry.status = result.exitCode === 0 ? "PASS" : "FAIL"; entry.evidence = { command: item.command, exitCode: result.exitCode }; flush(); } const failed = smokeReport.cases.filter((item) => item.status === "FAIL"); const pending = smokeReport.cases.filter((item) => item.status !== "PASS"); smokeReport.status = failed.length ? "FAILED_FOCUSED_FIX_REQUIRED" : pending.length ? "BLOCKED" : "PASS"; smokeReport.finishedAt = new Date().toISOString(); smokeReport.summary = { total: smokeReport.cases.length, passed: smokeReport.cases.filter((item) => item.status === "PASS").length, failed: failed.length, pending: pending.length, retries: 0 }; flush(); process.stdout.write(`feature-smoke-${smokeReport.status.toLowerCase()} total=${smokeReport.summary.total} passed=${smokeReport.summary.passed} failed=${smokeReport.summary.failed} pending=${smokeReport.summary.pending} retries=0\n`); process.exit(failed.length || pending.length ? 1 : 0);