@tea-agent/loop-agent 0.39.0-next.31 → 0.39.0-next.33

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 (111) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +7 -4
  4. package/dist/executors/pi-playwright-cli-tool.js +23 -16
  5. package/dist/executors/shell-executor.js +25 -8
  6. package/dist/shared/dag-failure-category.js +138 -0
  7. package/dist/task/config-types.js +6 -0
  8. package/dist/worker/console/chat/operation-card.js +8 -1
  9. package/dist/worker/console/chat/pi-console-config.js +81 -10
  10. package/dist/worker/console/chat/routes.js +5 -3
  11. package/dist/worker/console/inspect-split.js +18 -0
  12. package/dist/worker/console/operation-run-facts.js +19 -3
  13. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-gauhD9kD.js → abnfDiagram-N423BO3Z-Br92EGb7.js} +1 -1
  14. package/dist/worker/console/static/assets/{arc-pm3c2Rsl.js → arc-oulImtQq.js} +1 -1
  15. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-D_JAg3wH.js → architectureDiagram-T3A2C74G-tyHc6aqO.js} +1 -1
  16. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Dk7-ex52.js → blockDiagram-VBNYF7ZC-CY86BSpT.js} +1 -1
  17. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Uval9oRI.js → c4Diagram-5PPSVZJV-BKKsDmNs.js} +1 -1
  18. package/dist/worker/console/static/assets/channel-TY36zt-i.js +1 -0
  19. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BSYjHs-v.js → chunk-2GRJ4B5K-CXZdjl3B.js} +1 -1
  20. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-KGUuBXI8.js → chunk-2Q5K7J3B-B-aIFDQ5.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-JqAtxD9N.js → chunk-5RXB4S5H-BDgAW_B2.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-D6EJOVQK.js → chunk-5VM5RSS4-DABa4UPR.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-B1JriSjm.js → chunk-6Q2QTUOP-aGbysVOx.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D62b96Jd.js → chunk-GF5L2VYU-B8MSZDVs.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BsNKEC_r.js → chunk-JWPE2WC7-CpelV73g.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B56v6-ZP.js → chunk-KBJHAD2P-BE04ty3z.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-D2KPnd8K.js → chunk-RYQCIY6F-BUxTkaaR.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-kzKSCaaL.js → chunk-XXDRQBXY-CYYBVKfX.js} +1 -1
  29. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +1 -0
  30. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +1 -0
  31. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C6N6KNJd.js → cose-bilkent-JH36ORCC-CEQF_xmQ.js} +1 -1
  32. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DiyymT2D.js → cynefin-VYW2F7L2-CqpxN5IT.js} +1 -1
  33. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BeKAXvf3.js → cynefinDiagram-MW4NZA55-B3PzanVi.js} +1 -1
  34. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-TkIdXfPF.js → dagre-VZM6K2ZE-BSUGCNx1.js} +1 -1
  35. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BFmSG3ks.js → diagram-7IWD3JNH-CdLevU5T.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqpJT2Fs.js → diagram-B4RE2ZJO-BjpF_Kok.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-r5ATN43t.js → diagram-LBJQPF4R-C4I6NJny.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DEcSCMuR.js → diagram-Q27KOJAE-C8hecCr5.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-UB23O5K3-VOvYRLVc.js → diagram-UB23O5K3-Daod6tOS.js} +1 -1
  40. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-D7f4Cb1u.js → ebnfDiagram-BXEA7PRR-BvvmazGv.js} +1 -1
  41. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-ziUf3U0G.js → erDiagram-JOGREHBK-DeN_vfV4.js} +1 -1
  42. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BoRdQ-1y.js → flowDiagram-UKHOOZJN-D6dq1t9z.js} +1 -1
  43. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-B1-GncNm.js → ganttDiagram-PKOTCBZU-DziIdNY4.js} +1 -1
  44. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CTW-ph8W.js → gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js} +1 -1
  45. package/dist/worker/console/static/assets/index-C3GgHg-g.css +1 -0
  46. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +406 -0
  47. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BFA337tZ.js → infoDiagram-6WML65LV-Czv5eYx2.js} +1 -1
  48. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BtEujgRR.js → ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js} +1 -1
  49. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C8ZwD2Jp.js → journeyDiagram-NVQOT4AX-DxGmNj5K.js} +1 -1
  50. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Ck-z8B11.js → kanban-definition-27J2QSJJ-CYCFqkO_.js} +1 -1
  51. package/dist/worker/console/static/assets/{linear-CAyXjQjN.js → linear-B0YzRwrd.js} +1 -1
  52. package/dist/worker/console/static/assets/{mermaid.core-CcWKn6tS.js → mermaid.core-t3EZtIGh.js} +5 -5
  53. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-C2NuDO7E.js → mindmap-definition-FAOFIHXS-BTXpTv0T.js} +1 -1
  54. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BFub3PDz.js → pegDiagram-VL7TDLO6-DU8UkKqA.js} +1 -1
  55. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BJx5THZ5.js → pieDiagram-7S7Q4E2Y-DP4y8ohp.js} +1 -1
  56. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DhalkDWV.js → quadrantDiagram-CIZ2JOQS-B07vNaYR.js} +1 -1
  57. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-CVcBM36o.js → railroadDiagram-AXF67PYL-xDWktb2i.js} +1 -1
  58. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D_vy4n0w.js → requirementDiagram-LRYGKXZP-BLNMKqeS.js} +1 -1
  59. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DGYdJL1D.js → sankeyDiagram-W5VNT64P-n0adalxH.js} +1 -1
  60. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C1xJFg3k.js → sequenceDiagram-SI44F4Z6-BbnS-2DL.js} +1 -1
  61. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-D_Fc43df.js → sizeCapture-X5ZJPWSS-B4uPpCLu.js} +1 -1
  62. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BhGbIjbu.js → stateDiagram-OKZ733FA--MMbB0fX.js} +1 -1
  63. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +1 -0
  64. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DVkNcO-V.js → swimlanes-SLNWSIFB-BXUgp2QY.js} +2 -2
  65. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +8 -0
  66. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BQU9ufV0.js → timeline-definition-Z64GVDOM-CsaMxki7.js} +1 -1
  67. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BCQhRmYB.js → vennDiagram-T6HMQDX7-C2IuG9iH.js} +1 -1
  68. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DWerOyHO.js → wardleyDiagram-T6FBY63Y-CmEWOXCW.js} +1 -1
  69. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DxLeebQU.js → xychartDiagram-ELKLHX3M-D_9LE0MG.js} +1 -1
  70. package/dist/worker/console/static/index.html +2 -2
  71. package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
  72. package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
  73. package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
  74. package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
  75. package/dist/worker/console/static-src/operator-chat/turn-submission.js +13 -1
  76. package/dist/worker/console/static-src/operator-chat/useChatStream.js +29 -9
  77. package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
  78. package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
  79. package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
  80. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
  81. package/dist/worker/materialize/harness-task-lineage.js +5 -2
  82. package/dist/worker/observability/read-model.js +22 -0
  83. package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
  84. package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
  85. package/dist/workflows/dag/failure-category.js +7 -128
  86. package/dist/workflows/dag/frontend-test-case-checklist.js +4 -2
  87. package/dist/workflows/dag/frontend-test-case-manifest.js +6 -4
  88. package/dist/workflows/dag/frontend-test-case-quality.js +6 -3
  89. package/dist/workflows/dag/frontend-test-environment-probe.js +10 -7
  90. package/dist/workflows/dag/frontend-test-html-report.js +3 -1
  91. package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
  92. package/dist/workflows/dag/frontend-test-layout.js +159 -0
  93. package/dist/workflows/dag/frontend-test-result-contract.js +28 -14
  94. package/dist/workflows/dag/frontend-test-standard-scenarios.js +3 -1
  95. package/dist/workflows/dag/init-hybrid.js +58 -13
  96. package/dist/workflows/dag/types.js +12 -0
  97. package/dist/workflows/dag/validate.js +23 -19
  98. package/docs/templates/frontend-test-case-checklist.md +2 -2
  99. package/harness.json +3 -3
  100. package/package.json +1 -1
  101. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  102. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  103. package/skills/playwright-cli/SKILL.md +1 -1
  104. package/skills/playwright-cli-case-generator/SKILL.md +10 -8
  105. package/dist/worker/console/static/assets/channel-CVrnUdZl.js +0 -1
  106. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-aX91WTya.js +0 -1
  107. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-aX91WTya.js +0 -1
  108. package/dist/worker/console/static/assets/index-C8rOIAJr.css +0 -1
  109. package/dist/worker/console/static/assets/index-DEbyrEfW.js +0 -404
  110. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DUt1lyFL.js +0 -1
  111. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BM6SGr7j.js +0 -8
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { markdownSection } from "./frontend-test-markdown.js";
4
+ import { defaultFrontendTestLayout, frontendTestCasePath, } from "./frontend-test-layout.js";
4
5
  const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
