@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.
Files changed (132) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/init.js +1 -0
  4. package/dist/executors/dag-pi-executor.js +11 -6
  5. package/dist/executors/pi-writer-tool-policy.js +60 -4
  6. package/dist/executors/playwright-cli-launcher.js +31 -45
  7. package/dist/executors/shell-executor.js +39 -11
  8. package/dist/executors/shell-write-guard.js +24 -67
  9. package/dist/governance/exec-plans.js +10 -1
  10. package/dist/task/config-types.js +4 -2
  11. package/dist/task/source-prepare/build-draft.js +1 -0
  12. package/dist/task/source-prepare/path-policy.js +17 -7
  13. package/dist/worker/console/chat/browser-automation.js +343 -38
  14. package/dist/worker/console/chat/browser-content.js +74 -0
  15. package/dist/worker/console/chat/browser-diagnostics.js +250 -0
  16. package/dist/worker/console/chat/browser-evaluation.js +58 -0
  17. package/dist/worker/console/chat/browser-operation-queue.js +19 -0
  18. package/dist/worker/console/chat/browser-pick.js +113 -0
  19. package/dist/worker/console/chat/browser-routes.js +33 -5
  20. package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
  21. package/dist/worker/console/chat/browser-storage.js +89 -0
  22. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
  23. package/dist/worker/console/chat/pi-runtime.js +1 -1
  24. package/dist/worker/console/chat/repo-browser.js +80 -2
  25. package/dist/worker/console/chat/repo-preview-kind.js +155 -0
  26. package/dist/worker/console/chat/session-mode.js +1 -1
  27. package/dist/worker/console/chat/session-store.js +4 -2
  28. package/dist/worker/console/chat/tools.js +12 -0
  29. package/dist/worker/console/routes.js +5 -0
  30. package/dist/worker/console/static/app-icon.svg +39 -0
  31. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-BA7qCL_r.js} +1 -1
  32. package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-Df09ajwL.js} +1 -1
  33. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CpF9sw5R.js} +1 -1
  34. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-BtPys8ja.js} +1 -1
  35. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DZPnTWqT.js} +1 -1
  36. package/dist/worker/console/static/assets/channel-DiTNLZUm.js +1 -0
  37. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-BD9D4h1p.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-Svn8yBmn.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-B8EbQj-S.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-tENZpcpF.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-DBx71Lco.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-1v_QamMz.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-BAMCNNBG.js} +1 -1
  44. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BvuEury-.js} +1 -1
  45. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-YqFuHp8s.js} +1 -1
  46. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-ouI5T7Mr.js} +1 -1
  47. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +1 -0
  48. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +1 -0
  49. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-Bri0_Izk.js} +1 -1
  50. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-e8Vhhi4X.js} +1 -1
  51. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-tSIXGQiJ.js} +1 -1
  52. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-Rl-sF5pt.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-Db6ddPz8.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Jwd_Xw5S.js} +1 -1
  55. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-CeC_8InC.js} +1 -1
  56. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-LKPRaNq8.js} +1 -1
  57. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-DlVQytMW.js} +1 -1
  58. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-DnfJZwO-.js} +1 -1
  59. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-3axVSXca.js} +1 -1
  60. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-xe04-_L-.js} +1 -1
  61. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-hpmRINiD.js} +1 -1
  62. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js} +1 -1
  63. package/dist/worker/console/static/assets/index-C_8_gWal.css +1 -0
  64. package/dist/worker/console/static/assets/{index-CTMZsDmh.js → index-DybcVXCu.js} +102 -100
  65. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-BF6ksWTX.js} +1 -1
  66. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-DXisdeD7.js} +1 -1
  67. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BWu65wmu.js} +1 -1
  68. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-hfNhHqAI.js} +1 -1
  69. package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-D6HTfQcp.js} +1 -1
  70. package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-Iv91R1WP.js} +5 -5
  71. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CfPChwjR.js} +1 -1
  72. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-B7TcoqHz.js} +1 -1
  73. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-ouyPKMtQ.js} +1 -1
  74. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-B9tuyLbh.js} +1 -1
  75. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-Ck4q9nJs.js} +1 -1
  76. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-wXLX5Dnp.js} +1 -1
  77. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-D2XWw6_m.js} +1 -1
  78. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-DzxKiug4.js} +1 -1
  79. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-DWUqK5Us.js} +1 -1
  80. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-D5Pa4O5g.js} +1 -1
  81. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +1 -0
  82. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-VH7cRnsJ.js} +2 -2
  83. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +8 -0
  84. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-C4HM5NoZ.js} +1 -1
  85. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-Bu-4hOlA.js} +1 -1
  86. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-CasFEvvl.js} +1 -1
  87. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M-D-gpwPSA.js} +1 -1
  88. package/dist/worker/console/static/index.html +5 -2
  89. package/dist/worker/console/static/manifest.webmanifest +19 -0
  90. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
  91. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
  92. package/dist/worker/console/workspace-context.js +1 -0
  93. package/dist/worker/materialize/harness-task-lineage.js +4 -2
  94. package/dist/worker/observe/static/console-theme.css +33 -29
  95. package/dist/worker/observe/static/console-theme.js +38 -0
  96. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
  97. package/dist/workflows/dag/backend-test-layout.js +27 -7
  98. package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
  99. package/dist/workflows/dag/frontend-repair.js +24 -0
  100. package/dist/workflows/dag/frontend-review-scopes.js +10 -2
  101. package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
  102. package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
  103. package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
  104. package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
  105. package/dist/workflows/dag/frontend-test-layout.js +41 -12
  106. package/dist/workflows/dag/hybrid/sources.js +21 -2
  107. package/dist/workflows/dag/hybrid/templates/backend-test.js +65 -50
  108. package/dist/workflows/dag/hybrid/templates/frontend-test.js +58 -37
  109. package/dist/workflows/dag/test-artifact-namespace.js +73 -0
  110. package/dist/workflows/dag/validate.js +22 -3
  111. package/docs/architecture/dag-execution.md +3 -3
  112. package/docs/templates/README.md +4 -4
  113. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
  114. package/docs/templates/backend-test-dag.json +708 -708
  115. package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
  116. package/docs/templates/frontend-test-case-checklist.md +2 -2
  117. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
  118. package/docs/templates/frontend-test-dag.json +46 -37
  119. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
  120. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  121. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  122. package/package.json +7 -2
  123. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  124. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  125. package/skills/playwright-cli/SKILL.md +1 -1
  126. package/skills/playwright-cli-case-generator/SKILL.md +4 -4
  127. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
  128. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
  129. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
  130. package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
  131. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
  132. 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
