@tea-agent/loop-agent 0.39.0-beta.4 → 0.39.0-beta.6
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 +5 -0
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi-executor.js +167 -31
- package/dist/executors/pi-playwright-cli-tool.js +23 -16
- package/dist/executors/shell-executor.js +25 -8
- package/dist/shared/dag-failure-category.js +138 -0
- package/dist/task/config-types.js +6 -0
- package/dist/task/source-references.js +22 -1
- package/dist/worker/console/chat/operation-card.js +8 -1
- package/dist/worker/console/chat/routes.js +5 -3
- package/dist/worker/console/inspect-split.js +18 -0
- package/dist/worker/console/operation-run-facts.js +19 -3
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-zsNyvGaH.js → abnfDiagram-N423BO3Z-CfDSkLtp.js} +1 -1
- package/dist/worker/console/static/assets/{arc-BDjZ5kE1.js → arc-BlefM6I5.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BkgaujnN.js → architectureDiagram-T3A2C74G-C4tHfzHn.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CqxOJi0j.js → blockDiagram-VBNYF7ZC-G-G77Rh1.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C2JHLdRi.js → c4Diagram-5PPSVZJV-DWgxmY72.js} +1 -1
- package/dist/worker/console/static/assets/channel-DsdX3ZUX.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DF_YSvOS.js → chunk-2GRJ4B5K-CJQcfbCY.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D3Ort5Vo.js → chunk-2Q5K7J3B-vUPviH9y.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-XfAfZeUC.js → chunk-5RXB4S5H-DzRXSTeh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-SLBibhf5.js → chunk-5VM5RSS4-CWfgqm07.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-Dz29Ye_f.js → chunk-6Q2QTUOP-BtTDauXA.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-DB2G4H8V.js → chunk-GF5L2VYU-Bc-NNt84.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CIoGkxdu.js → chunk-JWPE2WC7-B_f-emL4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CG78FGkn.js → chunk-KBJHAD2P-DV-jgJ66.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ZDHAvXPd.js → chunk-RYQCIY6F-DMqzn4RZ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BKkkC4VA.js → chunk-XXDRQBXY-CVNtlAm4.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CrvCyJ-8.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CrvCyJ-8.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-SposUZsO.js → cose-bilkent-JH36ORCC-BBZBhVTd.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BSCtQpD7.js → cynefin-VYW2F7L2-DbMU2PZ2.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-DgFHrw9l.js → cynefinDiagram-MW4NZA55-Dj0v8rjc.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BVBasYbj.js → dagre-VZM6K2ZE-BW8R4WvJ.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-6r_4rN_c.js → diagram-7IWD3JNH-DEgWz3ld.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-JmuaB41P.js → diagram-B4RE2ZJO-BAA01rlR.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-xXvAY3sk.js → diagram-LBJQPF4R-Cau6qqpW.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D2B3lY2o.js → diagram-Q27KOJAE-8HiPsLos.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-tK54tMEX.js → diagram-UB23O5K3-CmkXuTYL.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CNtELzV-.js → ebnfDiagram-BXEA7PRR-DCV_0seW.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-C1xKMKcW.js → erDiagram-JOGREHBK-gckfWESj.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZ7dlPMz.js → flowDiagram-UKHOOZJN-D2mT2BUu.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-BqGb42Xe.js → ganttDiagram-PKOTCBZU-B_HR5qEw.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-D_BlXEtt.js → gitGraphDiagram-DS77QQ5N-BUw4fpSa.js} +1 -1
- package/dist/worker/console/static/assets/{index-D5zGX6fG.js → index-DPbsruV5.js} +81 -79
- package/dist/worker/console/static/assets/index-DwOGauxU.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Vce2vmIe.js → infoDiagram-6WML65LV-BSRAqEj0.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DDwVBAvI.js → ishikawaDiagram-WSZJBQD7-CLxE8hUp.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-y_Ew-wv4.js → journeyDiagram-NVQOT4AX-juFzVXIj.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CjXJFp1K.js → kanban-definition-27J2QSJJ-CwLHVhOU.js} +1 -1
- package/dist/worker/console/static/assets/{linear-1itMst1Q.js → linear-9LfUyPAs.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-C-D3ZRUa.js → mermaid.core-TPPHfQbe.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BiYywmZc.js → mindmap-definition-FAOFIHXS-Dn_Z9RxF.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-l2fydNWA.js → pegDiagram-VL7TDLO6-CiEMKTgm.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Dz0hNbYW.js → pieDiagram-7S7Q4E2Y-JASaGxjZ.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-BujI_mLZ.js → quadrantDiagram-CIZ2JOQS-ChmH0ply.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-pWe84beb.js → railroadDiagram-AXF67PYL-CtyM-HGQ.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-ByYu2frg.js → requirementDiagram-LRYGKXZP-BN9y_Bvs.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-CoPnilFP.js → sankeyDiagram-W5VNT64P-IyS0GQ1-.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-1Md2LnS0.js → sequenceDiagram-SI44F4Z6-DjxGXurN.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DqqqAoSH.js → sizeCapture-X5ZJPWSS-Cr3Z0S8y.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-C4sGkf--.js → stateDiagram-OKZ733FA-BjJFC6pW.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Bq44O9Nt.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ByxJrUzW.js → swimlanes-SLNWSIFB-DeMUJQiW.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BT4tWyW3.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-D8MLgM0O.js → timeline-definition-Z64GVDOM-Bc2Rv2Oo.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DnBis-wZ.js → vennDiagram-T6HMQDX7-CDhRUeqG.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-uuyYT7UO.js → wardleyDiagram-T6FBY63Y-DaN_z3Xy.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BjgabhwK.js → xychartDiagram-ELKLHX3M-C1vTLBDG.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +38 -2
- package/dist/worker/console/static-src/app/useRunProgress.js +46 -0
- package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
- package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +13 -1
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +29 -9
- package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
- package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
- package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
- package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
- package/dist/worker/materialize/harness-task-lineage.js +5 -2
- package/dist/worker/observability/read-model.js +22 -0
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
- package/dist/workflows/dag/dynamic-runtime/map.js +1 -1
- package/dist/workflows/dag/failure-category.js +7 -128
- package/dist/workflows/dag/frontend-design-policy.js +12 -7
- package/dist/workflows/dag/frontend-implementation-contract.js +10 -5
- package/dist/workflows/dag/frontend-plan-render.js +0 -3
- package/dist/workflows/dag/frontend-shadow-dual-write.js +51 -10
- package/dist/workflows/dag/frontend-test-case-checklist.js +4 -2
- package/dist/workflows/dag/frontend-test-case-manifest.js +6 -4
- package/dist/workflows/dag/frontend-test-case-quality.js +6 -3
- package/dist/workflows/dag/frontend-test-environment-probe.js +10 -7
- package/dist/workflows/dag/frontend-test-html-report.js +3 -1
- package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
- package/dist/workflows/dag/frontend-test-layout.js +159 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +28 -14
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +3 -1
- package/dist/workflows/dag/frontend-typed-event-store.js +13 -0
- package/dist/workflows/dag/init-hybrid.js +66 -19
- package/dist/workflows/dag/node-execution.js +21 -1
- package/dist/workflows/dag/retry-policy.js +32 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +23 -19
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/package.json +1 -1
- 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 +10 -8
- package/dist/worker/console/static/assets/channel-BuqaGKAT.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DNdSHjH9.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DNdSHjH9.js +0 -1
- package/dist/worker/console/static/assets/index-DBbhESQ_.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8rmop_9.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B0ky-kBM.js +0 -8
|
@@ -209,7 +209,6 @@ export function loadFrontendImplementationContractJsonSchema(startDir = path.dir
|
|
|
209
209
|
"mockApi",
|
|
210
210
|
"designEvidence",
|
|
211
211
|
"verificationTargets",
|
|
212
|
-
"evidenceGaps",
|
|
213
212
|
];
|
|
214
213
|
const actualRequired = Array.isArray(schema.required) ? schema.required : [];
|
|
215
214
|
const missing = expectedRequired.filter((key) => !actualRequired.includes(key));
|
|
@@ -279,7 +278,10 @@ const uiComponentChoiceSchema = z
|
|
|
279
278
|
component: z.string().min(1),
|
|
280
279
|
decision: z.enum(["specified", "reuse-existing", "new"]),
|
|
281
280
|
specReference: uiComponentSpecReferenceSchema.nullable().optional(),
|
|
282
|
-
|
|
281
|
+
// Optional: the decision enum already carries the semantic (specified →
|
|
282
|
+
// specReference, new → deviation, reuse-existing → no rationale needed).
|
|
283
|
+
// Omit for reuse-existing to keep plan output small.
|
|
284
|
+
rationale: z.string().min(1).optional(),
|
|
283
285
|
})
|
|
284
286
|
.strict();
|
|
285
287
|
const uiComponentChoicesSchema = z.array(uiComponentChoiceSchema);
|
|
@@ -407,7 +409,7 @@ export const frontendImplementationContractSchema = z
|
|
|
407
409
|
uiStates: z.array(z.string().min(1)),
|
|
408
410
|
})
|
|
409
411
|
.strict()).min(1),
|
|
410
|
-
evidenceGaps: z.array(gap),
|
|
412
|
+
evidenceGaps: z.array(gap).optional(),
|
|
411
413
|
implementationSteps: z.array(z.string().min(1)).optional(),
|
|
412
414
|
stylingStrategy: z.string().min(1).optional(),
|
|
413
415
|
uiComponentChoices: uiComponentChoicesSchema.optional(),
|
|
@@ -1687,7 +1689,7 @@ export async function analyzeFrontendImplementationContract(input) {
|
|
|
1687
1689
|
if (!result.success)
|
|
1688
1690
|
fail("retryable-invalid", `invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`, candidateJsonSha256);
|
|
1689
1691
|
const blockingGaps = [
|
|
1690
|
-
...result.data.evidenceGaps,
|
|
1692
|
+
...(result.data.evidenceGaps ?? []),
|
|
1691
1693
|
...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
|
|
1692
1694
|
].filter((item) => item.blocking);
|
|
1693
1695
|
if (blockingGaps.length > 0)
|
|
@@ -1696,7 +1698,7 @@ export async function analyzeFrontendImplementationContract(input) {
|
|
|
1696
1698
|
.join(", ")}`, candidateJsonSha256);
|
|
1697
1699
|
for (const requirementId of canonicalBinding.requirementIds)
|
|
1698
1700
|
if (!result.data.requirements.some((item) => item.id === requirementId) &&
|
|
1699
|
-
!result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
|
|
1701
|
+
!(result.data.evidenceGaps ?? []).some((item) => item.requirementId === requirementId))
|
|
1700
1702
|
fail("blocked", `frontend contract does not cover ${requirementId}`, candidateJsonSha256);
|
|
1701
1703
|
return {
|
|
1702
1704
|
canonical: result.data,
|
|
@@ -1728,6 +1730,9 @@ const PLAN_LEDGER_FACT_KINDS_FOR_COMPILE = [
|
|
|
1728
1730
|
"mock-api",
|
|
1729
1731
|
"design-deviation",
|
|
1730
1732
|
"dependency",
|
|
1733
|
+
"plan-requirement",
|
|
1734
|
+
"plan-verification-target",
|
|
1735
|
+
"plan-evidence-gap",
|
|
1731
1736
|
];
|
|
1732
1737
|
/**
|
|
1733
1738
|
* Restore the editable RFC 7386 plan patch from a committed plan ledger.
|
|
@@ -54,9 +54,6 @@ export function renderFrontendPlanMarkdown(contract) {
|
|
|
54
54
|
"## Requirement Coverage",
|
|
55
55
|
requirementsCoverage(contract),
|
|
56
56
|
"",
|
|
57
|
-
"## Implementation Steps",
|
|
58
|
-
bulletList(contract.implementationSteps) || "_(not specified)_",
|
|
59
|
-
"",
|
|
60
57
|
"## Target Files",
|
|
61
58
|
bulletList(contract.targets.files) || "_(none)_",
|
|
62
59
|
"",
|
|
@@ -232,6 +232,9 @@ export const PLAN_LEDGER_FACT_KINDS = [
|
|
|
232
232
|
"mock-api",
|
|
233
233
|
"design-deviation",
|
|
234
234
|
"dependency",
|
|
235
|
+
"plan-requirement",
|
|
236
|
+
"plan-verification-target",
|
|
237
|
+
"plan-evidence-gap",
|
|
235
238
|
];
|
|
236
239
|
function skeletonTargetFiles(skeleton) {
|
|
237
240
|
if (!isRecord(skeleton))
|
|
@@ -288,15 +291,17 @@ export function assemblePlanPatchFromCommittedFacts(records) {
|
|
|
288
291
|
if (routes.length > 0)
|
|
289
292
|
patch.targets = { routes };
|
|
290
293
|
}
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
294
|
+
const componentChoices = facts.filter((fact) => fact.kind === "component-choice");
|
|
295
|
+
const collectedChoices = componentChoices.flatMap((fact) => Array.isArray(fact.uiComponentChoices)
|
|
296
|
+
? fact.uiComponentChoices.filter(isRecord)
|
|
297
|
+
: []);
|
|
298
|
+
const stylingStrategy = componentChoices
|
|
299
|
+
.map((fact) => asString(fact.stylingStrategy))
|
|
300
|
+
.find((value) => value.length > 0);
|
|
301
|
+
if (collectedChoices.length > 0)
|
|
302
|
+
patch.uiComponentChoices = collectedChoices;
|
|
303
|
+
if (stylingStrategy)
|
|
304
|
+
patch.stylingStrategy = stylingStrategy;
|
|
300
305
|
const stateFlow = facts.find((fact) => fact.kind === "state-flow");
|
|
301
306
|
if (stateFlow) {
|
|
302
307
|
if (Array.isArray(stateFlow.uiStates)) {
|
|
@@ -320,6 +325,41 @@ export function assemblePlanPatchFromCommittedFacts(records) {
|
|
|
320
325
|
const dependencyPolicy = asString(dependency?.policy);
|
|
321
326
|
if (dependencyPolicy)
|
|
322
327
|
patch.dependencyPolicy = dependencyPolicy;
|
|
328
|
+
// Requirements / verification targets / evidence gaps / implementation
|
|
329
|
+
// steps arrive as individual facts (one per record_plan_* call) so a large
|
|
330
|
+
// plan never exceeds a single model output budget. Aggregate them in
|
|
331
|
+
// ledger order.
|
|
332
|
+
const requirements = facts
|
|
333
|
+
.filter((fact) => fact.kind === "plan-requirement")
|
|
334
|
+
.map((fact) => fact.entry)
|
|
335
|
+
.filter(isRecord)
|
|
336
|
+
.map((entry) => {
|
|
337
|
+
// The canonical contract schema requires a non-empty
|
|
338
|
+
// expectedOutcome, but the plan should not be forced to re-write
|
|
339
|
+
// full outcome prose for every requirement (the contract node
|
|
340
|
+
// already captured requirement semantics). Derive a deterministic
|
|
341
|
+
// placeholder from the requirement id when omitted.
|
|
342
|
+
if (isRecord(entry) && !asString(entry.expectedOutcome)) {
|
|
343
|
+
const id = asString(entry.id) || "requirement";
|
|
344
|
+
return { ...entry, expectedOutcome: `implied by contract: ${id}` };
|
|
345
|
+
}
|
|
346
|
+
return entry;
|
|
347
|
+
});
|
|
348
|
+
if (requirements.length > 0)
|
|
349
|
+
patch.requirements = requirements;
|
|
350
|
+
const verificationTargets = facts
|
|
351
|
+
.filter((fact) => fact.kind === "plan-verification-target")
|
|
352
|
+
.map((fact) => fact.entry)
|
|
353
|
+
.filter(isRecord);
|
|
354
|
+
if (verificationTargets.length > 0) {
|
|
355
|
+
patch.verificationTargets = verificationTargets;
|
|
356
|
+
}
|
|
357
|
+
const evidenceGaps = facts
|
|
358
|
+
.filter((fact) => fact.kind === "plan-evidence-gap")
|
|
359
|
+
.map((fact) => fact.entry)
|
|
360
|
+
.filter(isRecord);
|
|
361
|
+
if (evidenceGaps.length > 0)
|
|
362
|
+
patch.evidenceGaps = evidenceGaps;
|
|
323
363
|
return Object.keys(patch).length > 0 ? patch : undefined;
|
|
324
364
|
}
|
|
325
365
|
/** Derive the 7 origin=plan ledger facts from an editable patch. Skeleton
|
|
@@ -460,7 +500,8 @@ function planLedgerFactsFromCommittedRecords(records) {
|
|
|
460
500
|
.filter((fact) => isRecord(fact) &&
|
|
461
501
|
fact.origin === "plan" &&
|
|
462
502
|
typeof fact.kind === "string" &&
|
|
463
|
-
PLAN_LEDGER_FACT_KINDS.includes(fact.kind))
|
|
503
|
+
PLAN_LEDGER_FACT_KINDS.includes(fact.kind))
|
|
504
|
+
.map((fact) => fact);
|
|
464
505
|
}
|
|
465
506
|
export async function loadCommittedPlanLedgerFacts(runDir, nodeId = "frontend-plan-pi") {
|
|
466
507
|
const { readTypedEventStoreFromJsonl } = await import("./frontend-typed-event-store.js");
|
|
@@ -2,6 +2,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isPlaywrightCliCommand } from "../../shared/playwright-cli-command-policy.js";
|
|
4
4
|
import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
|
|
5
|
+
import { defaultFrontendTestLayout, frontendTestCasePath, } from "./frontend-test-layout.js";
|
|
5
6
|
const COMMAND_FENCE_LANGUAGE = /^(?:|bash|sh|shell|zsh|fish|console|terminal|command|cmd|powershell|pwsh|javascript|js|typescript|ts|python|py)$/i;
|
|
6
7
|
const LIST_ITEM = /^\s*(?:[-+*]|\d+[.)])\s+(.+)$/;
|
|
7
8
|
const INDENTED_CODE = /^(?: {4,}|\t+)(\S.*)$/;
|
|
@@ -290,7 +291,8 @@ function commandGateIssue(input) {
|
|
|
290
291
|
};
|
|
291
292
|
}
|
|
292
293
|
export async function validateFrontendCaseChecklist(input) {
|
|
293
|
-
const
|
|
294
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
295
|
+
const root = path.join(input.workspaceRoot, layout.casesDir);
|
|
294
296
|
const draft = path.join(root, "manifest.draft.json");
|
|
295
297
|
const final = path.join(root, "manifest.json");
|
|
296
298
|
const manifestPath = await exists(draft) ? draft : await exists(final) ? final : "";
|
|
@@ -319,7 +321,7 @@ export async function validateFrontendCaseChecklist(input) {
|
|
|
319
321
|
if (typeof item?.caseId === "string" && /^AC-/i.test(item.caseId))
|
|
320
322
|
issues.push({ ruleId: "case-id-is-ac", caseId: id, detail: "caseId must not be an acceptance id" });
|
|
321
323
|
const casePath = typeof item?.casePath === "string" ? item.casePath : "";
|
|
322
|
-
const expectedPath = typeof item?.caseId === "string" ?
|
|
324
|
+
const expectedPath = typeof item?.caseId === "string" ? frontendTestCasePath(layout, item.caseId) : "";
|
|
323
325
|
const absolute = path.resolve(input.workspaceRoot, casePath);
|
|
324
326
|
const relative = path.relative(input.workspaceRoot, absolute);
|
|
325
327
|
if (!casePath || relative.startsWith("..") || path.isAbsolute(relative) || !(await exists(absolute))) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, rename, unlink, writeFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { isTruncatedFrontendAcId, isTruncatedWhenUndeclaredFrontendAcId, } from "./frontend-test-result-contract.js";
|
|
4
|
+
import { defaultFrontendTestLayout, frontendTestCasePath, frontendTestEvidenceDir, frontendTestManifestDraftPath, frontendTestManifestPath, } from "./frontend-test-layout.js";
|
|
4
5
|
const DIMENSIONS = new Set(["core", "boundary", "flow", "backend"]);
|
|
5
6
|
function fail(ruleId, detail) {
|
|
6
7
|
const msg = JSON.stringify({ ruleId, detail: String(detail ?? "") });
|
|
@@ -19,8 +20,9 @@ function fail(ruleId, detail) {
|
|
|
19
20
|
* present). When empty, ac-id-shape still applies.
|
|
20
21
|
*/
|
|
21
22
|
export async function materializeFrontendTestCaseManifest(input) {
|
|
22
|
-
const
|
|
23
|
-
const
|
|
23
|
+
const layout = input.layout ?? defaultFrontendTestLayout();
|
|
24
|
+
const draft = path.join(input.workspaceRoot, frontendTestManifestDraftPath(layout));
|
|
25
|
+
const file = path.join(input.workspaceRoot, frontendTestManifestPath(layout));
|
|
24
26
|
const tmp = `${file}.tmp`;
|
|
25
27
|
const maxCases = input.maxCases ?? 32;
|
|
26
28
|
const declaredAc = new Set(input.declaredAcIds ?? []);
|
|
@@ -71,8 +73,8 @@ export async function materializeFrontendTestCaseManifest(input) {
|
|
|
71
73
|
fail("unknown-ac", `${ac} not in sourceBinding; repair generator input or AC list`);
|
|
72
74
|
acIds.push(ac);
|
|
73
75
|
}
|
|
74
|
-
const casePath =
|
|
75
|
-
const evidenceDir =
|
|
76
|
+
const casePath = frontendTestCasePath(layout, c.caseId);
|
|
77
|
+
const evidenceDir = frontendTestEvidenceDir(layout, c.caseId);
|
|
76
78
|
for (const [k, v] of [
|
|
77
79
|
["casePath", casePath],
|
|
78
80
|
["evidenceDir", evidenceDir],
|
|
@@ -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
|
|
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 ||
|
|
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
|
|
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,
|
|
70
|
-
const contextPath = path.join(workspaceRoot,
|
|
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
|
|
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 ${
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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 ||
|
|
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(
|
|
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 &&
|
|
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
|
|
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 =
|
|
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 = "";
|