5
6
  const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
6
7
  const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
@@ -15,7 +16,8 @@ const SECTION_ALIASES = {
15
16
  function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
16
17
  function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
17
18
  export async function validateFrontendCaseContent(input) {
18
- const root = path.join(input.workspaceRoot, "testcase/frontend/cases");
19
+ const layout = input.layout ?? defaultFrontendTestLayout();
20
+ const root = path.join(input.workspaceRoot, layout.casesDir);
19
21
  const manifestPath = path.join(root, "manifest.json");
20
22
  const findings = [];
21
23
  let manifest;
@@ -49,7 +51,7 @@ export async function validateFrontendCaseContent(input) {
49
51
  findings.push({ ruleId: "unknown-ac", caseId: id, detail: `${ac} is not declared by the task` });
50
52
  }
51
53
  }
52
- const file = path.join(input.workspaceRoot, item?.casePath || `testcase/frontend/cases/${id}.md`);
54
+ const file = path.join(input.workspaceRoot, item?.casePath || frontendTestCasePath(layout, id));
53
55
  let body = "";
54
56
  try {
55
57
  body = await readFile(file, "utf8");
@@ -89,7 +91,8 @@ function render(findings) {
89
91
  }
90
92
  export async function writeFrontendCaseQualityReports(input) {
91
93
  const result = await validateFrontendCaseContent(input);
92
- const dir = path.join(input.workspaceRoot, input.outputDir ?? "testcase/frontend/reports");
94
+ const layout = input.layout ?? defaultFrontendTestLayout();
95
+ const dir = path.join(input.workspaceRoot, input.outputDir ?? layout.reportsDir);
93
96
  await mkdir(dir, { recursive: true });
94
97
  await writeFile(path.join(dir, "frontend-test-case-quality-advisory.md"), result.markdown, "utf8");
95
98
  await writeFile(path.join(dir, "frontend-test-case-quality-advisory.html"), result.html, "utf8");
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { defaultFrontendTestLayout, frontendTestContextRel, frontendTestProbeRel, } from "./frontend-test-layout.js";
4
5
  export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
5
6
  export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
6
7
  const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
@@ -65,9 +66,9 @@ function resolveBaseUrl(context) {
65
66
  }
66
67
  return null;
67
68
  }
68
- async function writeProbe(workspaceRoot, probe) {
69
- const probePath = path.join(workspaceRoot, FRONTEND_TEST_PROBE_REL);
70
- const contextPath = path.join(workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
69
+ async function writeProbe(workspaceRoot, probe, layout) {
70
+ const probePath = path.join(workspaceRoot, frontendTestProbeRel(layout));
71
+ const contextPath = path.join(workspaceRoot, frontendTestContextRel(layout));
71
72
  await mkdir(path.dirname(probePath), { recursive: true });
72
73
  await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
73
74
  let context = "";
@@ -84,14 +85,16 @@ async function writeProbe(workspaceRoot, probe) {
84
85
  await writeFile(contextPath, next, "utf8");
85
86
  }
86
87
  export async function probeFrontendTestEnvironment(input) {
88
+ const layout = input.layout ?? defaultFrontendTestLayout();
87
89
  const curl = input.curl ?? defaultCurlRunner;
88
- const contextPath = path.join(input.workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
90
+ const contextRel = frontendTestContextRel(layout);
91
+ const contextPath = path.join(input.workspaceRoot, contextRel);
89
92
  let context;
90
93
  try {
91
94
  context = await readFile(contextPath, "utf8");
92
95
  }
93
96
  catch {
94
- return { ok: false, stderr: `missing ${FRONTEND_TEST_CONTEXT_REL}` };
97
+ return { ok: false, stderr: `missing ${contextRel}` };
95
98
  }
96
99
  const rawUrl = resolveBaseUrl(context);
97
100
  if (!rawUrl) {
@@ -139,7 +142,7 @@ export async function probeFrontendTestEnvironment(input) {
139
142
  ...(extra.hint ? { hint: extra.hint } : {}),
140
143
  ...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
141
144
  };
142
- await writeProbe(input.workspaceRoot, probe);
145
+ await writeProbe(input.workspaceRoot, probe, layout);
143
146
  const payload = {
144
147
  blockedReason: reason,
145
148
  baseUrl: probe.baseUrlRedacted,
@@ -218,7 +221,7 @@ export async function probeFrontendTestEnvironment(input) {
218
221
  curlExit: result.status,
219
222
  errorClass: null,
220
223
  };
221
- await writeProbe(input.workspaceRoot, probe);
224
+ await writeProbe(input.workspaceRoot, probe, layout);
222
225
  return {
223
226
  ok: true,
224
227
  stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { frontendTestResultContractSchema } from "./frontend-test-result-contract.js";
4
+ import { defaultFrontendTestLayout, } from "./frontend-test-layout.js";
4
5
  function escapeHtml(value) {
5
6
  return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
6
7
  }
@@ -80,7 +81,8 @@ async function writePairAtomic(markdownPath, markdown, htmlPath, html) {
80
81
  export async function renderFrontendTestHtmlReport(input) {
81
82
  const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
82
83
  const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
83
- const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
84
+ const layout = input.layout ?? defaultFrontendTestLayout();
85
+ const outputDir = path.join(input.workspaceRoot, layout.reportsDir);
84
86
  const markdownPath = path.join(outputDir, "frontend-test-report.md");
85
87
  const htmlPath = path.join(outputDir, "frontend-test-report.html");
86
88
  const outcomeLabel = result.outcome === "passed" ? "测试通过" : result.outcome === "failed" ? "测试失败" : "测试未完成";
@@ -1,6 +1,7 @@
1
1
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { frontendTestResultContractSchema, } from "./frontend-test-result-contract.js";
4
+ import { defaultFrontendTestLayout, } from "./frontend-test-layout.js";
4
5
  function ratioMetric(numerator, denominator, threshold, label) {
5
6
  if (denominator === 0) {
6
7
  return { numerator, denominator, ratio: null, threshold, status: "unavailable", reason: `${label}-denominator-is-zero` };
@@ -169,7 +170,8 @@ export async function renderFrontendTestL5Report(input) {
169
170
  const resultPath = path.join(input.runDir, "contracts", "frontend-test-result.json");
170
171
  const result = frontendTestResultContractSchema.parse(JSON.parse(await readFile(resultPath, "utf8")));
171
172
  const metrics = computeFrontendL5ReportMetrics(result);
172
- const outputDir = path.join(input.workspaceRoot, "testcase", "frontend", "reports");
173
+ const layout = input.layout ?? defaultFrontendTestLayout();
174
+ const outputDir = path.join(input.workspaceRoot, layout.reportsDir);
173
175
  const markdownPath = path.join(outputDir, "frontend-test-l5-dashboard.md");
174
176
  const htmlPath = path.join(outputDir, "frontend-test-l5-dashboard.html");
175
177
  await writePairAtomic(markdownPath, renderMarkdown(result, metrics), htmlPath, renderHtml(result, metrics));
@@ -0,0 +1,159 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Frontend-test artifact layout (configurable testRoot).
4
+ *
5
+ * Resolved once from `task.frontendTest.testRoot` at DAG compile time and
6
+ * frozen on the spec for the whole run. Writers, gates, Playwright fences
7
+ * and TaskSpec allowedPaths all derive from this single object.
8
+ *
9
+ * Default layout keeps the historical frozen contract:
10
+ * testcase/frontend/rag/**
11
+ * testcase/frontend/cases/**
12
+ * testcase/frontend/evidence/**
13
+ * testcase/frontend/reports/**
14
+ * testcase/frontend/fixtures/**
15
+ */
16
+ export const FRONTEND_TEST_DEFAULT_TEST_ROOT = "testcase/frontend";
17
+ const FORBIDDEN_TEST_ROOT_PREFIXES = [
18
+ "src",
19
+ "app",
20
+ "lib",
21
+ "apps",
22
+ "packages",
23
+ ".harness",
24
+ ".git",
25
+ "node_modules",
26
+ "dist",
27
+ "build",
28
+ "docs",
29
+ "website",
30
+ "scripts",
31
+ "test",
32
+ "smoke",
33
+ "knowledge",
34
+ "features",
35
+ "dogfood",
36
+ "examples",
37
+ "bin",
38
+ ];
39
+ function normalizeRelPosix(value, label) {
40
+ const trimmed = value.trim();
41
+ if (!trimmed)
42
+ throw new Error(`frontendTest.${label} must not be empty`);
43
+ if (trimmed.includes("\\")) {
44
+ throw new Error(`frontendTest.${label} must use forward slashes: ${value}`);
45
+ }
46
+ if (path.isAbsolute(trimmed) || /^[A-Za-z]:/.test(trimmed)) {
47
+ throw new Error(`frontendTest.${label} must be a repository-relative path: ${value}`);
48
+ }
49
+ const segments = trimmed.split("/").filter((segment) => segment.length > 0);
50
+ if (segments.some((segment) => segment === ".." || segment === ".")) {
51
+ throw new Error(`frontendTest.${label} must not contain "." or ".." segments: ${value}`);
52
+ }
53
+ if (segments.some((segment) => segment.startsWith("."))) {
54
+ throw new Error(`frontendTest.${label} must not target hidden directories: ${value}`);
55
+ }
56
+ if (segments.some((segment) => !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(segment))) {
57
+ throw new Error(`frontendTest.${label} contains unsafe path segments: ${value}`);
58
+ }
59
+ return segments.join("/");
60
+ }
61
+ function assertTestRootAllowed(testRoot) {
62
+ const topSegment = testRoot.split("/")[0];
63
+ if (FORBIDDEN_TEST_ROOT_PREFIXES.some((forbidden) => topSegment === forbidden || topSegment.startsWith(`${forbidden}.`))) {
64
+ throw new Error(`frontendTest.testRoot must not target production/governance trees: ${testRoot}`);
65
+ }
66
+ if (topSegment.startsWith(".")) {
67
+ throw new Error(`frontendTest.testRoot must not target hidden directories: ${testRoot}`);
68
+ }
69
+ }
70
+ /**
71
+ * Resolve the frozen frontend-test artifact layout from task config.
72
+ * Throws on unsafe or ambiguous configuration (fail-closed).
73
+ */
74
+ export function resolveFrontendTestLayout(config) {
75
+ const testRoot = config?.testRoot === undefined || config?.testRoot === null
76
+ ? FRONTEND_TEST_DEFAULT_TEST_ROOT
77
+ : normalizeRelPosix(config.testRoot, "testRoot");
78
+ assertTestRootAllowed(testRoot);
79
+ const isDefault = testRoot === FRONTEND_TEST_DEFAULT_TEST_ROOT;
80
+ return {
81
+ testRoot,
82
+ ragDir: `${testRoot}/rag`,
83
+ casesDir: `${testRoot}/cases`,
84
+ evidenceDir: `${testRoot}/evidence`,
85
+ reportsDir: `${testRoot}/reports`,
86
+ fixturesDir: `${testRoot}/fixtures`,
87
+ isDefault,
88
+ };
89
+ }
90
+ /** Historical default layout. Old DAGs without frontendTestLayout use this. */
91
+ export function defaultFrontendTestLayout() {
92
+ return resolveFrontendTestLayout(undefined);
93
+ }
94
+ export function frontendTestLayoutFromSpec(spec) {
95
+ const frozen = spec?.frontendTestLayout;
96
+ if (!frozen)
97
+ return defaultFrontendTestLayout();
98
+ return resolveFrontendTestLayout({ testRoot: frozen.testRoot });
99
+ }
100
+ const DEFAULT_PREFIX = `${FRONTEND_TEST_DEFAULT_TEST_ROOT}/`;
101
+ /**
102
+ * Longest-prefix rewrite of historical `testcase/frontend/` paths.
103
+ * No-op when layout.isDefault so default DAGs stay byte-identical.
104
+ * Never replaces the short token `testcase/` (would collide with backend-test).
105
+ */
106
+ export function applyFrontendTestLayoutToText(text, layout) {
107
+ if (layout.isDefault)
108
+ return text;
109
+ const replacement = `${layout.testRoot}/`;
110
+ let output = "";
111
+ for (let i = 0; i < text.length;) {
112
+ if (text.startsWith(replacement, i)) {
113
+ output += replacement;
114
+ i += replacement.length;
115
+ continue;
116
+ }
117
+ if (text.startsWith(DEFAULT_PREFIX, i)) {
118
+ output += replacement;
119
+ i += DEFAULT_PREFIX.length;
120
+ continue;
121
+ }
122
+ output += text[i];
123
+ i += 1;
124
+ }
125
+ return output;
126
+ }
127
+ export function frontendTestCasePath(layout, caseId) {
128
+ return `${layout.casesDir}/${caseId}.md`;
129
+ }
130
+ export function frontendTestEvidenceDir(layout, caseId) {
131
+ return `${layout.evidenceDir}/${caseId}/`;
132
+ }
133
+ export function frontendTestEvidencePrefix(layout, caseId) {
134
+ return `${layout.evidenceDir}/${caseId}`;
135
+ }
136
+ export function frontendTestManifestPath(layout) {
137
+ return `${layout.casesDir}/manifest.json`;
138
+ }
139
+ export function frontendTestManifestDraftPath(layout) {
140
+ return `${layout.casesDir}/manifest.draft.json`;
141
+ }
142
+ export function frontendTestContextRel(layout) {
143
+ return `${layout.ragDir}/context.md`;
144
+ }
145
+ export function frontendTestCoverageMapRel(layout) {
146
+ return `${layout.ragDir}/coverage-map.md`;
147
+ }
148
+ export function frontendTestProbeRel(layout) {
149
+ return `${layout.ragDir}/environment-probe.json`;
150
+ }
151
+ export function frontendTestStandardScenariosRel(layout) {
152
+ return `${layout.ragDir}/standard-scenarios.v1.json`;
153
+ }
154
+ export function frontendTestUiAnchorsRel(layout) {
155
+ return `${layout.ragDir}/ui-anchors.md`;
156
+ }
157
+ export function escapeRegexLiteral(value) {
158
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }
@@ -6,6 +6,7 @@ import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-s
6
6
  import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../../executors/pi-playwright-cli-tool.js";
7
7
  import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
8
8
  import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
9
+ import { defaultFrontendTestLayout, frontendTestEvidencePrefix, frontendTestManifestPath, } from "./frontend-test-layout.js";
9
10
  export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
10
11
  /**
11
12
  * Truncated acceptance-id shapes observed in generator output: a bare
@@ -124,12 +125,14 @@ function isSafeEvidenceShellPath(value) {
124
125
  * backslash interpretation for the former long `node -e` command.
125
126
  */
126
127
  export async function validateFrontendCaseEvidence(input) {
127
- const manifestPath = path.join(input.workspaceRoot, "testcase/frontend/cases/manifest.json");
128
+ const layout = input.layout ?? defaultFrontendTestLayout();
129
+ const manifestRel = frontendTestManifestPath(layout);
130
+ const manifestPath = path.join(input.workspaceRoot, manifestRel);
128
131
  try {
129
132
  await stat(manifestPath);
130
133
  }
131
134
  catch {
132
- throw new Error("missing testcase/frontend/cases/manifest.json");
135
+ throw new Error(`missing ${manifestRel}`);
133
136
  }
134
137
  let manifest;
135
138
  try {
@@ -147,7 +150,7 @@ export async function validateFrontendCaseEvidence(input) {
147
150
  const item = rawCase;
148
151
  const id = typeof item?.caseId === "string" ? item.caseId : "?";
149
152
  const dir = item?.evidenceDir;
150
- const prefix = `testcase/frontend/evidence/${id}`;
153
+ const prefix = frontendTestEvidencePrefix(layout, id);
151
154
  if (!item ||
152
155
  typeof item.caseId !== "string" ||
153
156
  typeof dir !== "string" ||
@@ -191,12 +194,15 @@ export async function validateFrontendCaseEvidence(input) {
191
194
  continue;
192
195
  }
193
196
  const currentPrefix = `${prefix}/`;
194
- if (evidencePath.startsWith("testcase/frontend/evidence/") && !evidencePath.startsWith(currentPrefix)) {
197
+ if ((evidencePath.startsWith(`${layout.evidenceDir}/`) ||
198
+ evidencePath.startsWith("testcase/frontend/evidence/")) &&
199
+ !evidencePath.startsWith(currentPrefix)) {
195
200
  hardFail = true;
196
201
  issues.push({ ruleId: "cross-case-evidence-path", caseId: id, detail: evidencePath });
197
202
  continue;
198
203
  }
199
- const target = evidencePath.startsWith("testcase/")
204
+ const target = evidencePath.startsWith(`${layout.testRoot}/`) ||
205
+ evidencePath.startsWith("testcase/")
200
206
  ? path.join(input.workspaceRoot, evidencePath)
201
207
  : path.join(input.workspaceRoot, dir, evidencePath);
202
208
  if (!(await isNonEmptyFile(target))) {
@@ -343,8 +349,10 @@ function sha256(content) {
343
349
  * headings. Numbered and bulleted lists are extracted; a missing 测试目的
344
350
  * falls back to the case H1 title.
345
351
  */
346
- async function readFrontendCaseContent(workspaceRoot, casePath) {
347
- if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
352
+ async function readFrontendCaseContent(workspaceRoot, casePath, layout) {
353
+ if (!safeRelativePathSchema.safeParse(casePath).success ||
354
+ !(casePath.startsWith(`${layout.casesDir}/`) ||
355
+ casePath.startsWith("testcase/frontend/cases/"))) {
348
356
  throw new Error(`unsafe frontend case path: ${casePath}`);
349
357
  }
350
358
  const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
@@ -397,7 +405,7 @@ function assertInside(root, candidate, label) {
397
405
  throw new Error(`${label} escapes its allowed root`);
398
406
  }
399
407
  }
400
- async function readEvidenceFile(repoRoot, evidenceRoot, relative) {
408
+ async function readEvidenceFile(repoRoot, evidenceRoot, relative, layout) {
401
409
  if (!safeRelativePathSchema.safeParse(relative).success) {
402
410
  throw new Error(`unsafe evidence path: ${relative}`);
403
411
  }
@@ -409,12 +417,16 @@ async function readEvidenceFile(repoRoot, evidenceRoot, relative) {
409
417
  const normalized = relative;
410
418
  const evidenceRootRel = path.relative(repoRootResolved, evidenceRootResolved).replaceAll("\\", "/");
411
419
  const isCurrentCaseRepoPath = normalized === evidenceRootRel || normalized.startsWith(`${evidenceRootRel}/`);
412
- const isRepoRelative = normalized.startsWith("testcase/") && !isCurrentCaseRepoPath;
420
+ const isRepoRelative = (normalized.startsWith(`${layout.testRoot}/`) ||
421
+ normalized.startsWith("testcase/")) &&
422
+ !isCurrentCaseRepoPath;
413
423
  const absolute = isRepoRelative
414
424
  ? path.resolve(repoRootResolved, normalized)
415
425
  : path.resolve(evidenceRootResolved, normalized);
416
426
  assertInside(repoRootResolved, absolute, "evidence path");
417
- if (isRepoRelative && normalized.startsWith("testcase/frontend/evidence/")) {
427
+ if (isRepoRelative &&
428
+ (normalized.startsWith(`${layout.evidenceDir}/`) ||
429
+ normalized.startsWith("testcase/frontend/evidence/"))) {
418
430
  throw new Error(`evidence path escapes evidence root: ${relative}`);
419
431
  }
420
432
  if (!isRepoRelative)
@@ -437,7 +449,8 @@ export async function materializeFrontendTestResult(input) {
437
449
  if (!sourceBinding?.taskId || !sourceBinding.sources?.length) {
438
450
  throw new Error("frontend-test result requires frozen sourceBinding");
439
451
  }
440
- const manifestPath = "testcase/frontend/cases/manifest.json";
452
+ const layout = input.layout ?? defaultFrontendTestLayout();
453
+ const manifestPath = frontendTestManifestPath(layout);
441
454
  const manifestAbsolute = path.resolve(input.workspaceRoot, manifestPath);
442
455
  let manifest;
443
456
  try {
@@ -453,6 +466,7 @@ export async function materializeFrontendTestResult(input) {
453
466
  const contentQuality = await validateFrontendCaseContent({
454
467
  workspaceRoot: input.workspaceRoot,
455
468
  requiredAcIds: sourceBinding.requirementIds ?? [],
469
+ layout,
456
470
  });
457
471
  advisoryFindings.push(...contentQuality.findings);
458
472
  const requiredIds = new Set(sourceBinding.requirementIds ?? []);
@@ -464,7 +478,7 @@ export async function materializeFrontendTestResult(input) {
464
478
  advisoryFindings.push({ ruleId: "invalid-manifest-case", detail: "manifest case is missing caseId, acIds, or evidenceDir" });
465
479
  continue;
466
480
  }
467
- const evidencePrefix = `testcase/frontend/evidence/${item.caseId}`;
481
+ const evidencePrefix = frontendTestEvidencePrefix(layout, item.caseId);
468
482
  if (!(item.evidenceDir === evidencePrefix || item.evidenceDir.startsWith(`${evidencePrefix}/`))) {
469
483
  throw new Error(`unsafe evidenceDir for ${item.caseId}`);
470
484
  }
@@ -495,7 +509,7 @@ export async function materializeFrontendTestResult(input) {
495
509
  if (typeof evidencePath !== "string" || evidencePath === "execution.md" && evidence.some((entry) => entry.path.endsWith("/execution.md")))
496
510
  continue;
497
511
  try {
498
- evidence.push(await readEvidenceFile(input.workspaceRoot, evidenceRoot, evidencePath));
512
+ evidence.push(await readEvidenceFile(input.workspaceRoot, evidenceRoot, evidencePath, layout));
499
513
  }
500
514
  catch (error) {
501
515
  const message = error instanceof Error ? error.message : String(error);
@@ -541,7 +555,7 @@ export async function materializeFrontendTestResult(input) {
541
555
  }
542
556
  }
543
557
  }
544
- const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath);
558
+ const { caseContent, testPoints } = await readFrontendCaseContent(input.workspaceRoot, item.casePath, layout);
545
559
  // Parse bounded fixed sections from execution.md. Missing optional
546
560
  // summary/steps are advisory only and never downgrade a passed status.
547
561
  let executionSummary = "";
@@ -1,5 +1,6 @@
1
1
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { defaultFrontendTestLayout, frontendTestStandardScenariosRel, } from "./frontend-test-layout.js";
3
4
  export const FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME = "frontend-test-standard-scenarios.v1.json";
4
5
  export const FRONTEND_TEST_STANDARD_SCENARIOS_DEST = "testcase/frontend/rag/standard-scenarios.v1.json";
5
6
  const MINIMAL_STANDARD_SCENARIOS = {
@@ -47,7 +48,8 @@ async function readGovernanceRoot(workspaceRoot) {
47
48
  }
48
49
  }
49
50
  export async function copyFrontendTestStandardScenarios(input) {
50
- const destRel = FRONTEND_TEST_STANDARD_SCENARIOS_DEST;
51
+ const layout = input.layout ?? defaultFrontendTestLayout();
52
+ const destRel = frontendTestStandardScenariosRel(layout);
51
53
  const destAbs = path.join(input.workspaceRoot, destRel);
52
54
  const candidates = listFrontendTestStandardScenarioCandidates({
53
55
  governanceRoot: await readGovernanceRoot(input.workspaceRoot),
@@ -28,6 +28,7 @@ import { resolveExecutorModelMatrices } from "../../executors/model-routing.js";
28
28
  import { normalizeTaskRequirementText, resolveTaskDagTemplateSelection, } from "./task-demand-routing.js";
29
29
  import { BACKEND_TEST_EXECUTION_DEFAULT_TEST_ROOT, buildBackendTestExecutionPreflightShellSnippet, } from "./backend-test-execution-contract.js";
30
30
  import { resolveBackendTestLayout, } from "./backend-test-layout.js";
31
+ import { applyFrontendTestLayoutToText, resolveFrontendTestLayout, } from "./frontend-test-layout.js";
31
32
  import { buildBackendTestOutcomeGateShellSnippet } from "./backend-test-result-contract.js";
32
33
  import { buildBackendTestIntakeContext } from "./backend-test-intake-context.js";
33
34
  import { buildFrontendTestOutcomeGateShellSnippet } from "./frontend-test-result-contract.js";
@@ -4798,11 +4799,57 @@ function applyBackendTestLayoutToDagSpec(spec, layout) {
4798
4799
  spec.successCriteria = spec.successCriteria?.map(rewrite);
4799
4800
  spec.backendTestLayout = layout;
4800
4801
  }
4802
+ /** Rewrite layout-dependent strings across a compiled frontend-test DAG spec. */
4803
+ function applyFrontendTestLayoutToDagSpec(spec, layout) {
4804
+ if (layout.isDefault) {
4805
+ spec.frontendTestLayout = layout;
4806
+ return;
4807
+ }
4808
+ const rewrite = (value) => applyFrontendTestLayoutToText(value, layout);
4809
+ for (const task of spec.tasks) {
4810
+ task.subtask_prompt = rewrite(task.subtask_prompt);
4811
+ if (task.outputContract)
4812
+ task.outputContract = rewrite(task.outputContract);
4813
+ task.writeSet = task.writeSet?.map(rewrite);
4814
+ task.allowedPaths = task.allowedPaths?.map(rewrite);
4815
+ task.forbiddenPaths = task.forbiddenPaths?.map(rewrite);
4816
+ if (task.dynamicExpansion?.childTask) {
4817
+ const child = task.dynamicExpansion.childTask;
4818
+ if (typeof child.subtaskPromptTemplate === "string") {
4819
+ child.subtaskPromptTemplate = rewrite(child.subtaskPromptTemplate);
4820
+ }
4821
+ if (typeof child.outputContract === "string") {
4822
+ child.outputContract = rewrite(child.outputContract);
4823
+ }
4824
+ if (Array.isArray(child.allowedPaths)) {
4825
+ child.allowedPaths = child.allowedPaths.map((entry) => typeof entry === "string" ? rewrite(entry) : entry);
4826
+ }
4827
+ if (Array.isArray(child.writeSet)) {
4828
+ child.writeSet = child.writeSet.map((entry) => typeof entry === "string" ? rewrite(entry) : entry);
4829
+ }
4830
+ if (Array.isArray(child.forbiddenPaths)) {
4831
+ child.forbiddenPaths = child.forbiddenPaths.map((entry) => typeof entry === "string" ? rewrite(entry) : entry);
4832
+ }
4833
+ }
4834
+ if (task.shell?.commands) {
4835
+ task.shell.commands = task.shell.commands.map(rewrite);
4836
+ }
4837
+ }
4838
+ spec.globalConstraints = spec.globalConstraints?.map(rewrite);
4839
+ spec.successCriteria = spec.successCriteria?.map(rewrite);
4840
+ spec.frontendTestLayout = layout;
4841
+ }
4842
+ function allowedPathsCoverFrontendTestRoot(allowedPaths, testRoot) {
4843
+ const probe = `${testRoot}/_layout_probe_`;
4844
+ return allowedPaths.some((pattern) => pathMatchesPattern(testRoot, pattern) ||
4845
+ pathMatchesPattern(probe, pattern));
4846
+ }
4801
4847
  // ---------------------------------------------------------------------------
4802
4848
  // Frontend browser-test RAG DAG template
4803
4849
  // ---------------------------------------------------------------------------
4804
4850
  function buildFrontendTestHybridDag(sources) {
4805
4851
  const rawFrontendTest = sources.taskConfig.frontendTest;
4852
+ const layout = resolveFrontendTestLayout(rawFrontendTest);
4806
4853
  const config = {
4807
4854
  // Default 32: common FE suites cover ~24 AC with multi-dimension cases; 20 caused map maxExpandedNodes failures.
4808
4855
  maxCasesPerBatch: rawFrontendTest?.maxCasesPerBatch ?? 32,
@@ -4827,9 +4874,9 @@ function buildFrontendTestHybridDag(sources) {
4827
4874
  // Optional project-local UI anchor ledger: only mention it in prompts when it
4828
4875
  // exists so ledger-less projects keep generating without noise.
4829
4876
  const hasUiAnchorsLedger = Boolean(sources.repoRoot &&
4830
- existsSync(path.join(sources.repoRoot, "testcase/frontend/rag/ui-anchors.md")));
4877
+ existsSync(path.join(sources.repoRoot, layout.ragDir, "ui-anchors.md")));
4831
4878
  const uiAnchorsLedgerInstruction = hasUiAnchorsLedger
4832
- ? "Also read testcase/frontend/rag/ui-anchors.md (UI anchor ledger). Every passing find assertion in generated cases must quote a ledger row whose 状态 is 有效 for the matching page x state section; rows marked 不可断言/失效 must not be used as passing assertions. When an AC names a control absent from the ledger section for that state, emit a blocked case note (blockedReason unique-control-unavailable) instead of exploratory find steps, and follow ledger 备注 alternatives (split-node short literals) exactly."
4879
+ ? `Also read ${layout.ragDir}/ui-anchors.md (UI anchor ledger). Every passing find assertion in generated cases must quote a ledger row whose 状态 is 有效 for the matching page x state section; rows marked 不可断言/失效 must not be used as passing assertions. When an AC names a control absent from the ledger section for that state, emit a blocked case note (blockedReason unique-control-unavailable) instead of exploratory find steps, and follow ledger 备注 alternatives (split-node short literals) exactly.`
4833
4880
  : "";
4834
4881
  const reviewMode = config.reviewMode;
4835
4882
  const blockingReview = reviewMode === "blocking";
@@ -4837,22 +4884,19 @@ function buildFrontendTestHybridDag(sources) {
4837
4884
  const maxRerunAttempts = config.maxRerunAttempts;
4838
4885
  const enableRetrospect = config.reports?.retrospect === true;
4839
4886
  const enableL5Report = config.reports?.l5 !== false;
4840
- const hasFrontendTestWriteScope = sources.taskConfig.allowedPaths.some((pattern) => pattern === "testcase/frontend/**" ||
4841
- pattern === "testcase/**" ||
4842
- pattern === "**");
4843
- if (!hasFrontendTestWriteScope) {
4844
- throw new Error('frontend-test requires task.json allowedPaths to include "testcase/frontend/**" (or an explicit containing glob).');
4887
+ if (!allowedPathsCoverFrontendTestRoot(sources.taskConfig.allowedPaths, layout.testRoot)) {
4888
+ throw new Error(`frontend-test requires task.json allowedPaths to cover "${layout.testRoot}/**" (or an explicit containing glob).`);
4845
4889
  }
4846
4890
  const forbidden = commonForbiddenPaths(sources);
4847
- const ragWriteSet = ["testcase/frontend/rag/**"];
4891
+ const ragWriteSet = [`${layout.ragDir}/**`];
4848
4892
  const caseDraftWriteSet = [
4849
- "testcase/frontend/cases/FE-*.md",
4850
- "testcase/frontend/cases/index.md",
4851
- "testcase/frontend/cases/manifest.draft.json",
4893
+ `${layout.casesDir}/FE-*.md`,
4894
+ `${layout.casesDir}/index.md`,
4895
+ `${layout.casesDir}/manifest.draft.json`,
4852
4896
  ];
4853
4897
  // The shell materializer alone owns the final manifest boundary.
4854
- const casesWriteSet = ["testcase/frontend/cases/**"];
4855
- const evidenceRoot = "testcase/frontend/evidence";
4898
+ const casesWriteSet = [`${layout.casesDir}/**`];
4899
+ const evidenceRoot = layout.evidenceDir;
4856
4900
  const declaredAcIdsLiteral = JSON.stringify(declaredAcIds);
4857
4901
  const maxCasesPerBatchLiteral = String(config.maxCasesPerBatch);
4858
4902
  const checklistScript = [
@@ -5444,6 +5488,7 @@ function buildFrontendTestHybridDag(sources) {
5444
5488
  verifyStrategy: resolveDagVerifyStrategy(sources.taskConfig),
5445
5489
  tasks,
5446
5490
  };
5491
+ applyFrontendTestLayoutToDagSpec(spec, layout);
5447
5492
  applyDefaultReadOnlyRetryPolicy(spec);
5448
5493
  parseDagSpec(spec);
5449
5494
  assertValidDagSpec(spec);
@@ -978,6 +978,18 @@ export const dagSpecSchema = z
978
978
  isDefault: z.boolean(),
979
979
  })
980
980
  .optional(),
981
+ /** Frozen frontend-test artifact layout; absent = historical testcase/frontend/ layout. */
982
+ frontendTestLayout: z
983
+ .object({
984
+ testRoot: z.string(),
985
+ ragDir: z.string(),
986
+ casesDir: z.string(),
987
+ evidenceDir: z.string(),
988
+ reportsDir: z.string(),
989
+ fixturesDir: z.string(),
990
+ isDefault: z.boolean(),
991
+ })
992
+ .optional(),
981
993
  /** Bound user shared-setup document (plan B); absent = no shared setup references expected. */
982
994
  backendTestSharedSetup: z
983
995
  .object({