@tea-agent/loop-agent 0.44.0-next.11 → 0.44.0-next.13
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 +25 -1
- package/dist/build-stamp.json +3 -3
- package/dist/commands/init.js +1 -0
- package/dist/executors/dag-pi-executor.js +11 -6
- package/dist/executors/pi-writer-tool-policy.js +60 -4
- package/dist/executors/playwright-cli-launcher.js +31 -45
- package/dist/executors/shell-executor.js +39 -11
- package/dist/executors/shell-write-guard.js +24 -67
- package/dist/governance/exec-plans.js +10 -1
- package/dist/task/config-types.js +4 -2
- package/dist/task/source-prepare/build-draft.js +1 -0
- package/dist/task/source-prepare/path-policy.js +17 -7
- package/dist/worker/console/chat/browser-automation.js +343 -38
- package/dist/worker/console/chat/browser-content.js +74 -0
- package/dist/worker/console/chat/browser-diagnostics.js +250 -0
- package/dist/worker/console/chat/browser-evaluation.js +58 -0
- package/dist/worker/console/chat/browser-operation-queue.js +19 -0
- package/dist/worker/console/chat/browser-pick.js +113 -0
- package/dist/worker/console/chat/browser-routes.js +33 -5
- package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
- package/dist/worker/console/chat/browser-storage.js +89 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
- package/dist/worker/console/chat/pi-runtime.js +1 -1
- package/dist/worker/console/chat/repo-browser.js +80 -2
- package/dist/worker/console/chat/repo-preview-kind.js +155 -0
- package/dist/worker/console/chat/session-mode.js +1 -1
- package/dist/worker/console/chat/session-store.js +4 -2
- package/dist/worker/console/chat/tools.js +12 -0
- package/dist/worker/console/routes.js +5 -0
- package/dist/worker/console/static/app-icon.svg +39 -0
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-BA7qCL_r.js} +1 -1
- package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-Df09ajwL.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CpF9sw5R.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-BtPys8ja.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DZPnTWqT.js} +1 -1
- package/dist/worker/console/static/assets/channel-DiTNLZUm.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-BD9D4h1p.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-Svn8yBmn.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-B8EbQj-S.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-tENZpcpF.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-DBx71Lco.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-1v_QamMz.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-BAMCNNBG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BvuEury-.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-YqFuHp8s.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-ouI5T7Mr.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-Bri0_Izk.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-e8Vhhi4X.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-tSIXGQiJ.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-Rl-sF5pt.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-Db6ddPz8.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Jwd_Xw5S.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-CeC_8InC.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-LKPRaNq8.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-DlVQytMW.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-DnfJZwO-.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-3axVSXca.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-xe04-_L-.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-hpmRINiD.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js} +1 -1
- package/dist/worker/console/static/assets/index-C_8_gWal.css +1 -0
- package/dist/worker/console/static/assets/{index-CTMZsDmh.js → index-DybcVXCu.js} +102 -100
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-BF6ksWTX.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-DXisdeD7.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BWu65wmu.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-hfNhHqAI.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-D6HTfQcp.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-Iv91R1WP.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CfPChwjR.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-B7TcoqHz.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-ouyPKMtQ.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-B9tuyLbh.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-Ck4q9nJs.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-wXLX5Dnp.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-D2XWw6_m.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-DzxKiug4.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-DWUqK5Us.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-D5Pa4O5g.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-VH7cRnsJ.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-C4HM5NoZ.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-Bu-4hOlA.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-CasFEvvl.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M-D-gpwPSA.js} +1 -1
- package/dist/worker/console/static/index.html +5 -2
- package/dist/worker/console/static/manifest.webmanifest +19 -0
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
- package/dist/worker/console/workspace-context.js +1 -0
- package/dist/worker/materialize/harness-task-lineage.js +4 -2
- package/dist/worker/observe/static/console-theme.css +33 -29
- package/dist/worker/observe/static/console-theme.js +38 -0
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
- package/dist/workflows/dag/backend-test-layout.js +27 -7
- package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
- package/dist/workflows/dag/frontend-repair.js +24 -0
- package/dist/workflows/dag/frontend-review-scopes.js +10 -2
- package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
- package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
- package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
- package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
- package/dist/workflows/dag/frontend-test-layout.js +41 -12
- package/dist/workflows/dag/hybrid/sources.js +21 -2
- package/dist/workflows/dag/hybrid/templates/backend-test.js +65 -50
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +58 -37
- package/dist/workflows/dag/test-artifact-namespace.js +73 -0
- package/dist/workflows/dag/validate.js +22 -3
- package/docs/architecture/dag-execution.md +3 -3
- package/docs/templates/README.md +4 -4
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
- package/docs/templates/backend-test-dag.json +708 -708
- package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
- package/docs/templates/frontend-test-dag.json +46 -37
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/package.json +7 -2
- package/skills/fe-test-ui-scout/SKILL.md +4 -4
- package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +4 -4
- package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +0 -8
|
@@ -7,10 +7,46 @@ export const CONSOLE_THEMES = Object.freeze([
|
|
|
7
7
|
|
|
8
8
|
const STORAGE_KEY = "loop-console.theme.v1";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Titlebar colour per theme, matching each palette's --canvas. An installed PWA
|
|
12
|
+
* paints the OS window frame with the theme-color meta value, so it must track
|
|
13
|
+
* the active theme: a fixed brand colour leaves an orange titlebar above the
|
|
14
|
+
* dark midnight palette. Values mirror console-theme.css --canvas.
|
|
15
|
+
*/
|
|
16
|
+
const TITLEBAR_COLOR = Object.freeze({
|
|
17
|
+
default: "#f4f2ee",
|
|
18
|
+
midnight: "#1c222a",
|
|
19
|
+
daylight: "#eef0f2",
|
|
20
|
+
});
|
|
21
|
+
|
|
10
22
|
function normalizeTheme(value) {
|
|
11
23
|
return CONSOLE_THEMES.some((theme) => theme.id === value) ? value : "default";
|
|
12
24
|
}
|
|
13
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Project the theme onto the document's theme-color meta tag. Browsers read
|
|
28
|
+
* this tag live (the manifest theme_color only applies at install time), and
|
|
29
|
+
* an installed window repaints its frame when it changes.
|
|
30
|
+
*
|
|
31
|
+
* Strictly best-effort: the titlebar is cosmetic, so a missing or partial DOM
|
|
32
|
+
* (SSR, a test stub, an exotic embedder) must never break theme switching.
|
|
33
|
+
*/
|
|
34
|
+
function syncTitlebarColor(theme) {
|
|
35
|
+
try {
|
|
36
|
+
const head = document?.head;
|
|
37
|
+
if (!head || typeof document.createElement !== "function") return;
|
|
38
|
+
let meta = document.querySelector?.('meta[name="theme-color"]');
|
|
39
|
+
if (!meta) {
|
|
40
|
+
meta = document.createElement("meta");
|
|
41
|
+
meta.setAttribute("name", "theme-color");
|
|
42
|
+
head.appendChild(meta);
|
|
43
|
+
}
|
|
44
|
+
meta.setAttribute("content", TITLEBAR_COLOR[theme] ?? TITLEBAR_COLOR.default);
|
|
45
|
+
} catch {
|
|
46
|
+
// The palette switch above already succeeded; the titlebar is optional.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
14
50
|
export function applyStoredConsoleTheme() {
|
|
15
51
|
let value;
|
|
16
52
|
try {
|
|
@@ -20,6 +56,7 @@ export function applyStoredConsoleTheme() {
|
|
|
20
56
|
}
|
|
21
57
|
const theme = normalizeTheme(value);
|
|
22
58
|
document.documentElement.dataset.consoleTheme = theme;
|
|
59
|
+
syncTitlebarColor(theme);
|
|
23
60
|
return theme;
|
|
24
61
|
}
|
|
25
62
|
|
|
@@ -27,6 +64,7 @@ export function setConsoleTheme(value) {
|
|
|
27
64
|
const theme = normalizeTheme(value);
|
|
28
65
|
// Apply before persistence: a storage failure must not disable the picker.
|
|
29
66
|
document.documentElement.dataset.consoleTheme = theme;
|
|
67
|
+
syncTitlebarColor(theme);
|
|
30
68
|
try {
|
|
31
69
|
window.localStorage.setItem(STORAGE_KEY, theme);
|
|
32
70
|
} catch {
|
|
@@ -666,7 +666,9 @@ function scenarioTypes(body) {
|
|
|
666
666
|
}
|
|
667
667
|
function declaredScripts(body) {
|
|
668
668
|
const automation = sectionBody(body, CASE_SECTION_ALIASES.automation);
|
|
669
|
-
|
|
669
|
+
// Accept both the canonical `.harness/testcase/` root and the legacy
|
|
670
|
+
// `testcase/` root so Markdown written by older runs still declares.
|
|
671
|
+
return orderedUnique([...automation.matchAll(/(?:\.harness\/)?testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py/gi)]
|
|
670
672
|
.map((match) => match[0].replaceAll("\\", "/")));
|
|
671
673
|
}
|
|
672
674
|
function automationLabelBody(body, labels) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { canonicalTestArtifactRoot } from "./test-artifact-namespace.js";
|
|
2
3
|
/**
|
|
3
4
|
* Backend-test artifact layout (plan A: configurable test asset paths).
|
|
4
5
|
*
|
|
@@ -8,13 +9,15 @@ import path from "node:path";
|
|
|
8
9
|
* writers, gates, analyzers and the executor can never disagree about where
|
|
9
10
|
* backend-test assets live.
|
|
10
11
|
*
|
|
11
|
-
* Default layout
|
|
12
|
-
* testcase/md/README.md
|
|
13
|
-
* testcase/md/<stem>.md
|
|
14
|
-
* testcase/test_<stem>.py
|
|
15
|
-
* pytest target: testcase/
|
|
12
|
+
* Default layout (since 2026-09-17) lives under the shared runtime namespace:
|
|
13
|
+
* .harness/testcase/md/README.md
|
|
14
|
+
* .harness/testcase/md/<stem>.md
|
|
15
|
+
* .harness/testcase/test_<stem>.py
|
|
16
|
+
* pytest target: .harness/testcase/
|
|
16
17
|
*/
|
|
17
|
-
export const BACKEND_TEST_DEFAULT_TEST_ROOT = "
|
|
18
|
+
export const BACKEND_TEST_DEFAULT_TEST_ROOT = canonicalTestArtifactRoot("backend-test");
|
|
19
|
+
/** Historical pre-2026-09-17 default root, kept for reference/diagnostics. */
|
|
20
|
+
export const BACKEND_TEST_LEGACY_DEFAULT_TEST_ROOT = "testcase";
|
|
18
21
|
export const BACKEND_TEST_DEFAULT_MARKDOWN_REL = "md";
|
|
19
22
|
export const BACKEND_TEST_DEFAULT_SCRIPT_REL = ".";
|
|
20
23
|
/** Repository roots that must never be configured as backend-test test roots. */
|
|
@@ -54,6 +57,9 @@ function normalizeRelPosix(value, label) {
|
|
|
54
57
|
if (segments.some((segment) => segment === ".." || segment === ".")) {
|
|
55
58
|
throw new Error(`backendTest.${label} must not contain "." or ".." segments: ${value}`);
|
|
56
59
|
}
|
|
60
|
+
if (segments.some((segment) => segment.startsWith("."))) {
|
|
61
|
+
throw new Error(`backendTest.${label} must not target hidden directories: ${value}`);
|
|
62
|
+
}
|
|
57
63
|
if (segments.some((segment) => !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment))) {
|
|
58
64
|
throw new Error(`backendTest.${label} contains unsafe path segments: ${value}`);
|
|
59
65
|
}
|
|
@@ -77,6 +83,10 @@ function normalizeOptionalRel(value, fallback, label) {
|
|
|
77
83
|
return normalizeRelPosix(value, label);
|
|
78
84
|
}
|
|
79
85
|
function assertTestRootAllowed(testRoot) {
|
|
86
|
+
// D3: the canonical harness test-artifact root is the ONLY hidden-root
|
|
87
|
+
// exception. Every other hidden or forbidden top segment stays rejected.
|
|
88
|
+
if (testRoot === BACKEND_TEST_DEFAULT_TEST_ROOT)
|
|
89
|
+
return;
|
|
80
90
|
const topSegment = testRoot.split("/")[0];
|
|
81
91
|
if (FORBIDDEN_TEST_ROOT_PREFIXES.some((forbidden) => topSegment === forbidden || topSegment.startsWith(`${forbidden}.`))) {
|
|
82
92
|
throw new Error(`backendTest.testRoot must not target production/governance trees: ${testRoot}`);
|
|
@@ -85,6 +95,16 @@ function assertTestRootAllowed(testRoot) {
|
|
|
85
95
|
throw new Error(`backendTest.testRoot must not target hidden directories: ${testRoot}`);
|
|
86
96
|
}
|
|
87
97
|
}
|
|
98
|
+
function resolveTestRootInput(raw, canonical) {
|
|
99
|
+
// D3: only the EXACT canonical harness test-artifact root may be a hidden
|
|
100
|
+
// path. Compare after trimming + stripping trailing slashes so
|
|
101
|
+
// `.harness/testcase/` matches, but every other hidden spelling falls
|
|
102
|
+
// through to the fail-closed normalizer below.
|
|
103
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
104
|
+
if (trimmed === canonical)
|
|
105
|
+
return canonical;
|
|
106
|
+
return normalizeRelPosix(raw, "testRoot");
|
|
107
|
+
}
|
|
88
108
|
/**
|
|
89
109
|
* Resolve the frozen backend-test artifact layout from task config.
|
|
90
110
|
* Throws on unsafe or ambiguous configuration (fail-closed).
|
|
@@ -92,7 +112,7 @@ function assertTestRootAllowed(testRoot) {
|
|
|
92
112
|
export function resolveBackendTestLayout(config) {
|
|
93
113
|
const testRoot = config?.testRoot === undefined || config?.testRoot === null
|
|
94
114
|
? BACKEND_TEST_DEFAULT_TEST_ROOT
|
|
95
|
-
:
|
|
115
|
+
: resolveTestRootInput(config.testRoot, BACKEND_TEST_DEFAULT_TEST_ROOT);
|
|
96
116
|
assertTestRootAllowed(testRoot);
|
|
97
117
|
const markdownRel = config?.markdownRel === undefined || config?.markdownRel === null
|
|
98
118
|
? BACKEND_TEST_DEFAULT_MARKDOWN_REL
|
|
@@ -787,7 +787,9 @@ function extractMappedPytestScripts(testCaseBody) {
|
|
|
787
787
|
const automation = sectionBody(testCaseBody, CASE_SECTION_ALIASES.automationNotes);
|
|
788
788
|
if (!automation.trim())
|
|
789
789
|
return [];
|
|
790
|
-
|
|
790
|
+
// Accept both the canonical `.harness/testcase/` root and the legacy
|
|
791
|
+
// `testcase/` root so Markdown written by older runs still maps.
|
|
792
|
+
return unique([...automation.matchAll(/`?((?:\.harness\/)?testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/gi)]
|
|
791
793
|
.map((match) => match[1].replaceAll("\\", "/"))
|
|
792
794
|
.filter((value) => !value.includes("..")));
|
|
793
795
|
}
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { FRONTEND_NO_VERIFICATION_MARKER_TEXT, } from "./frontend-verification-trace.js";
|
|
3
|
+
import { FRONTEND_TEST_OBSERVATION_FAILURES } from "./frontend-test-execution-evidence.js";
|
|
4
|
+
/**
|
|
5
|
+
* Substrings that prove a behavior-verification failure came from the
|
|
6
|
+
* verification configuration rather than the candidate's code. Kept next to the
|
|
7
|
+
* taxonomy so a new failure code is routed deliberately instead of falling
|
|
8
|
+
* through to the string-blob heuristics below.
|
|
9
|
+
*/
|
|
10
|
+
const FRONTEND_TEST_VERIFICATION_CONFIG_MARKERS = [
|
|
11
|
+
FRONTEND_TEST_OBSERVATION_FAILURES.bindingDrift,
|
|
12
|
+
FRONTEND_TEST_OBSERVATION_FAILURES.reportProtocol,
|
|
13
|
+
FRONTEND_TEST_OBSERVATION_FAILURES.capabilityGap,
|
|
14
|
+
"FRONTEND_TEST_OBSERVATION_INVALID",
|
|
15
|
+
"frontend test execution report unavailable",
|
|
16
|
+
];
|
|
3
17
|
export const frontendRepairFailureClassSchema = z.enum([
|
|
4
18
|
"typecheck",
|
|
5
19
|
"build",
|
|
@@ -73,6 +87,16 @@ export function classifyFrontendFailure(input) {
|
|
|
73
87
|
if (input.failureCategory === "write-guard") {
|
|
74
88
|
return "path";
|
|
75
89
|
}
|
|
90
|
+
// Verification-config faults must never be mistaken for product defects.
|
|
91
|
+
// A missing/unreadable report, a stale report, an unsupported runner or a
|
|
92
|
+
// drifted command binding are all tooling/contract problems: rewriting the
|
|
93
|
+
// candidate's business code cannot fix them and burns a repair round. These
|
|
94
|
+
// are detected structurally, before any blob heuristic can see a runner name
|
|
95
|
+
// (the `unit-test` rule matches the literal word "jest") and misroute the
|
|
96
|
+
// failure to the implementation owner.
|
|
97
|
+
if (FRONTEND_TEST_VERIFICATION_CONFIG_MARKERS.some((marker) => `${input.stderr ?? ""}\n${input.stdout ?? ""}`.includes(marker))) {
|
|
98
|
+
return "contract";
|
|
99
|
+
}
|
|
76
100
|
if (`${input.stderr ?? ""}\n${input.stdout ?? ""}`.includes(FRONTEND_NO_VERIFICATION_MARKER_TEXT)) {
|
|
77
101
|
// Verification unavailability is a generation-time contract gap: no
|
|
78
102
|
// authorized verification command exists. Bounded repair cannot fix it;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseFrontendTestExecutionReport } from "./frontend-test-execution-evidence.js";
|
|
2
|
+
import { isFrontendTestReportScheme } from "./frontend-test-framework-adapters.js";
|
|
2
3
|
import { readFileBoundarySafe } from "../../shared/path-safety.js";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import { readFile, realpath, lstat } from "node:fs/promises";
|
|
@@ -109,12 +110,16 @@ export async function loadFrontendReviewScopes(runDir, phase, targetBytes, works
|
|
|
109
110
|
const reportCoverage = new Map();
|
|
110
111
|
const reportedTests = [];
|
|
111
112
|
for (const ref of payload.verificationTrace?.testExecutionEvidence ?? []) {
|
|
112
|
-
if (!isArtifactReference(ref) || typeof ref.reportPath !== "string" || !/^[a-f0-9]{64}$/.test(ref.reportSha256))
|
|
113
|
+
if (!isArtifactReference(ref) || typeof ref.reportPath !== "string" || !/^[a-f0-9]{64}$/.test(ref.reportSha256) || !isFrontendTestReportScheme(ref.scheme))
|
|
113
114
|
throw invalidScope("invalid test execution reference");
|
|
114
115
|
const excerpt = await read(ref.path, ref.sha256);
|
|
115
116
|
const data = JSON.parse(excerpt.text);
|
|
116
117
|
if (data.reportSha256 !== ref.reportSha256)
|
|
117
118
|
throw invalidScope("test execution report binding mismatch");
|
|
119
|
+
// The excerpt records the protocol that produced it. A mismatch means the
|
|
120
|
+
// trace and its report disagree, so refuse rather than guess an adapter.
|
|
121
|
+
if (data.scheme !== ref.scheme)
|
|
122
|
+
throw invalidScope("test execution report protocol binding mismatch");
|
|
118
123
|
const coverage = reportCoverage.get(ref.reportPath) ?? { count: 0, total: data.total };
|
|
119
124
|
if (!Number.isInteger(data.total) || data.total < 1 || data.total !== coverage.total || data.offset !== coverage.count || !Array.isArray(data.tests) || !data.tests.length || data.tests.length > 24)
|
|
120
125
|
throw invalidScope("test report excerpt coverage invalid");
|
|
@@ -128,7 +133,10 @@ export async function loadFrontendReviewScopes(runDir, phase, targetBytes, works
|
|
|
128
133
|
const cwdPath = await realpath(path.resolve(workspaceRoot, data.cwd));
|
|
129
134
|
if (cwdPath !== rootPath && !cwdPath.startsWith(rootPath + path.sep))
|
|
130
135
|
throw invalidScope("test report cwd escaped workspace");
|
|
131
|
-
|
|
136
|
+
// Parse with the protocol the runner actually used. Defaulting to
|
|
137
|
+
// Vitest here failed every Playwright/Jest report at review time even
|
|
138
|
+
// though execution and trace had accepted it.
|
|
139
|
+
const actual = parseFrontendTestExecutionReport(JSON.parse(report.text), cwdPath, rootPath, ref.scheme);
|
|
132
140
|
const expected = actual.slice(data.offset, data.offset + 24).map(test => ({ file: test.file, status: test.status, title: test.title.slice(0, 500), ...(test.title.length > 500 ? { titleTruncated: true } : {}) }));
|
|
133
141
|
if (data.total !== actual.length || !expected.length || sha256OfCanonicalJson(expected) !== sha256OfCanonicalJson(data.tests))
|
|
134
142
|
throw invalidScope("test report excerpt differs from actual runner report");
|
|
@@ -142,12 +142,18 @@ function executableListStep(value) {
|
|
|
142
142
|
return null;
|
|
143
143
|
return normalizeCommand(inlineCode[1]);
|
|
144
144
|
}
|
|
145
|
-
const prompted = /^[$>]\s*\S/.test(trimmed);
|
|
146
145
|
const normalized = normalizeCommand(trimmed);
|
|
147
146
|
if (isNonExecutableBlockedProse(normalized))
|
|
148
147
|
return null;
|
|
149
|
-
|
|
148
|
+
// A `$ `/`> ` prompt marker (stripped by normalizeCommand) must NOT turn
|
|
149
|
+
// Markdown documentation — e.g. a `> 协议说明` blockquote inside 测试步骤 —
|
|
150
|
+
// into an executable instruction: every line, prompted or not, follows the
|
|
151
|
+
// same command-like rule. Real commands after the marker (e.g.
|
|
152
|
+
// `> npx playwright test`) still normalize to command-like executables and
|
|
153
|
+
// stay gated by the allowlist.
|
|
154
|
+
if (isCommandLikeExecutable(normalized)) {
|
|
150
155
|
return normalized;
|
|
156
|
+
}
|
|
151
157
|
return null;
|
|
152
158
|
}
|
|
153
159
|
function hasShellControl(command) {
|
|
@@ -330,7 +336,10 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
330
336
|
}
|
|
331
337
|
if (expectedPath && casePath.replaceAll("\\", "/") !== expectedPath)
|
|
332
338
|
issues.push({ ruleId: "case-path-mismatch", caseId: id, casePath, detail: `${casePath} must equal ${expectedPath}` });
|
|
333
|
-
|
|
339
|
+
// `playwright_cli` (underscore) is the structured tool name; normalize it to
|
|
340
|
+
// the canonical `playwright-cli` executable spelling before gating so the
|
|
341
|
+
// same command is accepted regardless of which spelling the case used.
|
|
342
|
+
const body = (await readFile(absolute, "utf8")).replace(/\bplaywright_cli\b/g, "playwright-cli");
|
|
334
343
|
if (!openRe.test(body))
|
|
335
344
|
issues.push({ ruleId: "open-prefix", caseId: id, casePath, detail: "missing playwright-cli open --browser=chrome <absolute-url>" });
|
|
336
345
|
const match = body.match(/playwright-cli\s+open\s+--browser=chrome\s+(https?:\/\/\S+)/i);
|
|
@@ -2,8 +2,6 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { defaultFrontendTestLayout, frontendTestContextRel, frontendTestProbeRel, } from "./frontend-test-layout.js";
|
|
5
|
-
export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
|
|
6
|
-
export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
|
|
7
5
|
const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
|
|
8
6
|
export function classifyFrontendBaseUrlProbeFailure(input) {
|
|
9
7
|
if (input.spawnError)
|
|
@@ -3,14 +3,32 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { pathMatchesPattern } from "../../shared/git-progress.js";
|
|
4
4
|
import { readFile, realpath } from "node:fs/promises";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { FRONTEND_TEST_ADAPTERS, detectUnadaptedRunner, frontendTestReportSchemeSchema, resolveFrontendTestAdapter, resolveRunnerArgv, } from "./frontend-test-framework-adapters.js";
|
|
7
|
+
/** Scheme → adapter lookup, built once from the adapter registry. */
|
|
8
|
+
const FRONTEND_TEST_ADAPTERS_BY_SCHEME = Object.fromEntries(FRONTEND_TEST_ADAPTERS.map((adapter) => [adapter.scheme, adapter]));
|
|
9
|
+
/**
|
|
10
|
+
* Failure taxonomy for behavior verification. Kept separate from the writer's
|
|
11
|
+
* `failureCategory` so a broken report protocol never reads as broken product
|
|
12
|
+
* code and trigger a pointless implementation rewrite.
|
|
13
|
+
*/
|
|
14
|
+
export const FRONTEND_TEST_OBSERVATION_FAILURES = {
|
|
15
|
+
/** The frozen command drift from what the contract bound. */
|
|
16
|
+
bindingDrift: "FRONTEND_TEST_BINDING_DRIFT",
|
|
17
|
+
/** The runner produced no readable report, or the report was stale. */
|
|
18
|
+
reportProtocol: "FRONTEND_TEST_REPORT_PROTOCOL",
|
|
19
|
+
/** The runner itself is not adapted (Cypress, `node --test`, …). */
|
|
20
|
+
capabilityGap: "FRONTEND_TEST_CAPABILITY_GAP",
|
|
21
|
+
};
|
|
6
22
|
export const frontendTestObservationSchema = z.object({
|
|
7
|
-
scheme:
|
|
23
|
+
scheme: frontendTestReportSchemeSchema,
|
|
8
24
|
commandId: z.string().optional(),
|
|
9
25
|
status: z.enum(["supported", "deferred", "unsupported"]),
|
|
10
26
|
commandSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
11
27
|
cwd: z.string(),
|
|
12
28
|
packageScript: z.object({ file: z.string(), name: z.string(), sha256: z.string().nullable() }).optional(),
|
|
13
29
|
reason: z.string().optional(),
|
|
30
|
+
/** Set when `status: "unsupported"` because no adapter exists yet. */
|
|
31
|
+
capabilityGap: z.string().optional(),
|
|
14
32
|
}).strict();
|
|
15
33
|
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
16
34
|
const quote = (text) => `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
@@ -124,20 +142,68 @@ function literalCommand(command, cwd, depth = 0) {
|
|
|
124
142
|
}
|
|
125
143
|
return { argv, cwd, append: suffix => command.trimEnd() + suffix };
|
|
126
144
|
}
|
|
127
|
-
function directVitest(argv) {
|
|
128
|
-
const args = [...argv];
|
|
129
|
-
if (["npx"].includes(args[0] ?? ""))
|
|
130
|
-
args.shift();
|
|
131
|
-
else if (["pnpm", "yarn"].includes(args[0] ?? "") && args[1] === "exec")
|
|
132
|
-
args.splice(0, 2);
|
|
133
|
-
return args[0] === "vitest" && args[1] === "run";
|
|
134
|
-
}
|
|
135
145
|
function hasReporter(argv) { return argv.some(arg => /^--(?:reporter|outputFile)(?:[.=]|$)/.test(arg)); }
|
|
136
|
-
/**
|
|
137
|
-
|
|
146
|
+
/** Locate the `npm|pnpm|yarn` script invocation in a literal argv. */
|
|
147
|
+
function scriptInvocation(argv) {
|
|
148
|
+
const manager = argv[0];
|
|
149
|
+
if (manager !== "npm" && manager !== "pnpm" && manager !== "yarn")
|
|
150
|
+
return undefined;
|
|
151
|
+
// `exec`/`dlx`/`create` run a binary rather than a package script, so they
|
|
152
|
+
// are not script invocations — `yarn exec vitest run` must resolve as a
|
|
153
|
+
// direct runner command, not as a script literally named "exec".
|
|
154
|
+
const subcommands = new Set(["exec", "dlx", "create", "init", "add", "install", "why", "config"]);
|
|
155
|
+
// npm test | npm run <name> | pnpm test | pnpm run <name> | yarn <name>
|
|
156
|
+
let name;
|
|
157
|
+
let rest = [];
|
|
158
|
+
if (argv[1] === "test") {
|
|
159
|
+
name = "test";
|
|
160
|
+
rest = argv.slice(2);
|
|
161
|
+
}
|
|
162
|
+
else if (argv[1] === "run") {
|
|
163
|
+
name = argv[2];
|
|
164
|
+
rest = argv.slice(3);
|
|
165
|
+
}
|
|
166
|
+
else if (manager === "yarn" && argv[1] && !argv[1].startsWith("-") && !subcommands.has(argv[1])) {
|
|
167
|
+
name = argv[1];
|
|
168
|
+
rest = argv.slice(2);
|
|
169
|
+
}
|
|
170
|
+
if (!name || !/^[\w:-]+$/.test(name))
|
|
171
|
+
return undefined;
|
|
172
|
+
if (rest.length && rest[0] !== "--")
|
|
173
|
+
throw Error("package manager script arguments require --");
|
|
174
|
+
return { name, rest };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Build the report invocation for an adapter.
|
|
178
|
+
*
|
|
179
|
+
* When the adapter names a `reportPathEnv`, the path travels in the environment
|
|
180
|
+
* and the suffix only selects the reporter — appending the path to the command
|
|
181
|
+
* would hand it to the runner as a positional argument (Playwright treats a
|
|
182
|
+
* stray argument as a test-name filter and then finds no tests at all).
|
|
183
|
+
*
|
|
184
|
+
* Otherwise the path is joined to the suffix: a suffix ending in `=` (Vitest's
|
|
185
|
+
* `--outputFile=`) binds it directly, and any other suffix names a flag
|
|
186
|
+
* (`--outputFile`, `--json`) that takes the path as the next argument.
|
|
187
|
+
*/
|
|
188
|
+
function reportInvocation(adapter, append, suffix, reportPath) {
|
|
189
|
+
if (adapter.reportPathEnv) {
|
|
190
|
+
return {
|
|
191
|
+
command: append(suffix),
|
|
192
|
+
env: { [adapter.reportPathEnv]: reportPath },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const joiner = suffix.endsWith("=") ? "" : " ";
|
|
196
|
+
return { command: append(`${suffix}${joiner}${quote(reportPath)}`) };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Shared read-only capability resolver, used at generation, admission and
|
|
200
|
+
* immediately before execution. It never executes or rewrites an npm script.
|
|
201
|
+
*/
|
|
138
202
|
export async function inspectFrontendTestCommand(input) {
|
|
139
203
|
const root = path.resolve(input.workspaceRoot ?? input.cwd);
|
|
140
204
|
let cwd = path.resolve(input.cwd);
|
|
205
|
+
// `scheme` is unknowable until an adapter resolves; the placeholder is only
|
|
206
|
+
// ever surfaced on the unsupported path, where no report is produced.
|
|
141
207
|
const base = { scheme: "vitest-json-v1", status: "unsupported", commandSha256: hash(input.command), cwd: path.relative(root, cwd).replace(/\\/g, "/") || "." };
|
|
142
208
|
try {
|
|
143
209
|
const parsed = literalCommand(input.command, cwd);
|
|
@@ -154,79 +220,109 @@ export async function inspectFrontendTestCommand(input) {
|
|
|
154
220
|
throw Error("test cwd resolves outside the workspace");
|
|
155
221
|
if (hasReporter(parsed.argv))
|
|
156
222
|
throw Error("frozen command already owns reporter/outputFile flags");
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
if (!
|
|
162
|
-
throw
|
|
163
|
-
const
|
|
164
|
-
if (
|
|
165
|
-
throw Error(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
throw Error("package.json is unreadable or invalid");
|
|
174
|
-
}
|
|
175
|
-
const script = manifest?.scripts?.[name];
|
|
176
|
-
base.packageScript = { file, name, sha256: typeof script === "string" ? hash(JSON.stringify({ script, pre: manifest.scripts?.[`pre${name}`] ?? null, post: manifest.scripts?.[`post${name}`] ?? null })) : null };
|
|
177
|
-
if (script === undefined) {
|
|
178
|
-
const authorized = input.allowedPaths?.some(pattern => pathMatchesPattern(file, pattern)) && !input.forbiddenPaths?.some(pattern => pathMatchesPattern(file, pattern));
|
|
179
|
-
if (!authorized)
|
|
180
|
-
throw Error(`missing npm script ${name}; ${file} is not an authorized delivery`);
|
|
181
|
-
return { observation: { ...base, status: "deferred", reason: `Awaiting authorized ${file} script ${name} with vitest-json-v1 capability` }, effectiveCwd: cwd };
|
|
182
|
-
}
|
|
183
|
-
if (typeof script !== "string")
|
|
184
|
-
throw Error("npm script must be a string");
|
|
185
|
-
const scriptWords = words(script);
|
|
186
|
-
if (scriptWords.some(word => word.operator) || !directVitest(scriptWords.map(w => w.value)) || hasReporter(scriptWords.map(w => w.value)))
|
|
187
|
-
throw Error("npm script has no supported Vitest JSON reporter capability");
|
|
188
|
-
separator = rest.length ? "" : " --";
|
|
223
|
+
const runner = resolveRunnerArgv(parsed.argv);
|
|
224
|
+
const invocation = scriptInvocation(parsed.argv);
|
|
225
|
+
if (!invocation) {
|
|
226
|
+
const adapter = resolveFrontendTestAdapter({ argv: parsed.argv, runner });
|
|
227
|
+
if (!adapter)
|
|
228
|
+
throw capabilityGapError(runner);
|
|
229
|
+
const suffix = adapter.reportSuffix({ argv: parsed.argv, runner });
|
|
230
|
+
if (suffix === undefined)
|
|
231
|
+
throw Error(`${adapter.runner} project configuration owns report output; declare an explicit regression command instead of overriding it`);
|
|
232
|
+
return { observation: { ...base, scheme: adapter.scheme, status: "supported" }, effectiveCwd: cwd,
|
|
233
|
+
withReporter: reportPath => reportInvocation(adapter, inner => parsed.append(` ${inner}`), suffix, reportPath) };
|
|
234
|
+
}
|
|
235
|
+
const file = path.relative(root, path.join(cwd, "package.json")).replace(/\\/g, "/");
|
|
236
|
+
let manifest = {};
|
|
237
|
+
try {
|
|
238
|
+
manifest = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
|
|
189
239
|
}
|
|
190
|
-
|
|
191
|
-
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error.code !== "ENOENT")
|
|
242
|
+
throw Error("package.json is unreadable or invalid");
|
|
243
|
+
}
|
|
244
|
+
const script = manifest?.scripts?.[invocation.name];
|
|
245
|
+
base.packageScript = { file, name: invocation.name, sha256: typeof script === "string" ? hash(JSON.stringify({ script, pre: manifest.scripts?.[`pre${invocation.name}`] ?? null, post: manifest.scripts?.[`post${invocation.name}`] ?? null })) : null };
|
|
246
|
+
if (script === undefined) {
|
|
247
|
+
const authorized = input.allowedPaths?.some(pattern => pathMatchesPattern(file, pattern)) && !input.forbiddenPaths?.some(pattern => pathMatchesPattern(file, pattern));
|
|
248
|
+
if (!authorized)
|
|
249
|
+
throw Error(`missing npm script ${invocation.name}; ${file} is not an authorized delivery`);
|
|
250
|
+
return { observation: { ...base, status: "deferred", reason: `Awaiting authorized ${file} script ${invocation.name} with a supported test report capability` }, effectiveCwd: cwd };
|
|
251
|
+
}
|
|
252
|
+
if (typeof script !== "string")
|
|
253
|
+
throw Error("npm script must be a string");
|
|
254
|
+
const scriptWords = words(script);
|
|
255
|
+
if (scriptWords.some(word => word.operator))
|
|
256
|
+
throw Error("npm script must be a single literal command");
|
|
257
|
+
const scriptArgv = scriptWords.map(w => w.value);
|
|
258
|
+
if (hasReporter(scriptArgv))
|
|
259
|
+
throw Error("npm script already owns reporter/outputFile flags");
|
|
260
|
+
const scriptRunner = resolveRunnerArgv(scriptArgv);
|
|
261
|
+
const adapter = resolveFrontendTestAdapter({ argv: parsed.argv, runner: scriptRunner, scriptName: invocation.name, scriptArgv });
|
|
262
|
+
if (!adapter)
|
|
263
|
+
throw capabilityGapError(scriptRunner);
|
|
264
|
+
const suffix = adapter.reportSuffix({ argv: scriptArgv, runner: scriptRunner });
|
|
265
|
+
if (suffix === undefined)
|
|
266
|
+
throw Error(`${adapter.runner} project configuration owns report output; declare an explicit regression command instead of overriding it`);
|
|
267
|
+
// `npm test -- --json` forwards the flags to the script; a bare script
|
|
268
|
+
// uses the same `--` separator so npm never eats the report flags.
|
|
269
|
+
// Space-prefixed because `append` concatenates onto the trimmed
|
|
270
|
+
// command; a bare script needs `--` so npm forwards the report flags.
|
|
271
|
+
const separator = invocation.rest.length ? "" : " -- ";
|
|
272
|
+
return { observation: { ...base, scheme: adapter.scheme, status: "supported" }, effectiveCwd: cwd,
|
|
273
|
+
withReporter: reportPath => reportInvocation(adapter, inner => parsed.append(invocation.rest.length ? ` ${inner}` : `${separator}${inner}`), suffix, reportPath) };
|
|
192
274
|
}
|
|
193
275
|
catch (error) {
|
|
194
|
-
|
|
276
|
+
const gap = error.capabilityGap;
|
|
277
|
+
return { observation: { ...base, status: "unsupported", reason: error instanceof Error ? error.message : String(error), ...(gap ? { capabilityGap: gap } : {}) }, effectiveCwd: cwd };
|
|
195
278
|
}
|
|
196
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* An unadapted runner is a capability gap in this tool, not a user mistake: the
|
|
282
|
+
* message must never suggest switching frameworks.
|
|
283
|
+
*/
|
|
284
|
+
function capabilityGapError(runner) {
|
|
285
|
+
const known = detectUnadaptedRunner(runner);
|
|
286
|
+
const binary = known ?? (runner[0] ?? "unknown");
|
|
287
|
+
const error = new Error(`${FRONTEND_TEST_OBSERVATION_FAILURES.capabilityGap}: no test report adapter for ${binary}; ` +
|
|
288
|
+
`frontend behavior verification currently adapts vitest, jest and playwright test, ` +
|
|
289
|
+
`so this command cannot produce execution evidence`);
|
|
290
|
+
error.capabilityGap = binary;
|
|
291
|
+
return error;
|
|
292
|
+
}
|
|
197
293
|
export function compareFrontendTestObservation(frozen, observed, phase, writeSet = []) {
|
|
198
294
|
if (frozen.status === "unsupported")
|
|
199
295
|
return frozen.reason ?? "unsupported test observation scheme";
|
|
200
296
|
if (observed.status === "unsupported")
|
|
201
297
|
return observed.reason ?? "unsupported test observation scheme";
|
|
202
|
-
|
|
203
|
-
|
|
298
|
+
// A `deferred` observation has no resolved adapter, so its `scheme` is the
|
|
299
|
+
// placeholder rather than a binding. Comparing it would reject every new
|
|
300
|
+
// Jest/Playwright project the moment the writer adds the very script the
|
|
301
|
+
// contract asked for. The scheme only becomes a binding once the frozen
|
|
302
|
+
// observation is `supported`; script identity is still checked below.
|
|
303
|
+
const schemeBound = frozen.status !== "deferred";
|
|
304
|
+
if ((schemeBound && frozen.scheme !== observed.scheme) || frozen.commandSha256 !== observed.commandSha256 || frozen.cwd !== observed.cwd || frozen.packageScript?.file !== observed.packageScript?.file || frozen.packageScript?.name !== observed.packageScript?.name)
|
|
305
|
+
return `${FRONTEND_TEST_OBSERVATION_FAILURES.bindingDrift}: frozen test command/cwd/script binding changed`;
|
|
204
306
|
if (frozen.status === "supported" && (observed.status !== "supported" || frozen.packageScript?.sha256 !== observed.packageScript?.sha256))
|
|
205
|
-
return
|
|
307
|
+
return `${FRONTEND_TEST_OBSERVATION_FAILURES.bindingDrift}: frozen npm test script changed`;
|
|
206
308
|
if (frozen.status === "deferred" && phase === "admission" && (!frozen.packageScript || !writeSet.includes(frozen.packageScript.file)))
|
|
207
309
|
return "future test script is outside the admitted concrete writeSet";
|
|
208
310
|
if (phase === "execution" && observed.status !== "supported")
|
|
209
311
|
return "future test reporter capability is still unavailable";
|
|
210
312
|
return undefined;
|
|
211
313
|
}
|
|
212
|
-
/** Observe supported frozen
|
|
314
|
+
/** Observe supported frozen commands without changing their selection. */
|
|
213
315
|
export async function withFrontendTestReporter(input) {
|
|
214
|
-
return (await inspectFrontendTestCommand(input)).withReporter?.(input.reportPath);
|
|
316
|
+
return (await inspectFrontendTestCommand(input)).withReporter?.(input.reportPath)?.command;
|
|
215
317
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
export function parseFrontendTestExecutionReport(raw, cwd, workspaceRoot = cwd) {
|
|
227
|
-
return resultSchema.parse(raw).testResults.flatMap((suite) => suite.assertionResults.map((test) => ({
|
|
228
|
-
file: path.relative(workspaceRoot, path.resolve(cwd, suite.name)).replace(/\\/g, "/"),
|
|
229
|
-
title: test.fullName ?? [...(test.ancestorTitles ?? []), test.title].join(" "),
|
|
230
|
-
status: test.status,
|
|
231
|
-
})));
|
|
318
|
+
/**
|
|
319
|
+
* Parse a runner report into uniform execution records using the adapter for
|
|
320
|
+
* `scheme`. A malformed report throws; callers classify that as a report
|
|
321
|
+
* protocol failure, never as a product defect.
|
|
322
|
+
*/
|
|
323
|
+
export function parseFrontendTestExecutionReport(raw, cwd, workspaceRoot = cwd, scheme = "vitest-json-v1") {
|
|
324
|
+
const adapter = FRONTEND_TEST_ADAPTERS_BY_SCHEME[scheme];
|
|
325
|
+
if (!adapter)
|
|
326
|
+
throw Error(`no test report adapter for scheme ${scheme}`);
|
|
327
|
+
return adapter.parse(raw, cwd, workspaceRoot);
|
|
232
328
|
}
|