@tea-agent/loop-agent 0.24.11-beta.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -5
- package/dist/commands/init.js +16 -2
- package/dist/executors/dag-pi-executor.js +74 -13
- package/dist/executors/pi-executor.js +25 -7
- package/dist/executors/pi-prompt-transport.js +198 -0
- package/dist/executors/pi-sdk-executor.js +1 -1
- package/dist/executors/process-tree.js +33 -0
- package/dist/executors/shell-executor.js +332 -24
- package/dist/executors/shell-verification.js +4 -2
- package/dist/infrastructure/harness/task-store.js +31 -0
- package/dist/shared/operator/capabilities.js +6 -0
- package/dist/verification/maven/cache.js +142 -0
- package/dist/verification/maven/index.js +120 -0
- package/dist/verification/maven/plan-commands.js +421 -0
- package/dist/verification/maven/pom-static.js +136 -0
- package/dist/verification/maven/scope-resolve.js +153 -0
- package/dist/verification/maven/stale.js +130 -0
- package/dist/verification/maven/types.js +23 -0
- package/dist/verification/maven/workspace-graph.js +322 -0
- package/dist/worker/cli.js +236 -21
- package/dist/worker/delivery/final-verification.js +12 -0
- package/dist/worker/delivery/git-transaction.js +32 -7
- package/dist/worker/delivery/package.js +9 -5
- package/dist/worker/delivery/verification-bundle.js +26 -5
- package/dist/worker/feature/advance.js +301 -0
- package/dist/worker/feature/doctor.js +223 -0
- package/dist/worker/feature/next-action.js +11 -3
- package/dist/worker/feature/scaffold.js +798 -0
- package/dist/worker/observe/dag-run-artifacts.js +90 -0
- package/dist/worker/observe/node-input.js +444 -0
- package/dist/worker/observe/routes.js +17 -0
- package/dist/worker/observe/static/api.js +9 -0
- package/dist/worker/observe/static/constants.js +9 -0
- package/dist/worker/observe/static/state.js +14 -0
- package/dist/worker/observe/static/styles.css +74 -0
- package/dist/worker/observe/static/views/dag-inspector.js +371 -15
- package/dist/worker/outcomes/projector.js +5 -1
- package/dist/worker/runner/run-ready.js +41 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +202 -9
- package/dist/workflows/dag/backend-test-result-contract.js +103 -0
- package/dist/workflows/dag/failure-category.js +5 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +4 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +0 -17
- package/dist/workflows/dag/frontend-test-result-contract.js +106 -41
- package/dist/workflows/dag/init-hybrid.js +220 -32
- package/dist/workflows/dag/node-execution.js +3 -2
- package/dist/workflows/dag/reconcile-run.js +24 -0
- package/dist/workflows/dag/types.js +46 -0
- package/dist/workflows/dag/validate.js +19 -2
- package/docs/architecture/dag-execution.md +5 -1
- package/docs/architecture/runtime-boundaries.md +11 -1
- package/docs/architecture/worker-and-feature.md +2 -0
- package/docs/templates/backend-test-dag.json +3 -3
- package/docs/templates/product-line/README.md +17 -1
- package/docs/templates/product-line/feature-scaffold-batch.example.yaml +31 -0
- package/docs/templates/product-line/scaffold-samples/README.md +36 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/BE-IMPL-001.yaml +83 -0
- package/docs/templates/product-line/scaffold-samples/backend-only/tasks/task-graph.yaml +14 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +15 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/design.md +18 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/BE-IMPL-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/CONTRACT-001.yaml +84 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/FE-IMPL-001.yaml +86 -0
- package/docs/templates/product-line/scaffold-samples/fe-with-api/tasks/task-graph.yaml +40 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +13 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/design.md +14 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +3 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/requirement.md +6 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/FE-IMPL-001.yaml +85 -0
- package/docs/templates/product-line/scaffold-samples/frontend-only/tasks/task-graph.yaml +14 -0
- package/examples/l5-report-coms-process-definition.html +322 -0
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +84 -0
- package/skills/frontend-implementation/references/node-contracts.md +2 -2
- package/skills/loop-agent/SKILL.md +8 -8
- package/skills/loop-agent/references/command-reference.md +14 -2
- package/skills/loop-agent/references/harness-policy.md +22 -0
- package/skills/loop-agent/references/task-workflow.md +5 -0
|
@@ -413,6 +413,18 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
413
413
|
if (!hasAssertableExpectedResult(expected)) {
|
|
414
414
|
findings.push(`${testCase.id} has no structured expected result`);
|
|
415
415
|
}
|
|
416
|
+
const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
|
|
417
|
+
const mappedScripts = extractMappedPytestScripts(testCase.body);
|
|
418
|
+
if (mappedScripts.length === 0) {
|
|
419
|
+
findings.push(`${testCase.id} has no mapped pytest script in Automation Notes/自动化映射 (expected ${expectedScript} from module ${path.basename(relativeFile)})`);
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
for (const script of mappedScripts) {
|
|
423
|
+
if (script !== expectedScript) {
|
|
424
|
+
findings.push(`${testCase.id} automation mapping ${script} must equal module one-to-one path ${expectedScript} (from ${path.basename(relativeFile)})`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
416
428
|
}
|
|
417
429
|
}
|
|
418
430
|
const missing = input.requiredRequirementIds.filter((id) => !coveredAc.has(id));
|
|
@@ -454,6 +466,27 @@ export async function validateBackendMarkdownCases(input) {
|
|
|
454
466
|
function collectMarkdownCaseIds(markdown) {
|
|
455
467
|
return splitCases(markdown).map((testCase) => testCase.id);
|
|
456
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Derive the stable module stem used for Markdown ↔ pytest one-to-one mapping.
|
|
471
|
+
* `testcase/md/<module>.md` → stem → `testcase/test_<stem>.py`.
|
|
472
|
+
* Example: `BE-HEALTH.md` / `be-health.md` / `order-api.md` → `be_health` / `order_api`.
|
|
473
|
+
*/
|
|
474
|
+
export function normalizeBackendTestModuleStem(moduleFileName) {
|
|
475
|
+
const base = path.basename(moduleFileName).replace(/\.md$/i, "");
|
|
476
|
+
const stem = base
|
|
477
|
+
.toLowerCase()
|
|
478
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
479
|
+
.replace(/^_+|_+$/g, "")
|
|
480
|
+
.replace(/_+/g, "_");
|
|
481
|
+
if (!stem) {
|
|
482
|
+
throw new Error(`cannot derive backend-test module stem from: ${moduleFileName}`);
|
|
483
|
+
}
|
|
484
|
+
return stem;
|
|
485
|
+
}
|
|
486
|
+
/** Expected pytest script path for a Markdown module file (basename or relative path). */
|
|
487
|
+
export function expectedBackendTestPytestScriptForMarkdownModule(moduleFileName) {
|
|
488
|
+
return `testcase/test_${normalizeBackendTestModuleStem(moduleFileName)}.py`;
|
|
489
|
+
}
|
|
457
490
|
function extractMappedPytestScripts(testCaseBody) {
|
|
458
491
|
const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
|
|
459
492
|
if (!automation.trim())
|
|
@@ -462,6 +495,15 @@ function extractMappedPytestScripts(testCaseBody) {
|
|
|
462
495
|
.map((match) => match[1].replaceAll("\\", "/"))
|
|
463
496
|
.filter((value) => !value.includes("..")));
|
|
464
497
|
}
|
|
498
|
+
function isSafeBackendPytestScript(script, workspaceRoot) {
|
|
499
|
+
const absolute = path.resolve(workspaceRoot, script);
|
|
500
|
+
const relative = path.relative(workspaceRoot, absolute).replaceAll(path.sep, "/");
|
|
501
|
+
return (!relative.startsWith("..") &&
|
|
502
|
+
!path.isAbsolute(relative) &&
|
|
503
|
+
script.startsWith("testcase/") &&
|
|
504
|
+
!script.includes("..") &&
|
|
505
|
+
/^test_.*\.py$/i.test(path.basename(script)));
|
|
506
|
+
}
|
|
465
507
|
function testFunctionRegion(input) {
|
|
466
508
|
const functionLineStart = input.source.lastIndexOf("\n", input.functionIndex - 1) + 1;
|
|
467
509
|
const functionLine = input.source.slice(functionLineStart, input.functionHeaderEnd);
|
|
@@ -610,26 +652,50 @@ export async function collectBackendTestMappedPytestScripts(workspaceRoot) {
|
|
|
610
652
|
for (const file of files) {
|
|
611
653
|
if (path.basename(file).toLowerCase() === "readme.md")
|
|
612
654
|
continue;
|
|
655
|
+
const relativeFile = path.relative(workspaceRoot, file).replaceAll(path.sep, "/");
|
|
656
|
+
const expectedScript = expectedBackendTestPytestScriptForMarkdownModule(relativeFile);
|
|
613
657
|
const markdown = await readFile(file, "utf8");
|
|
658
|
+
const mappedFromModule = new Set();
|
|
614
659
|
for (const testCase of splitCases(markdown)) {
|
|
615
|
-
for (const script of extractMappedPytestScripts(testCase.body))
|
|
660
|
+
for (const script of extractMappedPytestScripts(testCase.body)) {
|
|
661
|
+
mappedFromModule.add(script);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// Prefer explicit mappings, but recover to the deterministic module stem path
|
|
665
|
+
// when the model wrote the correct one-to-one file while Markdown still names
|
|
666
|
+
// a drifted script (common: health.md maps test_health.py but writer emitted
|
|
667
|
+
// test_be_health.py for BE-HEALTH.md, or the reverse).
|
|
668
|
+
if (mappedFromModule.size === 0) {
|
|
669
|
+
if (await exists(path.resolve(workspaceRoot, expectedScript))) {
|
|
670
|
+
scripts.add(expectedScript);
|
|
671
|
+
}
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
for (const script of mappedFromModule) {
|
|
675
|
+
if (!isSafeBackendPytestScript(script, workspaceRoot)) {
|
|
676
|
+
throw new Error(`unsafe mapped pytest script: ${script}`);
|
|
677
|
+
}
|
|
678
|
+
if (await exists(path.resolve(workspaceRoot, script))) {
|
|
616
679
|
scripts.add(script);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
if (isSafeBackendPytestScript(expectedScript, workspaceRoot) &&
|
|
683
|
+
(await exists(path.resolve(workspaceRoot, expectedScript)))) {
|
|
684
|
+
scripts.add(expectedScript);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
throw new Error(`mapped pytest script is missing: ${script} (module one-to-one path ${expectedScript} also missing)`);
|
|
617
688
|
}
|
|
618
689
|
}
|
|
619
690
|
if (scripts.size === 0) {
|
|
620
691
|
throw new Error("no pytest scripts are mapped by final Markdown cases");
|
|
621
692
|
}
|
|
622
693
|
const safeScripts = [];
|
|
623
|
-
for (const script of unique(scripts)) {
|
|
624
|
-
|
|
625
|
-
const relative = path.relative(workspaceRoot, absolute);
|
|
626
|
-
if (relative.startsWith("..") ||
|
|
627
|
-
path.isAbsolute(relative) ||
|
|
628
|
-
!script.startsWith("testcase/") ||
|
|
629
|
-
!/^test_.*\.py$/i.test(path.basename(script))) {
|
|
694
|
+
for (const script of unique([...scripts]).sort()) {
|
|
695
|
+
if (!isSafeBackendPytestScript(script, workspaceRoot)) {
|
|
630
696
|
throw new Error(`unsafe mapped pytest script: ${script}`);
|
|
631
697
|
}
|
|
632
|
-
if (!(await exists(
|
|
698
|
+
if (!(await exists(path.resolve(workspaceRoot, script)))) {
|
|
633
699
|
throw new Error(`mapped pytest script is missing: ${script}`);
|
|
634
700
|
}
|
|
635
701
|
safeScripts.push(script);
|
|
@@ -1013,6 +1079,133 @@ function renderQualityCard(title, summary) {
|
|
|
1013
1079
|
const label = summary.status === "Unavailable" ? "不可用" : summary.status;
|
|
1014
1080
|
return `<div style="padding:12px 14px;border:1px solid #e3e8ef;border-radius:10px;background:#fbfcfe"><div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px"><strong style="color:#17365d;font-size:0.88rem">${escapeHtml(title)}</strong><span style="display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:0.72rem;color:${colors.fg};background:${colors.bg}">${label}</span></div><div style="color:#667085;font-size:0.78rem">Findings:${summary.findings ?? "不可用"}</div><div style="color:#475467;font-size:0.8rem;margin-top:2px">${escapeHtml(summary.firstFinding)}</div></div>`;
|
|
1015
1081
|
}
|
|
1082
|
+
function inferModuleScript(classname) {
|
|
1083
|
+
// classname like "test_process_definition_list.py::TestX::test_a" → take the file stem.
|
|
1084
|
+
const first = classname.split("::")[0] ?? classname;
|
|
1085
|
+
return first.endsWith(".py") ? first : `${first}.py`;
|
|
1086
|
+
}
|
|
1087
|
+
function collectModuleStats(parsed) {
|
|
1088
|
+
const map = new Map();
|
|
1089
|
+
for (const c of parsed.cases) {
|
|
1090
|
+
const script = inferModuleScript(c.classname || c.name);
|
|
1091
|
+
const stat = map.get(script) ?? { script, total: 0, passed: 0, failed: 0, errors: 0 };
|
|
1092
|
+
stat.total += 1;
|
|
1093
|
+
if (c.status === "passed")
|
|
1094
|
+
stat.passed += 1;
|
|
1095
|
+
else if (c.status === "failure")
|
|
1096
|
+
stat.failed += 1;
|
|
1097
|
+
else if (c.status === "error")
|
|
1098
|
+
stat.errors += 1;
|
|
1099
|
+
map.set(script, stat);
|
|
1100
|
+
}
|
|
1101
|
+
return [...map.values()].sort((a, b) => a.script.localeCompare(b.script));
|
|
1102
|
+
}
|
|
1103
|
+
function l5StatusBadge(status) {
|
|
1104
|
+
if (status === "pass")
|
|
1105
|
+
return { label: "达标", fg: "#15815c", bg: "#e7f7f0" };
|
|
1106
|
+
if (status === "fail")
|
|
1107
|
+
return { label: "未达标", fg: "#d34661", bg: "#fff0f3" };
|
|
1108
|
+
return { label: "不可用", fg: "#718097", bg: "#eef2f7" };
|
|
1109
|
+
}
|
|
1110
|
+
function l5GateRow(name, metric, valueLabel, footLabel, evidence) {
|
|
1111
|
+
const badge = l5StatusBadge(metric.status);
|
|
1112
|
+
const pct = metric.ratio !== null ? Math.round(metric.ratio * 100) : null;
|
|
1113
|
+
const meterWidth = metric.status === "unavailable" ? 0 : pct ?? 0;
|
|
1114
|
+
const meterClass = metric.status === "pass" ? "#15815c" : metric.status === "fail" ? "#d34661" : "#e9a040";
|
|
1115
|
+
const meter = `<div style="height:6px;margin:10px 0 7px;background:#e7edf5;border-radius:99px;overflow:hidden"><i style="display:block;height:100%;width:${meterWidth}%;border-radius:inherit;background:${meterClass}"></i></div>`;
|
|
1116
|
+
const evidenceBlock = evidence
|
|
1117
|
+
? `<details style="margin-top:10px;padding-top:9px;border-top:1px solid #e5ebf3"><summary style="color:#4775ef;font-size:12px;font-weight:750;cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px">查看依据</summary><div style="margin-top:7px;color:#718097;font-size:12px">${evidence}</div></details>`
|
|
1118
|
+
: "";
|
|
1119
|
+
return `<div style="padding:14px 15px;border:1px solid #e5ebf3;border-radius:11px;background:#fcfdff;margin-top:8px"><div style="display:flex;align-items:center;gap:10px"><span style="flex:1;color:#263c5e;font-size:13px;font-weight:750">${escapeHtml(name)}</span><strong style="font-size:15px;font-weight:800;color:${badge.fg}">${escapeHtml(valueLabel)}</strong><span style="display:inline-flex;align-items:center;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:800;color:${badge.fg};background:${badge.bg}">${badge.label}</span></div>${meter}<div style="display:flex;justify-content:space-between;color:#718097;font-size:12px"><span>${escapeHtml(footLabel)}</span></div>${evidenceBlock}</div>`;
|
|
1120
|
+
}
|
|
1121
|
+
export function renderBackendTestL5Dashboard(input) {
|
|
1122
|
+
const m = input.metrics.metrics;
|
|
1123
|
+
const decision = input.metrics.status === "ready" ? { text: "READY", color: "#15815c", bg: "rgba(21,129,92,.12)", border: "rgba(120,200,170,.4)", small: "所有 L-5 门禁达标" }
|
|
1124
|
+
: { text: "NOT READY", color: "#d34661", bg: "rgba(211,70,97,.12)", border: "rgba(255,177,192,.4)", small: `${input.metrics.blockingItems.length} 个阻断项需要处理` };
|
|
1125
|
+
const passRatePct = m.passRate.ratio !== null ? (m.passRate.ratio * 100).toFixed(2) : "—";
|
|
1126
|
+
const acPct = m.acCoverage.ratio !== null ? (m.acCoverage.ratio * 100).toFixed(2) : null;
|
|
1127
|
+
const autoPct = m.automationCoverage.ratio !== null ? (m.automationCoverage.ratio * 100).toFixed(2) : null;
|
|
1128
|
+
// Donut SVG: pass arc + fail arc on a r=46 circle (circumference ≈ 289.03).
|
|
1129
|
+
const circ = 2 * Math.PI * 46;
|
|
1130
|
+
const executed = input.parsed.passed + input.parsed.failed + input.parsed.errors;
|
|
1131
|
+
const passLen = executed > 0 ? (input.parsed.passed / executed) * circ : 0;
|
|
1132
|
+
const failLen = executed > 0 ? ((input.parsed.failed + input.parsed.errors) / executed) * circ : 0;
|
|
1133
|
+
const signal = (label, strong, width, markerLeft, trackClass, small) => `<div style="margin-top:14px"><div style="display:flex;justify-content:space-between;align-items:baseline;color:#2b405f;font-size:12px"><span>${escapeHtml(label)}</span><strong style="color:#17365d;font-size:14px">${escapeHtml(strong)}</strong></div><div style="position:relative;height:8px;margin:7px 0 4px;background:#edf1f6;border-radius:99px"><i style="display:block;height:100%;width:${width}%;border-radius:inherit;background:${trackClass}"></i><b style="position:absolute;top:-3px;left:${markerLeft}%;width:2px;height:14px;background:#273b59;border-radius:2px"></b></div><small style="color:#718097;font-size:10px">${escapeHtml(small)}</small></div>`;
|
|
1134
|
+
const acStrong = acPct !== null ? `${acPct}%` : "—";
|
|
1135
|
+
const acWidth = acPct !== null ? Number(acPct) : 0;
|
|
1136
|
+
const autoStrong = autoPct !== null ? `${autoPct}%` : "—";
|
|
1137
|
+
const autoWidth = autoPct !== null ? Number(autoPct) : 0;
|
|
1138
|
+
const moduleBars = collectModuleStats(input.parsed).map((stat) => {
|
|
1139
|
+
const passPct = stat.total > 0 ? Math.round((stat.passed / stat.total) * 100) : 0;
|
|
1140
|
+
const failPct = stat.total > 0 ? 100 - passPct : 0;
|
|
1141
|
+
const badge = stat.failed + stat.errors === 0 ? { fg: "#15815c", bg: "#e7f7f0", t: `${stat.passed}/${stat.total}` } : { fg: "#d34661", bg: "#fff0f3", t: `${stat.passed}/${stat.total}` };
|
|
1142
|
+
return `<div style="padding:13px 14px;border:1px solid #e4e9f1;border-radius:11px;background:#fbfcfe"><div style="display:flex;justify-content:space-between;align-items:center;gap:8px"><span style="color:#2a3c5a;font-size:12px;font-weight:700;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(stat.script)}</span><span style="display:inline-flex;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800;color:${badge.fg};background:${badge.bg}">${badge.t}</span></div><div style="height:6px;margin:9px 0 6px;background:#edf1f6;border-radius:99px;overflow:hidden;display:flex"><i style="display:block;height:100%;width:${passPct}%;background:#15815c"></i><i style="display:block;height:100%;width:${failPct}%;background:#d34661"></i></div></div>`;
|
|
1143
|
+
}).join("");
|
|
1144
|
+
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
1145
|
+
const traceability = parseAdvisorySummary(input.traceabilitySummary);
|
|
1146
|
+
const qRow = (name, status, extra = "") => {
|
|
1147
|
+
const c = status === "PASS" ? { fg: "#15815c", bg: "#e7f7f0" } : status === "FAIL" ? { fg: "#d34661", bg: "#fff0f3" } : { fg: "#718097", bg: "#eef2f7" };
|
|
1148
|
+
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:11px 13px;border:1px solid #e4e9f1;border-radius:10px;background:#fbfcfe"><span style="color:#2a3c5a;font-size:13px;font-weight:650">${escapeHtml(name)}</span><span style="font-size:11px;font-weight:800;color:${c.fg};background:${c.bg};padding:2px 8px;border-radius:999px">${status === "Unavailable" ? "UNAVAILABLE" : status}${extra}</span></div>`;
|
|
1149
|
+
};
|
|
1150
|
+
const failures = input.failures && input.failures.length > 0
|
|
1151
|
+
? input.failures.map((f) => `<details style="border:1px solid #ecd4d9;border-radius:13px;background:linear-gradient(135deg,#fffdfd,#fff8f9);overflow:hidden;margin-top:10px" open><summary style="display:flex;align-items:center;gap:10px;padding:15px;cursor:pointer;list-style:none"><span style="color:#c43b58;font:700 12px ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(f.caseId)}</span><span style="flex:1;color:#17365d;font-size:14px;font-weight:750">${escapeHtml(f.title)}</span><span style="display:inline-flex;align-items:center;padding:3px 9px;border-radius:999px;font-size:11px;font-weight:800;color:#d34661;background:#fff0f3">未修复</span></summary><div style="padding:0 15px 15px;border-top:1px solid #f3dce1"><div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;margin-top:14px">${f.script ? `<div style="padding:9px 10px;border-radius:8px;background:#fff"><label style="display:block;color:#718097;font-size:11px">自动化脚本</label><span style="display:block;margin-top:2px;color:#172033;font-size:12px;font-weight:650;font-family:ui-monospace,SFMono-Regular,Menlo,monospace">${escapeHtml(f.script)}</span></div>` : ""}<div style="padding:9px 10px;border-radius:8px;background:#fff"><label style="display:block;color:#718097;font-size:11px">失败原因</label><span style="display:block;margin-top:2px;color:#172033;font-size:12px;font-weight:650">${escapeHtml(f.message)}</span></div></div>${f.request ? `<div style="margin-top:10px"><div style="color:#667085;font-size:11px;font-weight:600;margin-bottom:3px">请求参数</div><pre style="margin:0;white-space:pre-wrap;color:#344054;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;padding:10px 12px;border-radius:8px;border:1px solid #eceef2">${escapeHtml(f.request)}</pre></div>` : ""}${f.response ? `<div style="margin-top:10px"><div style="color:#667085;font-size:11px;font-weight:600;margin-bottom:3px">响应结果</div><pre style="margin:0;white-space:pre-wrap;color:#344054;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;background:#f6f8fa;padding:10px 12px;border-radius:8px;border:1px solid #eceef2">${escapeHtml(f.response)}</pre></div>` : ""}</div></details>`).join("")
|
|
1152
|
+
: "";
|
|
1153
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(input.title)} · L-5</title></head><body style="margin:0;background:radial-gradient(circle at 8% 0,#edf4ff 0,transparent 36rem),#f5f7fb;color:#17243b;font:15px/1.6 Inter,system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif"><div style="max-width:1180px;margin:0 auto;padding:42px 28px 72px">
|
|
1154
|
+
<header style="position:relative;overflow:hidden;padding:34px 38px;border:1px solid #294b78;border-radius:26px;background:linear-gradient(125deg,#102849 0%,#173d6d 57%,#245b91 100%);box-shadow:0 18px 40px rgba(16,40,73,.18);display:flex;align-items:flex-start;justify-content:space-between;gap:28px"><div style="position:relative;z-index:1"><div style="color:#9fc6ee;font-size:11px;font-weight:800;letter-spacing:.2em">BACKEND TEST · L-5 QUALITY VIEW</div><h1 style="margin:12px 0 10px;color:#f7fbff;font-size:clamp(30px,3.8vw,46px);font-weight:800;letter-spacing:-.055em;line-height:1.1">${escapeHtml(input.title)}</h1><p style="color:#bdd2e9;font-size:13px;margin:0">${escapeHtml(input.objective ?? "后端自动化测试成熟度报告")}</p></div><div style="position:relative;z-index:1;min-width:220px;padding:16px 18px;border:1px solid ${decision.border};border-radius:18px;background:${decision.bg};box-shadow:0 8px 24px rgba(0,0,0,.08)"><div style="display:flex;align-items:center;gap:9px;color:${decision.color};font-size:17px;font-weight:800"><i style="width:11px;height:11px;border-radius:50%;background:${decision.color};box-shadow:0 0 0 5px ${decision.color}1a;display:inline-block"></i>L-5 ${escapeHtml(decision.text)}</div><small style="display:block;margin:7px 0 0 20px;color:${decision.color};opacity:.85;font-size:12px">${escapeHtml(decision.small)}</small></div></header>
|
|
1155
|
+
<section style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:18px">
|
|
1156
|
+
<div style="position:relative;min-height:132px;padding:21px 22px;overflow:hidden;background:#fff;border:1px solid #e1e8f2;border-radius:15px;box-shadow:0 6px 18px rgba(25,53,92,.045)"><i style="position:absolute;top:0;left:0;right:0;height:3px;background:#4775ef;display:block"></i><div style="color:#748198;font-size:13px;font-weight:650">测试通过率</div><div style="margin-top:19px;color:#13213a;font-size:35px;font-weight:800;letter-spacing:-.06em;line-height:1">${passRatePct}%</div><div style="margin-top:9px;color:${m.passRate.status === "pass" ? "#15815c" : "#c87918"};font-size:12px">${input.parsed.passed} / ${executed} · 目标 100%${m.passRate.status === "pass" ? "(达标)" : "(未达标)"}</div></div>
|
|
1157
|
+
<div style="position:relative;min-height:132px;padding:21px 22px;overflow:hidden;background:#fff;border:1px solid #e1e8f2;border-radius:15px;box-shadow:0 6px 18px rgba(25,53,92,.045)"><i style="position:absolute;top:0;left:0;right:0;height:3px;background:#c87918;display:block"></i><div style="color:#748198;font-size:13px;font-weight:650">AC 验收覆盖</div><div style="margin-top:19px;color:#13213a;font-size:35px;font-weight:800;letter-spacing:-.06em;line-height:1">${acStrong}</div><div style="margin-top:9px;color:${m.acCoverage.status === "pass" ? "#15815c" : m.acCoverage.status === "unavailable" ? "#718097" : "#d34661"};font-size:12px">${m.acCoverage.numerator ?? "—"} / ${m.acCoverage.denominator ?? "—"} · 目标 100%</div></div>
|
|
1158
|
+
<div style="position:relative;min-height:132px;padding:21px 22px;overflow:hidden;background:#fff;border:1px solid #e1e8f2;border-radius:15px;box-shadow:0 6px 18px rgba(25,53,92,.045)"><i style="position:absolute;top:0;left:0;right:0;height:3px;background:#7589d9;display:block"></i><div style="color:#748198;font-size:13px;font-weight:650">自动化覆盖率</div><div style="margin-top:19px;color:#13213a;font-size:35px;font-weight:800;letter-spacing:-.06em;line-height:1">${autoStrong}</div><div style="margin-top:9px;color:${m.automationCoverage.status === "pass" ? "#15815c" : "#c87918"};font-size:12px">${m.automationCoverage.numerator ?? "—"} / ${m.automationCoverage.denominator ?? "—"} · 目标 ≥90%</div></div>
|
|
1159
|
+
<div style="position:relative;min-height:132px;padding:21px 22px;overflow:hidden;background:#fff;border:1px solid #e1e8f2;border-radius:15px;box-shadow:0 6px 18px rgba(25,53,92,.045)"><i style="position:absolute;top:0;left:0;right:0;height:3px;background:${m.criticalRisks.status === "pass" ? "#15815c" : "#d34661"};display:block"></i><div style="color:#748198;font-size:13px;font-weight:650">Critical 风险</div><div style="margin-top:19px;color:#13213a;font-size:35px;font-weight:800;letter-spacing:-.06em;line-height:1">${m.criticalRisks.numerator ?? 0}</div><div style="margin-top:9px;color:${m.criticalRisks.status === "pass" ? "#15815c" : "#d34661"};font-size:12px">${m.criticalRisks.status === "pass" ? "无阻断项" : "阻断级风险"}</div></div>
|
|
1160
|
+
</section>
|
|
1161
|
+
<section style="display:grid;grid-template-columns:1fr 1.35fr;gap:12px;margin-top:18px">
|
|
1162
|
+
<div style="padding:20px 22px;border:1px solid #dfe7f1;border-radius:15px;background:rgba(255,255,255,.82);box-shadow:0 7px 20px rgba(25,53,92,.04)">
|
|
1163
|
+
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px"><div><span style="color:#8796aa;font-size:10px;font-weight:800;letter-spacing:.16em">RUN OUTCOME</span><h2 style="margin:3px 0 0;color:#17365d;font-size:17px">本轮执行结果</h2></div><span style="color:#718097;font-size:11px;white-space:nowrap">${executed} 个用例 · ${formatDuration(input.parsed.durationMs)}</span></div>
|
|
1164
|
+
<div style="position:relative;display:flex;align-items:center;gap:20px;margin-top:15px">
|
|
1165
|
+
<svg viewBox="0 0 120 120" style="width:116px;height:116px;flex:0 0 116px;transform:rotate(-90deg)" role="img" aria-label="${input.parsed.passed} 通过,${input.parsed.failed} 失败"><circle cx="60" cy="60" r="46" fill="none" stroke="#edf1f6" stroke-width="12"></circle><circle cx="60" cy="60" r="46" fill="none" stroke="#15815c" stroke-width="12" stroke-dasharray="${passLen.toFixed(2)} ${circ.toFixed(2)}" stroke-linecap="round"></circle><circle cx="60" cy="60" r="46" fill="none" stroke="#d34661" stroke-width="12" stroke-dasharray="${failLen.toFixed(2)} ${circ.toFixed(2)}" stroke-dashoffset="${(-passLen).toFixed(2)}"></circle></svg>
|
|
1166
|
+
<div style="position:absolute;display:flex;width:116px;height:116px;flex-direction:column;align-items:center;justify-content:center;pointer-events:none"><strong style="color:#17365d;font-size:20px;letter-spacing:-.05em">${passRatePct}%</strong><span style="color:#718097;font-size:10px">通过率</span></div>
|
|
1167
|
+
<div style="display:grid;gap:6px;flex:1"><div style="display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:7px;color:#718097;font-size:12px"><i style="width:8px;height:8px;border-radius:50%;background:#15815c;display:block"></i><span>通过</span><strong style="color:#17243b;font-size:14px">${input.parsed.passed}</strong></div><div style="display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:7px;color:#718097;font-size:12px"><i style="width:8px;height:8px;border-radius:50%;background:#d34661;display:block"></i><span>失败</span><strong style="color:#17243b;font-size:14px">${input.parsed.failed}</strong></div><div style="display:grid;grid-template-columns:10px 1fr auto;align-items:center;gap:7px;color:#718097;font-size:12px"><i style="width:8px;height:8px;border-radius:50%;background:#aab5c4;display:block"></i><span>错误 / 跳过</span><strong style="color:#17243b;font-size:14px">${input.parsed.errors + input.parsed.skipped}</strong></div></div>
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div style="padding:20px 22px;border:1px solid #dfe7f1;border-radius:15px;background:rgba(255,255,255,.82);box-shadow:0 7px 20px rgba(25,53,92,.04);display:flex;flex-direction:column">
|
|
1171
|
+
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px"><div><span style="color:#8796aa;font-size:10px;font-weight:800;letter-spacing:.16em">QUALITY SIGNALS</span><h2 style="margin:3px 0 0;color:#17365d;font-size:17px">质量信号</h2></div><span style="color:#718097;font-size:11px;white-space:nowrap">按 L-5 门槛判定</span></div>
|
|
1172
|
+
${signal("需求 / AC 覆盖", acStrong, acWidth, 100, acPct !== null && Number(acPct) >= 100 ? "linear-gradient(90deg,#52b88b,#15815c)" : "linear-gradient(90deg,#e9a040,#f0b85a)", `${m.acCoverage.numerator ?? 0} / ${m.acCoverage.denominator ?? 0} · 目标 100%`)}
|
|
1173
|
+
${signal("自动化覆盖率", autoStrong, autoWidth, 90, autoPct !== null && Number(autoPct) >= 90 ? "linear-gradient(90deg,#52b88b,#15815c)" : "linear-gradient(90deg,#7d9bf5,#4775ef)", `${m.automationCoverage.numerator ?? 0} / ${m.automationCoverage.denominator ?? 0} · 目标 ≥90%`)}
|
|
1174
|
+
${signal("用例跳过数", String(input.parsed.skipped), input.parsed.skipped === 0 ? 100 : 0, 100, "linear-gradient(90deg,#52b88b,#15815c)", `${input.parsed.skipped} / ${executed} · 目标 0`)}
|
|
1175
|
+
${signal("Line 代码覆盖", m.lineCoverage.ratio !== null ? `${(m.lineCoverage.ratio * 100).toFixed(1)}%` : "—", m.lineCoverage.ratio !== null ? Math.round(m.lineCoverage.ratio * 100) : 0, 80, m.lineCoverage.status === "pass" ? "linear-gradient(90deg,#52b88b,#15815c)" : "linear-gradient(90deg,#7d9bf5,#4775ef)", m.lineCoverage.status === "unavailable" ? (m.lineCoverage.reason ?? "unavailable") : `${m.lineCoverage.numerator} / ${m.lineCoverage.denominator} · 目标 ≥80%`)}
|
|
1176
|
+
</div>
|
|
1177
|
+
</section>
|
|
1178
|
+
<section style="display:grid;grid-template-columns:1.28fr .86fr;gap:20px;margin-top:20px">
|
|
1179
|
+
<div style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px">
|
|
1180
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px"><h2 style="margin:0;color:#17365d;font-size:19px">L-5 门禁摘要</h2><span style="color:#718097;font-size:13px">阻断项与执行门槛</span></div>
|
|
1181
|
+
${l5GateRow("测试通过率", m.passRate, passRatePct + "%", `${input.parsed.passed} / ${executed} 个用例 · 目标 100%`, `失败 ${input.parsed.failed},错误 ${input.parsed.errors}。<code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">reports/backend-test-facts.md · pytest-exit=${input.parsed.failed + input.parsed.errors > 0 ? 1 : 0}</code>`)}
|
|
1182
|
+
${l5GateRow("AC 验收覆盖", m.acCoverage, acStrong, `${m.acCoverage.numerator ?? 0} / ${m.acCoverage.denominator ?? 0} · 目标 100%`, m.acCoverage.status === "unavailable" ? "case-manifest coverage 缺失" : "来自 backend-test-case-manifest coverageSummary")}
|
|
1183
|
+
${l5GateRow("自动化覆盖率", m.automationCoverage, autoStrong, `${m.automationCoverage.numerator ?? 0} / ${m.automationCoverage.denominator ?? 0} · 目标 ≥90%`, m.automationCoverage.status === "unavailable" ? "case-manifest coverage 缺失" : "来自 backend-test-case-manifest coverageSummary")}
|
|
1184
|
+
${l5GateRow("Critical 风险", m.criticalRisks, String(m.criticalRisks.numerator ?? 0), m.criticalRisks.status === "pass" ? "无阻断项 · 目标 0" : `${m.criticalRisks.numerator ?? 0} 个阻断项 · 目标 0`, m.criticalRisks.status === "pass" ? "无阻断级风险" : "存在阻断级接口契约偏差")}
|
|
1185
|
+
${l5GateRow("Line 代码覆盖", m.lineCoverage, m.lineCoverage.ratio !== null ? `${(m.lineCoverage.ratio * 100).toFixed(1)}%` : "unavailable", m.lineCoverage.status === "unavailable" ? "未产出 coverage artifact · 目标 ≥80%" : `${m.lineCoverage.numerator} / ${m.lineCoverage.denominator} · 目标 ≥80%`, m.lineCoverage.status === "unavailable" ? (m.lineCoverage.reason ?? "coverage missing") : "来自 validated coverage artifact")}
|
|
1186
|
+
</div>
|
|
1187
|
+
<div style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px">
|
|
1188
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:18px"><h2 style="margin:0;color:#17365d;font-size:19px">模块通过情况</h2><span style="color:#718097;font-size:13px">${collectModuleStats(input.parsed).length} 个脚本</span></div>
|
|
1189
|
+
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px">${moduleBars || '<div style="color:#718097;font-size:13px">无模块数据</div>'}</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
</section>
|
|
1192
|
+
<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px">
|
|
1193
|
+
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:12px"><h2 style="margin:0;color:#17365d;font-size:19px">质量校验矩阵</h2><span style="color:#718097;font-size:13px">来自节点 4 / 6</span></div>
|
|
1194
|
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
|
1195
|
+
${qRow("环境硬门(Shell)", "PASS")}
|
|
1196
|
+
${qRow("Markdown 用例校验(节点4)", caseValidation.status, caseValidation.findings !== undefined ? ` · ${caseValidation.findings}` : "")}
|
|
1197
|
+
${qRow("Markdown → pytest 追溯(节点6)", traceability.status, traceability.findings !== undefined ? ` · ${traceability.findings}` : "")}
|
|
1198
|
+
${qRow("HTTP 请求/响应日志(节点6 advisory)", traceability.status)}
|
|
1199
|
+
${qRow("Markdown 敏感信息", "PASS")}
|
|
1200
|
+
${qRow("代码覆盖率", m.lineCoverage.status === "unavailable" && m.lineCoverage.reason !== null ? "Unavailable" : "PASS")}
|
|
1201
|
+
</div>
|
|
1202
|
+
</section>
|
|
1203
|
+
${failures ? `<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px"><div style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:12px"><h2 style="margin:0;color:#17365d;font-size:19px">缺陷记录</h2><span style="color:#718097;font-size:13px">${input.failures?.length ?? 0} 条 · 已关联用例与证据</span></div>${failures}</section>` : ""}
|
|
1204
|
+
<section style="background:#fff;border:1px solid #e0e7f0;border-radius:15px;box-shadow:0 7px 20px rgba(25,53,92,.04);padding:22px;margin-top:20px">
|
|
1205
|
+
<p style="margin:0;color:#718097;font-size:12px">报告由 backend-test DAG 节点 7(execute-backend-pytest-and-html-report-shell)基于本轮真实执行产物确定性渲染。L-5 指标由 <code style="color:#17365d;font:11px ui-monospace,SFMono-Regular,Menlo,monospace">computeL5ReportMetrics</code> 机器计算。判定依据:pass=100%、AC=100%、automation≥90%、line≥80%、branch≥70%、skipped=0、无阻断级 Critical 风险。</p>
|
|
1206
|
+
</section>
|
|
1207
|
+
</div></body></html>`;
|
|
1208
|
+
}
|
|
1016
1209
|
export function renderBackendTestFacts(input) {
|
|
1017
1210
|
const passRate = input.parsed.tests > 0 ? input.parsed.passed / input.parsed.tests : 0;
|
|
1018
1211
|
const caseValidation = parseAdvisorySummary(input.caseValidationSummary);
|
|
@@ -773,6 +773,109 @@ async function readPytestExitCode(runDir, fromNodeId, exitRelativePath = "report
|
|
|
773
773
|
}
|
|
774
774
|
throw new Error("missing valid pytest exit evidence");
|
|
775
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Materialize Result v1 from a pytest-html 4.x self-contained report (Markdown-first
|
|
778
|
+
* 8-node pipeline). `junit.relativePath` records the HTML path for integrity hashing
|
|
779
|
+
* (same field used historically for JUnit XML path).
|
|
780
|
+
*/
|
|
781
|
+
export async function materializeBackendTestResultFromPytestHtml(input) {
|
|
782
|
+
const htmlRelativePath = input.htmlRelativePath ?? "reports/backend-test.html";
|
|
783
|
+
const artifactName = input.artifactName ?? "backend-test-result.json";
|
|
784
|
+
const outputDir = input.outputDir ?? "contracts";
|
|
785
|
+
if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(artifactName) ||
|
|
786
|
+
!/^[a-z0-9][a-z0-9._-]*$/.test(outputDir)) {
|
|
787
|
+
throw new Error("unsafe structured artifact path");
|
|
788
|
+
}
|
|
789
|
+
let html = input.htmlContent;
|
|
790
|
+
if (html === undefined) {
|
|
791
|
+
const htmlAbs = path.join(input.runDir, ...htmlRelativePath.split("/"));
|
|
792
|
+
try {
|
|
793
|
+
html = await readFile(htmlAbs, "utf8");
|
|
794
|
+
}
|
|
795
|
+
catch {
|
|
796
|
+
throw new Error(`missing pytest-html report at ${htmlRelativePath} (fail-closed for Result v1)`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!html.trim()) {
|
|
800
|
+
throw new Error("invalid pytest-html report: empty report");
|
|
801
|
+
}
|
|
802
|
+
let parsed;
|
|
803
|
+
try {
|
|
804
|
+
parsed = parsePytestHtmlReport(html);
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
throw new Error(`invalid pytest-html report: ${error instanceof Error ? error.message : String(error)}`);
|
|
808
|
+
}
|
|
809
|
+
const sha256 = createHash("sha256").update(html).digest("hex");
|
|
810
|
+
const exit = input.pytestExitCode;
|
|
811
|
+
let executionStatus = "completed";
|
|
812
|
+
let collectionStatus = "ok";
|
|
813
|
+
let outcome = "passed";
|
|
814
|
+
const looksLikeCollection = exit === 2 ||
|
|
815
|
+
(parsed.errors > 0 && parsed.passed + parsed.failed === 0) ||
|
|
816
|
+
parsed.failures.some((f) => f.kind === "error" &&
|
|
817
|
+
/collect|import|syntax/i.test(`${f.name} ${f.message}`));
|
|
818
|
+
if (looksLikeCollection && (parsed.errors > 0 || exit >= 2)) {
|
|
819
|
+
executionStatus = "collection-error";
|
|
820
|
+
collectionStatus = "error";
|
|
821
|
+
outcome = "collection-error";
|
|
822
|
+
}
|
|
823
|
+
else if (exit >= 2 && parsed.failed === 0 && parsed.errors === 0) {
|
|
824
|
+
executionStatus = "command-error";
|
|
825
|
+
collectionStatus = "unknown";
|
|
826
|
+
outcome = "command-error";
|
|
827
|
+
}
|
|
828
|
+
else if (parsed.failed > 0 || parsed.errors > 0 || exit === 1) {
|
|
829
|
+
executionStatus = "completed";
|
|
830
|
+
collectionStatus = "ok";
|
|
831
|
+
outcome = "completed-with-failures";
|
|
832
|
+
}
|
|
833
|
+
else if (exit === 0 && parsed.failed === 0 && parsed.errors === 0) {
|
|
834
|
+
executionStatus = "completed";
|
|
835
|
+
collectionStatus = "ok";
|
|
836
|
+
outcome = "passed";
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
executionStatus = "command-error";
|
|
840
|
+
collectionStatus = "unknown";
|
|
841
|
+
outcome = "command-error";
|
|
842
|
+
}
|
|
843
|
+
const commandSummary = input.commandSummary ??
|
|
844
|
+
`PYTHONUTF8=1 PYTHONIOENCODING=utf-8 PYTHONDONTWRITEBYTECODE=1 python -m pytest -v -p no:cacheprovider --html=${htmlRelativePath} --self-contained-html`;
|
|
845
|
+
assertNoSecrets("commandSummary", commandSummary);
|
|
846
|
+
const result = backendTestResultContractSchema.parse({
|
|
847
|
+
schemaVersion: 1,
|
|
848
|
+
executionStatus,
|
|
849
|
+
pytestExitCode: exit,
|
|
850
|
+
collectionStatus,
|
|
851
|
+
tests: parsed.tests,
|
|
852
|
+
passed: parsed.passed,
|
|
853
|
+
failed: parsed.failed,
|
|
854
|
+
error: parsed.errors,
|
|
855
|
+
skipped: parsed.skipped,
|
|
856
|
+
durationMs: parsed.durationMs,
|
|
857
|
+
junit: {
|
|
858
|
+
relativePath: htmlRelativePath,
|
|
859
|
+
sha256,
|
|
860
|
+
},
|
|
861
|
+
commandSummary,
|
|
862
|
+
failures: parsed.failures.map((f) => ({
|
|
863
|
+
classname: f.classname || "unknown",
|
|
864
|
+
name: f.name || "unknown",
|
|
865
|
+
message: truncate(f.message || "failure"),
|
|
866
|
+
kind: f.kind,
|
|
867
|
+
})),
|
|
868
|
+
outcome,
|
|
869
|
+
});
|
|
870
|
+
const relativePath = path.posix.join(outputDir, artifactName);
|
|
871
|
+
const artifactPath = await writeDagRunJsonArtifact(input.runDir, relativePath, result);
|
|
872
|
+
const serialized = `${JSON.stringify(result, null, 2)}\n`;
|
|
873
|
+
return {
|
|
874
|
+
path: artifactPath,
|
|
875
|
+
sha256: createHash("sha256").update(serialized).digest("hex"),
|
|
876
|
+
schemaId: BACKEND_TEST_RESULT_SCHEMA_ID,
|
|
877
|
+
};
|
|
878
|
+
}
|
|
776
879
|
export async function materializeBackendTestResultFromRunDir(input) {
|
|
777
880
|
if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(input.artifactName) ||
|
|
778
881
|
!/^[a-z0-9][a-z0-9._-]*$/.test(input.outputDir)) {
|
|
@@ -32,6 +32,7 @@ const RAW_TO_NORMALIZED = {
|
|
|
32
32
|
"empty-output": "validation",
|
|
33
33
|
"verify-failure": "validation",
|
|
34
34
|
"test-failure": "validation",
|
|
35
|
+
"verification-plan-stale": "validation",
|
|
35
36
|
network: "executor",
|
|
36
37
|
quota: "executor",
|
|
37
38
|
"rate-limit": "executor",
|
|
@@ -59,7 +60,10 @@ function mapRawCategory(raw) {
|
|
|
59
60
|
const lower = raw.toLowerCase();
|
|
60
61
|
if (lower.includes("timeout"))
|
|
61
62
|
return "timeout";
|
|
62
|
-
if (lower.includes("validation") ||
|
|
63
|
+
if (lower.includes("validation") ||
|
|
64
|
+
lower.includes("invalid") ||
|
|
65
|
+
lower.includes("verification-plan-stale") ||
|
|
66
|
+
lower.includes("plan-stale"))
|
|
63
67
|
return "validation";
|
|
64
68
|
if (lower.includes("auth") || lower.includes("api-key"))
|
|
65
69
|
return "auth";
|
|
@@ -120,6 +120,7 @@ export const frontendImplementationContractSchema = z
|
|
|
120
120
|
requirements: z.array(z
|
|
121
121
|
.object({
|
|
122
122
|
id,
|
|
123
|
+
expectedOutcome: z.string().min(1),
|
|
123
124
|
implementationTargets: z.array(safePath),
|
|
124
125
|
verificationTargetIds: z.array(z.string().min(1)),
|
|
125
126
|
evidenceGap: gap.optional(),
|
|
@@ -138,6 +139,8 @@ export const frontendImplementationContractSchema = z
|
|
|
138
139
|
interactions: z.array(z
|
|
139
140
|
.object({
|
|
140
141
|
name: z.string().min(1),
|
|
142
|
+
trigger: z.string().min(1),
|
|
143
|
+
expectedBehavior: z.string().min(1),
|
|
141
144
|
implementationTargets: z.array(safePath),
|
|
142
145
|
verificationTargetIds: z.array(z.string().min(1)),
|
|
143
146
|
})
|
|
@@ -536,6 +539,7 @@ export function coerceFrontendImplementationContractInput(value, canonicalBindin
|
|
|
536
539
|
asString(req.realIntegrationGap);
|
|
537
540
|
const out = {
|
|
538
541
|
id,
|
|
542
|
+
expectedOutcome: asString(req.expectedOutcome),
|
|
539
543
|
implementationTargets: implementationTargets.length > 0 ? implementationTargets : targetFiles,
|
|
540
544
|
verificationTargetIds: verificationTargetIds.length > 0
|
|
541
545
|
? verificationTargetIds
|
|
@@ -175,23 +175,6 @@ export async function runFrontendPrewriteGate(input) {
|
|
|
175
175
|
throw new Error(`frontend prewrite gate contract target is outside implementation writeSet: ${uncoveredTargets.join(", ")}`);
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
-
if (contract.mockApi.strategy !== "not-needed") {
|
|
179
|
-
for (const endpoint of contract.mockApi.endpoints) {
|
|
180
|
-
for (const relative of [endpoint.fixture, endpoint.consumer]) {
|
|
181
|
-
if (!relative)
|
|
182
|
-
continue;
|
|
183
|
-
const absolute = path.resolve(workspaceRoot ?? ".", relative);
|
|
184
|
-
try {
|
|
185
|
-
const info = await stat(absolute);
|
|
186
|
-
if (!info.isFile())
|
|
187
|
-
throw new Error("not a regular file");
|
|
188
|
-
}
|
|
189
|
-
catch (error) {
|
|
190
|
-
throw new Error(`frontend prewrite gate missing Mock file ${relative}: ${error instanceof Error ? error.message : String(error)}`);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
178
|
const candidatePaths = input.config.openspecCandidatePaths ?? [];
|
|
196
179
|
const openspecReadPaths = await checkOpenspecReadEvidence({
|
|
197
180
|
runDir: input.runDir,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
@@ -68,6 +68,111 @@ export const frontendTestResultContractSchema = z.object({
|
|
|
68
68
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["integrationMode"], message: "real integration requires passed outcome" });
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
|
+
async function isNonEmptyFile(filePath) {
|
|
72
|
+
try {
|
|
73
|
+
const info = await stat(filePath);
|
|
74
|
+
return info.isFile() && info.size > 0;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isSafeEvidenceShellPath(value) {
|
|
81
|
+
return (typeof value === "string" &&
|
|
82
|
+
value.length > 0 &&
|
|
83
|
+
!path.isAbsolute(value) &&
|
|
84
|
+
!path.win32.isAbsolute(value) &&
|
|
85
|
+
!value.includes(".."));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Validate frontend browser evidence without going through a shell command.
|
|
89
|
+
* Keeping this in the Node executor avoids CMD/Git Bash/PowerShell quoting and
|
|
90
|
+
* backslash interpretation for the former long `node -e` command.
|
|
91
|
+
*/
|
|
92
|
+
export async function validateFrontendCaseEvidence(input) {
|
|
93
|
+
const manifestPath = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
|
|
94
|
+
try {
|
|
95
|
+
await stat(manifestPath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error("missing testcase/frontend/cases/manifest.json");
|
|
99
|
+
}
|
|
100
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
101
|
+
if (!Array.isArray(manifest.cases))
|
|
102
|
+
throw new Error("invalid frontend case manifest");
|
|
103
|
+
const statuses = new Set(["passed", "failed", "blocked"]);
|
|
104
|
+
const issues = [];
|
|
105
|
+
let hardFail = false;
|
|
106
|
+
for (const rawCase of manifest.cases) {
|
|
107
|
+
const item = rawCase;
|
|
108
|
+
const id = typeof item?.caseId === "string" ? item.caseId : "?";
|
|
109
|
+
const dir = item?.evidenceDir;
|
|
110
|
+
const prefix = `testcase/frontend/evidence/${id}`;
|
|
111
|
+
if (!item ||
|
|
112
|
+
typeof item.caseId !== "string" ||
|
|
113
|
+
typeof dir !== "string" ||
|
|
114
|
+
!isSafeEvidenceShellPath(dir) ||
|
|
115
|
+
!(dir === prefix || dir.startsWith(`${prefix}/`))) {
|
|
116
|
+
hardFail = true;
|
|
117
|
+
issues.push({ ruleId: "unsafe-evidence-dir", caseId: id, detail: String(dir) });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const execution = path.join(input.workspaceRoot, dir, "execution.md");
|
|
121
|
+
const resultPath = path.join(input.workspaceRoot, dir, "case-result.json");
|
|
122
|
+
if (!(await isNonEmptyFile(execution))) {
|
|
123
|
+
issues.push({ ruleId: "missing-execution", caseId: id, detail: "execution.md is missing or empty" });
|
|
124
|
+
}
|
|
125
|
+
let result = null;
|
|
126
|
+
if (!(await isNonEmptyFile(resultPath))) {
|
|
127
|
+
issues.push({ ruleId: "missing-case-result", caseId: id, detail: "case-result.json is missing" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
result = JSON.parse(await readFile(resultPath, "utf8"));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
issues.push({ ruleId: "invalid-case-result", caseId: id, detail: "case-result.json is malformed" });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!result || result.caseId !== id) {
|
|
138
|
+
issues.push({ ruleId: "case-result-identity", caseId: id, detail: "case-result caseId does not match manifest" });
|
|
139
|
+
}
|
|
140
|
+
if (!statuses.has(String(result?.status))) {
|
|
141
|
+
issues.push({ ruleId: "invalid-case-status", caseId: id, detail: "status must be passed, failed, or blocked" });
|
|
142
|
+
}
|
|
143
|
+
if (!Array.isArray(result?.evidencePaths)) {
|
|
144
|
+
issues.push({ ruleId: "invalid-evidence-paths", caseId: id, detail: "evidencePaths must be an array" });
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
for (const evidencePath of result.evidencePaths) {
|
|
148
|
+
if (!isSafeEvidenceShellPath(evidencePath)) {
|
|
149
|
+
hardFail = true;
|
|
150
|
+
issues.push({ ruleId: "unsafe-evidence-path", caseId: id, detail: String(evidencePath) });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const currentPrefix = `${prefix}/`;
|
|
154
|
+
if (evidencePath.startsWith("testcase/frontend/evidence/") && !evidencePath.startsWith(currentPrefix)) {
|
|
155
|
+
hardFail = true;
|
|
156
|
+
issues.push({ ruleId: "cross-case-evidence-path", caseId: id, detail: evidencePath });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const target = evidencePath.startsWith("testcase/")
|
|
160
|
+
? path.join(input.workspaceRoot, evidencePath)
|
|
161
|
+
: path.join(input.workspaceRoot, dir, evidencePath);
|
|
162
|
+
if (!(await isNonEmptyFile(target))) {
|
|
163
|
+
issues.push({ ruleId: "missing-evidence", caseId: id, detail: `${evidencePath} is missing or empty` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (result?.status === "passed" && (!Array.isArray(result.evidencePaths) || !result.evidencePaths.some((entry) => /\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(String(entry))))) {
|
|
168
|
+
issues.push({ ruleId: "passed-without-browser-evidence", caseId: id, detail: "passed case has no screenshot, HAR, video, or equivalent browser artifact" });
|
|
169
|
+
}
|
|
170
|
+
if (result?.status === "blocked" && (typeof result.blockedReason !== "string" || !result.blockedReason.trim())) {
|
|
171
|
+
issues.push({ ruleId: "blocked-reason", caseId: id, detail: "blocked result has no usable reason" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { cases: manifest.cases.length, issues, hardFail };
|
|
175
|
+
}
|
|
71
176
|
function sha256(content) {
|
|
72
177
|
return createHash("sha256").update(content).digest("hex");
|
|
73
178
|
}
|
|
@@ -214,43 +319,3 @@ export function buildFrontendTestOutcomeGateShellSnippet(options) {
|
|
|
214
319
|
`node -e 'const fs=require("fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const ok=r.outcome==="passed"&&r.integrationMode==="real"&&Number(r.totals?.failed||0)===0&&Number(r.totals?.blocked||0)===0&&Array.isArray(r.acceptanceCoverage?.missing)&&r.acceptanceCoverage.missing.length===0;console.log("frontend-test outcome="+r.outcome+" integrationMode="+r.integrationMode);if(!ok)process.exit(1);' "\${RESULT}"`,
|
|
215
320
|
].join("; ");
|
|
216
321
|
}
|
|
217
|
-
/**
|
|
218
|
-
* Shared frontend-test evidence advisory check for map children + node 7.
|
|
219
|
-
* Hard-fails only for unsafe evidence directories or evidence paths. Missing,
|
|
220
|
-
* malformed, or empty evidence is reported without mutating case outputs.
|
|
221
|
-
*/
|
|
222
|
-
export function buildFrontendCaseEvidenceValidateShellSnippet() {
|
|
223
|
-
const body = [
|
|
224
|
-
"const fs=require('fs'),path=require('path');",
|
|
225
|
-
"const manifestPath='testcase/frontend/cases/manifest.json';",
|
|
226
|
-
"if(!fs.existsSync(manifestPath))throw new Error('missing '+manifestPath);",
|
|
227
|
-
"const manifest=JSON.parse(fs.readFileSync(manifestPath,'utf8'));",
|
|
228
|
-
"if(!Array.isArray(manifest.cases))throw new Error('invalid frontend case manifest');",
|
|
229
|
-
"const statuses=new Set(['passed','failed','blocked']);",
|
|
230
|
-
"const issues=[];",
|
|
231
|
-
"let hardFail=false;",
|
|
232
|
-
"function isSafeRel(p){return typeof p==='string'&&p.length>0&&!path.isAbsolute(p)&&!path.win32.isAbsolute(p)&&!p.includes('..');}",
|
|
233
|
-
"for(const c of manifest.cases){",
|
|
234
|
-
" const id=c&&typeof c.caseId==='string'?c.caseId:'?';",
|
|
235
|
-
" const dir=c&&c.evidenceDir;",
|
|
236
|
-
" const prefix='testcase/frontend/evidence/'+id;",
|
|
237
|
-
" if(!c||typeof c.caseId!=='string'||typeof dir!=='string'||!isSafeRel(dir)||!(dir===prefix||dir.startsWith(prefix+'/'))){",
|
|
238
|
-
" hardFail=true; issues.push({ruleId:'unsafe-evidence-dir',caseId:id,detail:String(dir)}); continue;",
|
|
239
|
-
" }",
|
|
240
|
-
" const execution=path.join(dir,'execution.md');",
|
|
241
|
-
" const resultPath=path.join(dir,'case-result.json');",
|
|
242
|
-
" if(!fs.existsSync(execution)||!fs.statSync(execution).isFile()||fs.statSync(execution).size===0)issues.push({ruleId:'missing-execution',caseId:id,detail:'execution.md is missing or empty'});",
|
|
243
|
-
" let result=null;",
|
|
244
|
-
" if(!fs.existsSync(resultPath)){issues.push({ruleId:'missing-case-result',caseId:id,detail:'case-result.json is missing'});continue;}",
|
|
245
|
-
" try{result=JSON.parse(fs.readFileSync(resultPath,'utf8'));}catch(e){issues.push({ruleId:'invalid-case-result',caseId:id,detail:'case-result.json is malformed'});continue;}",
|
|
246
|
-
" if(!result||result.caseId!==id)issues.push({ruleId:'case-result-identity',caseId:id,detail:'case-result caseId does not match manifest'});",
|
|
247
|
-
" if(!statuses.has(result&&result.status))issues.push({ruleId:'invalid-case-status',caseId:id,detail:'status must be passed, failed, or blocked'});",
|
|
248
|
-
" if(!Array.isArray(result&&result.evidencePaths)){issues.push({ruleId:'invalid-evidence-paths',caseId:id,detail:'evidencePaths must be an array'});}else{for(const p of result.evidencePaths){if(!isSafeRel(p)){hardFail=true;issues.push({ruleId:'unsafe-evidence-path',caseId:id,detail:String(p)});continue;}const currentPrefix=prefix+'/';if(p.startsWith('testcase/frontend/evidence/')&&!p.startsWith(currentPrefix)){hardFail=true;issues.push({ruleId:'cross-case-evidence-path',caseId:id,detail:p});continue;}const target=p.startsWith('testcase/')?p:path.join(dir,p);if(!fs.existsSync(target)||!fs.statSync(target).isFile()||fs.statSync(target).size===0)issues.push({ruleId:'missing-evidence',caseId:id,detail:p+' is missing or empty'});}}",
|
|
249
|
-
" if(result&&result.status==='passed'&&(!Array.isArray(result.evidencePaths)||!result.evidencePaths.some(p=>/\\.(png|jpg|jpeg|webp|zip|har|webm|mp4)$/i.test(p))))issues.push({ruleId:'passed-without-browser-evidence',caseId:id,detail:'passed case has no screenshot, HAR, video, or equivalent browser artifact'});",
|
|
250
|
-
" if(result&&result.status==='blocked'&&(typeof result.blockedReason!=='string'||!result.blockedReason.trim()))issues.push({ruleId:'blocked-reason',caseId:id,detail:'blocked result has no usable reason'});",
|
|
251
|
-
"}",
|
|
252
|
-
"if(hardFail){console.error('frontend-test evidence hard-fail: '+JSON.stringify(issues)); process.exit(1);}",
|
|
253
|
-
"console.log('frontend case evidence advisory validation ok cases='+manifest.cases.length+' findings='+issues.length+(issues.length?(' issues='+JSON.stringify(issues)):''));",
|
|
254
|
-
].join("");
|
|
255
|
-
return ["node -e", JSON.stringify(body)].join(" ");
|
|
256
|
-
}
|