- return orderedUnique([...automation.matchAll(/testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py/gi)]
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 keeps the historical frozen contract:
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 = "testcase";
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
- : normalizeRelPosix(config.testRoot, "testRoot");
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
- return unique([...automation.matchAll(/`?(testcase\/[A-Za-z0-9_./-]*test_[A-Za-z0-9_.-]*\.py)`?/gi)]
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
- const actual = parseFrontendTestExecutionReport(JSON.parse(report.text), cwdPath, rootPath);
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
- if (prompted || isCommandLikeExecutable(normalized))
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
- const body = await readFile(absolute, "utf8");
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: z.literal("vitest-json-v1"),
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
- /** Shared read-only capability resolver, used at generation, admission and
137
- * immediately before execution. It never executes or rewrites an npm script. */
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
- let separator = "";
158
- if (!directVitest(parsed.argv)) {
159
- const argv = parsed.argv;
160
- const name = argv[0] === "npm" ? argv[1] === "test" ? "test" : argv[1] === "run" ? argv[2] : undefined : undefined;
161
- if (!name || !/^[\w:-]+$/.test(name))
162
- throw Error("no supported test reporter adapter for this command");
163
- const rest = argv.slice(argv[1] === "test" ? 2 : 3);
164
- if (rest.length && rest[0] !== "--")
165
- throw Error("npm script arguments require --");
166
- const file = path.relative(root, path.join(cwd, "package.json")).replace(/\\/g, "/");
167
- let manifest = {};
168
- try {
169
- manifest = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
170
- }
171
- catch (error) {
172
- if (error.code !== "ENOENT")
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
- return { observation: { ...base, status: "supported" }, effectiveCwd: cwd,
191
- withReporter: reportPath => parsed.append(`${separator} --reporter=json --outputFile=${quote(reportPath)}`) };
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
- return { observation: { ...base, status: "unsupported", reason: error instanceof Error ? error.message : String(error) }, effectiveCwd: cwd };
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
- if (frozen.scheme !== observed.scheme || frozen.commandSha256 !== observed.commandSha256 || frozen.cwd !== observed.cwd || frozen.packageScript?.file !== observed.packageScript?.file || frozen.packageScript?.name !== observed.packageScript?.name)
203
- return "frozen test command/cwd/script binding changed";
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 "frozen npm test script changed";
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 Vitest commands without changing their selection. */
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
- const resultSchema = z.object({
217
- testResults: z.array(z.object({
218
- name: z.string().min(1),
219
- assertionResults: z.array(z.object({
220
- title: z.string(), fullName: z.string().optional(),
221
- ancestorTitles: z.array(z.string()).optional(), status: z.string(),
222
- })),
223
- })),
224
- });
225
- /** Resolve runner paths against its cwd, then use the contract workspace coordinates. */
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
  }