@tea-agent/loop-agent 0.39.0-next.24 → 0.39.0-next.26
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 +8 -0
- package/README.md +2 -0
- package/dist/build-stamp.json +2 -2
- package/dist/executors/pi-executor.js +51 -0
- package/dist/executors/pi-sdk-executor.js +108 -57
- package/dist/executors/shell-executor.js +55 -1
- package/dist/worker/console/pi-readiness.js +4 -4
- package/dist/worker/observe/static/state.js +2 -2
- package/dist/worker/observe/static/views/session-timeline.js +18 -5
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +54 -2
- package/dist/workflows/dag/backend-test-scenario-partitions.js +73 -1
- package/dist/workflows/dag/frontend-test-case-quality.js +5 -13
- package/dist/workflows/dag/frontend-test-environment-probe.js +227 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +10 -18
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +68 -0
- package/dist/workflows/dag/init-hybrid.js +10 -18
- package/dist/workflows/dag/rerun-plan.js +22 -3
- package/dist/workflows/dag/types.js +4 -0
- package/dist/workflows/dag/validate.js +2 -0
- package/docs/operations/local-development-environment.md +1 -5
- package/docs/skills/vetted-skill-registry.md +14 -0
- package/docs/templates/README.md +1 -1
- package/docs/templates/agent-dag.schema.json +31 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/package.json +8 -4
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/improve-codebase-architecture/SKILL.md +81 -0
- package/skills/improve-codebase-architecture/deepening.md +37 -0
- package/skills/improve-codebase-architecture/html-report.md +123 -0
- package/skills/improve-codebase-architecture/interface-design.md +44 -0
- package/skills/improve-codebase-architecture/language.md +53 -0
|
@@ -35,6 +35,8 @@ export const scenarioPartitionRowSchema = z.object({
|
|
|
35
35
|
const PARTITION_HEADING = /^##\s+Scenario Partitions\s*$/;
|
|
36
36
|
const PARTITION_TABLE_HEADER = /^\|\s*Partition ID\s*\|\s*Operation\s*\|\s*Axis\s*\|\s*Domain\s*\|\s*Required Slots\s*\|\s*Expected by Slot\s*\|\s*Bind Rule\s*\|\s*$/;
|
|
37
37
|
const TABLE_ROW = /^\|(.+)\|$/;
|
|
38
|
+
const FINITE_DOMAIN_MEMBER = /^[A-Za-z0-9]+(?:[._\- ][A-Za-z0-9]+)*$/;
|
|
39
|
+
const RESERVED_SLOT_TOKENS = new Set(["OMITTED", "NOT-IN-SET"]);
|
|
38
40
|
function splitCells(raw) {
|
|
39
41
|
return raw
|
|
40
42
|
.split("|")
|
|
@@ -100,6 +102,11 @@ export function parseScenarioPartitions(readmeMarkdown) {
|
|
|
100
102
|
continue;
|
|
101
103
|
}
|
|
102
104
|
seen.add(partitionId);
|
|
105
|
+
const domainIssue = describeInvalidPartitionDomain(domain);
|
|
106
|
+
if (domainIssue) {
|
|
107
|
+
issues.push(`INVALID_PARTITION_DOMAIN: partition ${partitionId} ${domainIssue}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
103
110
|
partitions.push({
|
|
104
111
|
partitionId,
|
|
105
112
|
operation,
|
|
@@ -119,6 +126,40 @@ function slotToken(value) {
|
|
|
119
126
|
.replace(/[^A-Z0-9]+/g, "-")
|
|
120
127
|
.replace(/^-+|-+$/g, "");
|
|
121
128
|
}
|
|
129
|
+
function describeInvalidPartitionDomain(domain) {
|
|
130
|
+
const tokens = [];
|
|
131
|
+
const seenTokens = new Set();
|
|
132
|
+
for (const value of domain) {
|
|
133
|
+
if (!FINITE_DOMAIN_MEMBER.test(value)) {
|
|
134
|
+
return `value is not a finite identifier member: ${value}`;
|
|
135
|
+
}
|
|
136
|
+
const token = slotToken(value);
|
|
137
|
+
if (!token) {
|
|
138
|
+
return `value cannot produce a stable non-empty slot token: ${value}`;
|
|
139
|
+
}
|
|
140
|
+
if (RESERVED_SLOT_TOKENS.has(token)) {
|
|
141
|
+
return `value maps to reserved slot token ${token}: ${value}`;
|
|
142
|
+
}
|
|
143
|
+
if (seenTokens.has(token)) {
|
|
144
|
+
return `values collapse to duplicate slot token ${token}`;
|
|
145
|
+
}
|
|
146
|
+
seenTokens.add(token);
|
|
147
|
+
tokens.push(token);
|
|
148
|
+
}
|
|
149
|
+
return tokens.length === 0 ? "domain produced no slot tokens" : undefined;
|
|
150
|
+
}
|
|
151
|
+
function normalizePartitionAxisName(value) {
|
|
152
|
+
return value.trim().replace(/^(?:query|path)\s*:\s*/i, "").toLowerCase();
|
|
153
|
+
}
|
|
154
|
+
function normalizePartitionOperation(value) {
|
|
155
|
+
return value.trim().replace(/\s+/g, " ").toUpperCase();
|
|
156
|
+
}
|
|
157
|
+
function sameFiniteSet(left, right) {
|
|
158
|
+
if (left.length !== right.length)
|
|
159
|
+
return false;
|
|
160
|
+
const expected = new Set(left);
|
|
161
|
+
return right.every((value) => expected.has(value));
|
|
162
|
+
}
|
|
122
163
|
const STABLE_PARTITION_ID = /^SP-[A-Z0-9][A-Z0-9._-]*$/i;
|
|
123
164
|
/**
|
|
124
165
|
* Slot IDs are `TP-<Partition ID>-<VALUE|OMITTED|NOT-IN-SET>`.
|
|
@@ -138,13 +179,16 @@ function partitionToken(row) {
|
|
|
138
179
|
export function expandScenarioPartitions(partitions) {
|
|
139
180
|
const slots = [];
|
|
140
181
|
for (const row of partitions) {
|
|
182
|
+
if (describeInvalidPartitionDomain(row.domain))
|
|
183
|
+
continue;
|
|
141
184
|
const prefix = partitionToken(row);
|
|
142
185
|
for (const value of row.domain) {
|
|
186
|
+
const token = slotToken(value);
|
|
143
187
|
slots.push({
|
|
144
188
|
partitionId: row.partitionId,
|
|
145
189
|
operation: row.operation,
|
|
146
190
|
axis: row.axis,
|
|
147
|
-
slotId: `${prefix}-${
|
|
191
|
+
slotId: `${prefix}-${token}`,
|
|
148
192
|
kind: "each-value",
|
|
149
193
|
value,
|
|
150
194
|
intent: "nominal-filter",
|
|
@@ -176,6 +220,34 @@ export function expandScenarioPartitions(partitions) {
|
|
|
176
220
|
}
|
|
177
221
|
return slots;
|
|
178
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Bind parsed partitions to GET query/path OpenAPI axes.
|
|
225
|
+
* Unconfirmed axes keep identifier-valid Domain rows (requirement finite sets).
|
|
226
|
+
*/
|
|
227
|
+
export function applyOpenApiPartitionDomainPolicy(partitions, axes) {
|
|
228
|
+
const issues = [];
|
|
229
|
+
const valid = [];
|
|
230
|
+
for (const row of partitions) {
|
|
231
|
+
const operation = normalizePartitionOperation(row.operation);
|
|
232
|
+
const axisName = normalizePartitionAxisName(row.axis);
|
|
233
|
+
const matched = axes.find((axis) => normalizePartitionOperation(axis.operation) === operation &&
|
|
234
|
+
normalizePartitionAxisName(axis.name) === axisName);
|
|
235
|
+
if (!matched) {
|
|
236
|
+
valid.push(row);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (matched.enumValues === null) {
|
|
240
|
+
issues.push(`NO_FINITE_DOMAIN: partition ${row.partitionId} axis ${row.axis} exists on ${row.operation} without a documented enum`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (!sameFiniteSet(matched.enumValues, row.domain)) {
|
|
244
|
+
issues.push(`PARTITION_SOURCE_MISMATCH: partition ${row.partitionId} domain does not match OpenAPI enum for ${row.operation} ${row.axis}`);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
valid.push(row);
|
|
248
|
+
}
|
|
249
|
+
return { partitions: valid, issues };
|
|
250
|
+
}
|
|
179
251
|
/** Facts projection consumed by N6 and plan D gap-fill. */
|
|
180
252
|
export const scenarioPartitionFactsSchema = z.object({
|
|
181
253
|
partitions: z.array(z.object({
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { markdownSection } from "./frontend-test-markdown.js";
|
|
3
4
|
const CASE_ID = /^FE-[A-Za-z0-9]+-\d{3}-(?:core|boundary|flow|backend)$/i;
|
|
4
5
|
const AC_ID = /^AC(?:-[A-Z0-9]+)+$/i;
|
|
5
6
|
const PLACEHOLDER = /\b(?:TODO|TBD|FIXME)\b|结果正确|页面正常|按实际情况处理|验证成功/i;
|
|
6
7
|
const SECTION_ALIASES = {
|
|
7
8
|
purpose: ["Test Purpose", "测试目的", "测试场景"],
|
|
8
9
|
source: ["Source References", "需求依据"],
|
|
9
|
-
preconditions: ["Preconditions", "前置条件"],
|
|
10
|
-
steps: ["Steps", "操作步骤"],
|
|
10
|
+
preconditions: ["Preconditions", "前置条件", "前置条件与重置"],
|
|
11
|
+
steps: ["Test Steps", "测试步骤", "Steps", "操作步骤"],
|
|
11
12
|
expected: ["Expected Results", "预期结果"],
|
|
12
13
|
automation: ["Automation Notes", "自动化映射", "自动化说明"],
|
|
13
14
|
};
|
|
14
|
-
function section(body, names) {
|
|
15
|
-
const marker = new RegExp(`^###\\s+(?:${names.map((v) => v.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")).join("|")})\\s*$`, "mi");
|
|
16
|
-
const hit = marker.exec(body);
|
|
17
|
-
if (!hit)
|
|
18
|
-
return "";
|
|
19
|
-
const rest = body.slice(hit.index + hit[0].length);
|
|
20
|
-
const next = /^###\s+/m.exec(rest);
|
|
21
|
-
return rest.slice(0, next?.index ?? rest.length).trim();
|
|
22
|
-
}
|
|
23
15
|
function hasList(value) { return /^\s*(?:\d+[.)]|[-*+])\s+\S+/m.test(value); }
|
|
24
16
|
function hasAssertion(value) { return /(?:status|状态|包含|显示|等于|为|可见|不可见|跳转|错误|成功|失败|should|expect|assert|must)/i.test(value); }
|
|
25
17
|
export async function validateFrontendCaseContent(input) {
|
|
@@ -69,9 +61,9 @@ export async function validateFrontendCaseContent(input) {
|
|
|
69
61
|
if (PLACEHOLDER.test(body))
|
|
70
62
|
findings.push({ ruleId: "placeholder-wording", caseId: id, detail: "case contains placeholder or non-assertable wording" });
|
|
71
63
|
for (const [key, names] of Object.entries(SECTION_ALIASES))
|
|
72
|
-
if (!
|
|
64
|
+
if (!markdownSection(body, names))
|
|
73
65
|
findings.push({ ruleId: `missing-${key}`, caseId: id, detail: `missing section: ${names.join(" or ")}` });
|
|
74
|
-
const steps =
|
|
66
|
+
const steps = markdownSection(body, SECTION_ALIASES.steps), expected = markdownSection(body, SECTION_ALIASES.expected);
|
|
75
67
|
if (!hasList(steps))
|
|
76
68
|
findings.push({ ruleId: "unstructured-steps", caseId: id, detail: "steps should contain numbered or bulleted executable actions" });
|
|
77
69
|
if (!hasList(expected) || !hasAssertion(expected))
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export const FRONTEND_TEST_CONTEXT_REL = "testcase/frontend/rag/context.md";
|
|
5
|
+
export const FRONTEND_TEST_PROBE_REL = "testcase/frontend/rag/environment-probe.json";
|
|
6
|
+
const CONNECTION_REFUSED_HINT = "被测服务未在 baseUrl 监听(connection refused)。请先启动本地前端(例如 scripts/serve.sh 或项目约定的 npm start),确认可访问后再从 materialize-frontend-test-execution-shell 续跑。";
|
|
7
|
+
export function classifyFrontendBaseUrlProbeFailure(input) {
|
|
8
|
+
if (input.spawnError)
|
|
9
|
+
return { errorClass: "spawn-error" };
|
|
10
|
+
const exit = input.curlExit ?? 0;
|
|
11
|
+
if (exit === 7) {
|
|
12
|
+
return { errorClass: "connection-refused", hint: CONNECTION_REFUSED_HINT };
|
|
13
|
+
}
|
|
14
|
+
if (exit === 6) {
|
|
15
|
+
return {
|
|
16
|
+
errorClass: "dns-unresolved",
|
|
17
|
+
hint: "无法解析 baseUrl 主机名。确认 context.md 中的地址可在本机解析,或改用 127.0.0.1。",
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (exit === 28) {
|
|
21
|
+
return {
|
|
22
|
+
errorClass: "connect-timeout",
|
|
23
|
+
hint: "连接 baseUrl 超时。确认被测服务已启动且端口可访问。",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (exit !== 0)
|
|
27
|
+
return { errorClass: `curl-exit-${exit}` };
|
|
28
|
+
if (input.httpStatus && Number.isFinite(input.httpStatus)) {
|
|
29
|
+
return { errorClass: `http-${input.httpStatus}` };
|
|
30
|
+
}
|
|
31
|
+
return { errorClass: "unreachable" };
|
|
32
|
+
}
|
|
33
|
+
function defaultCurlRunner(args) {
|
|
34
|
+
const result = spawnSync("curl", [...args], { encoding: "utf8" });
|
|
35
|
+
return {
|
|
36
|
+
status: result.status,
|
|
37
|
+
stdout: result.stdout ?? "",
|
|
38
|
+
stderr: result.stderr ?? "",
|
|
39
|
+
error: result.error,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function redactUrl(value) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(value);
|
|
45
|
+
parsed.username = "";
|
|
46
|
+
parsed.password = "";
|
|
47
|
+
parsed.search = "";
|
|
48
|
+
return parsed.toString();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return value.replace(/\/\/[^@\s]+@/g, "//");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function resolveBaseUrl(context) {
|
|
55
|
+
const patterns = [
|
|
56
|
+
/baseUrl\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
|
|
57
|
+
/base[-_ ]url\s*[:=]\s*["'`]?((?:https?):\/\/[^\s"'`<>]+)/i,
|
|
58
|
+
/playwright-cli open --browser=chrome\s+((?:https?):\/\/[^\s"'`<>]+)/i,
|
|
59
|
+
/(https?:\/\/(?:localhost|127\.0\.0\.1)[^\s)}\],"']*)/i,
|
|
60
|
+
];
|
|
61
|
+
for (const pattern of patterns) {
|
|
62
|
+
const match = context.match(pattern);
|
|
63
|
+
if (match?.[1])
|
|
64
|
+
return match[1].replace(/[)}\],"'.`]+$/g, "");
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
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);
|
|
71
|
+
await mkdir(path.dirname(probePath), { recursive: true });
|
|
72
|
+
await writeFile(probePath, `${JSON.stringify(probe, null, 2)}\n`, "utf8");
|
|
73
|
+
let context = "";
|
|
74
|
+
try {
|
|
75
|
+
context = await readFile(contextPath, "utf8");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
context = "";
|
|
79
|
+
}
|
|
80
|
+
const line = `environmentProbe: ${probe.status}${probe.blockedReason ? ` (${probe.blockedReason})` : ""}`;
|
|
81
|
+
const next = /environmentProbe\s*[:=]/i.test(context)
|
|
82
|
+
? context.replace(/environmentProbe\s*[:=]\s*.*/i, line)
|
|
83
|
+
: `${context.trimEnd()}\n\n${line}\n`;
|
|
84
|
+
await writeFile(contextPath, next, "utf8");
|
|
85
|
+
}
|
|
86
|
+
export async function probeFrontendTestEnvironment(input) {
|
|
87
|
+
const curl = input.curl ?? defaultCurlRunner;
|
|
88
|
+
const contextPath = path.join(input.workspaceRoot, FRONTEND_TEST_CONTEXT_REL);
|
|
89
|
+
let context;
|
|
90
|
+
try {
|
|
91
|
+
context = await readFile(contextPath, "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return { ok: false, stderr: `missing ${FRONTEND_TEST_CONTEXT_REL}` };
|
|
95
|
+
}
|
|
96
|
+
const rawUrl = resolveBaseUrl(context);
|
|
97
|
+
if (!rawUrl) {
|
|
98
|
+
return {
|
|
99
|
+
ok: false,
|
|
100
|
+
stderr: "frontend-test preflight missing absolute baseUrl from context.md",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(rawUrl);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return { ok: false, stderr: `baseUrl must be absolute http(s): ${rawUrl}` };
|
|
109
|
+
}
|
|
110
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
111
|
+
parsed.username ||
|
|
112
|
+
parsed.password ||
|
|
113
|
+
parsed.search ||
|
|
114
|
+
parsed.hash) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
stderr: `unsafe baseUrl from context.md: ${redactUrl(rawUrl)}`,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
if (/(?:^|\.)(?:www\.)?[^.]*?(?:prod|production)/i.test(parsed.hostname)) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
stderr: `production URL forbidden: ${redactUrl(rawUrl)}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const baseUrl = parsed.toString();
|
|
127
|
+
const safe = redactUrl(baseUrl);
|
|
128
|
+
const sourceMatch = context.match(/baseUrlSource\s*[:=]\s*([^\r\n]+)/i);
|
|
129
|
+
const baseUrlSource = sourceMatch ? sourceMatch[1].trim() : "context.md";
|
|
130
|
+
const failBlocked = async (reason, extra) => {
|
|
131
|
+
const probe = {
|
|
132
|
+
status: "unreachable",
|
|
133
|
+
blockedReason: reason,
|
|
134
|
+
baseUrlRedacted: extra.baseUrlRedacted ?? safe,
|
|
135
|
+
httpStatus: extra.httpStatus ?? null,
|
|
136
|
+
method: extra.method ?? null,
|
|
137
|
+
curlExit: extra.curlExit ?? null,
|
|
138
|
+
errorClass: extra.errorClass ?? null,
|
|
139
|
+
...(extra.hint ? { hint: extra.hint } : {}),
|
|
140
|
+
...(extra.baseUrlSource ? { baseUrlSource: extra.baseUrlSource } : {}),
|
|
141
|
+
};
|
|
142
|
+
await writeProbe(input.workspaceRoot, probe);
|
|
143
|
+
const payload = {
|
|
144
|
+
blockedReason: reason,
|
|
145
|
+
baseUrl: probe.baseUrlRedacted,
|
|
146
|
+
httpStatus: probe.httpStatus,
|
|
147
|
+
errorClass: probe.errorClass,
|
|
148
|
+
...(probe.hint ? { hint: probe.hint } : {}),
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
stderr: `frontend-test preflight blocked: ${JSON.stringify(payload)}`,
|
|
153
|
+
probe,
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
const curlCheck = curl(["--version"]);
|
|
157
|
+
if (curlCheck.error || curlCheck.status !== 0) {
|
|
158
|
+
return failBlocked("curl-unavailable", {
|
|
159
|
+
baseUrlRedacted: safe,
|
|
160
|
+
errorClass: "curl-missing",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const probeMethod = (method) => curl([
|
|
164
|
+
"-sS",
|
|
165
|
+
"-o",
|
|
166
|
+
"/dev/null",
|
|
167
|
+
"-w",
|
|
168
|
+
"%{http_code}",
|
|
169
|
+
"--connect-timeout",
|
|
170
|
+
"3",
|
|
171
|
+
"--max-time",
|
|
172
|
+
"8",
|
|
173
|
+
"-X",
|
|
174
|
+
method,
|
|
175
|
+
"-L",
|
|
176
|
+
"--max-redirs",
|
|
177
|
+
"3",
|
|
178
|
+
"--http1.1",
|
|
179
|
+
"--proto-redir",
|
|
180
|
+
"=http,https",
|
|
181
|
+
safe,
|
|
182
|
+
]);
|
|
183
|
+
let used = "HEAD";
|
|
184
|
+
let result = probeMethod("HEAD");
|
|
185
|
+
let code = String(result.stdout || "").trim();
|
|
186
|
+
let statusNum = Number.parseInt(code, 10);
|
|
187
|
+
const headRejected = result.status !== 0 || !statusNum || statusNum === 405 || statusNum === 501;
|
|
188
|
+
if (headRejected) {
|
|
189
|
+
used = "GET";
|
|
190
|
+
result = probeMethod("GET");
|
|
191
|
+
code = String(result.stdout || "").trim();
|
|
192
|
+
statusNum = Number.parseInt(code, 10);
|
|
193
|
+
}
|
|
194
|
+
const ok = statusNum >= 200 && statusNum < 400;
|
|
195
|
+
if (!ok) {
|
|
196
|
+
const classified = classifyFrontendBaseUrlProbeFailure({
|
|
197
|
+
spawnError: Boolean(result.error),
|
|
198
|
+
curlExit: result.status,
|
|
199
|
+
httpStatus: Number.isFinite(statusNum) ? statusNum : null,
|
|
200
|
+
});
|
|
201
|
+
return failBlocked("frontend-base-url-unreachable", {
|
|
202
|
+
baseUrlRedacted: safe,
|
|
203
|
+
httpStatus: Number.isFinite(statusNum) ? statusNum : null,
|
|
204
|
+
method: used,
|
|
205
|
+
curlExit: result.status,
|
|
206
|
+
errorClass: classified.errorClass,
|
|
207
|
+
hint: classified.hint,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const probe = {
|
|
211
|
+
status: "reachable",
|
|
212
|
+
blockedReason: null,
|
|
213
|
+
baseUrl: safe,
|
|
214
|
+
baseUrlRedacted: safe,
|
|
215
|
+
baseUrlSource,
|
|
216
|
+
httpStatus: statusNum,
|
|
217
|
+
method: used,
|
|
218
|
+
curlExit: result.status,
|
|
219
|
+
errorClass: null,
|
|
220
|
+
};
|
|
221
|
+
await writeProbe(input.workspaceRoot, probe);
|
|
222
|
+
return {
|
|
223
|
+
ok: true,
|
|
224
|
+
stdout: `frontend-test-execution-v1 validated contextBaseUrl=${safe} source=${baseUrlSource} probe=reachable method=${used} httpStatus=${statusNum}`,
|
|
225
|
+
probe,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded ATX section/list parsing for frontend-test case Markdown.
|
|
3
|
+
* Generator cases use `##` headings; older fixtures use `###`.
|
|
4
|
+
*/
|
|
5
|
+
const ATX_HEADING = /^(#{1,3})\s+(\S.*)$/;
|
|
6
|
+
function headingMatches(title, names) {
|
|
7
|
+
const normalized = title.trim();
|
|
8
|
+
return names.some((name) => {
|
|
9
|
+
if (normalized === name)
|
|
10
|
+
return true;
|
|
11
|
+
return (normalized.startsWith(name) &&
|
|
12
|
+
(normalized.length === name.length ||
|
|
13
|
+
/[\s::与((-]/.test(normalized.charAt(name.length))));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function markdownSection(body, names) {
|
|
17
|
+
const lines = body.replace(/\r\n/g, "\n").split("\n");
|
|
18
|
+
let start = -1;
|
|
19
|
+
let startLevel = 0;
|
|
20
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
21
|
+
const match = ATX_HEADING.exec(lines[index]);
|
|
22
|
+
if (!match)
|
|
23
|
+
continue;
|
|
24
|
+
if (!headingMatches(match[2], names))
|
|
25
|
+
continue;
|
|
26
|
+
start = index;
|
|
27
|
+
startLevel = match[1].length;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
if (start < 0)
|
|
31
|
+
return "";
|
|
32
|
+
const collected = [];
|
|
33
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
34
|
+
const match = ATX_HEADING.exec(lines[index]);
|
|
35
|
+
if (match && match[1].length <= startLevel)
|
|
36
|
+
break;
|
|
37
|
+
collected.push(lines[index]);
|
|
38
|
+
}
|
|
39
|
+
return collected.join("\n").trim();
|
|
40
|
+
}
|
|
41
|
+
export function markdownList(value) {
|
|
42
|
+
const items = [];
|
|
43
|
+
for (const raw of value.replace(/\r\n/g, "\n").split("\n")) {
|
|
44
|
+
const listed = raw.match(/^\s*(?:\d+[.)]|[-*+])\s+(.*)$/);
|
|
45
|
+
if (listed) {
|
|
46
|
+
const text = listed[1].trim();
|
|
47
|
+
if (text)
|
|
48
|
+
items.push(text);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const continuation = raw.match(/^\s{2,}(\S.*)$/);
|
|
52
|
+
if (continuation && items.length > 0) {
|
|
53
|
+
items[items.length - 1] = `${items[items.length - 1]} ${continuation[1].trim()}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
export function markdownH1Title(body) {
|
|
59
|
+
const match = /^#\s+(\S.*)$/m.exec(body.replace(/\r\n/g, "\n"));
|
|
60
|
+
return match?.[1]?.trim() ?? "";
|
|
61
|
+
}
|
|
@@ -5,6 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
6
6
|
import { readPlaywrightCliReceipts, summarizePlaywrightCliReceipts, } from "../../executors/pi-playwright-cli-tool.js";
|
|
7
7
|
import { validateFrontendCaseContent } from "./frontend-test-case-quality.js";
|
|
8
|
+
import { markdownH1Title, markdownList, markdownSection, } from "./frontend-test-markdown.js";
|
|
8
9
|
export const FRONTEND_TEST_RESULT_SCHEMA_ID = "frontend-test-result-v1";
|
|
9
10
|
const safeRelativePathSchema = z.string().min(1).refine((value) => !path.posix.isAbsolute(value) &&
|
|
10
11
|
!path.win32.isAbsolute(value) &&
|
|
@@ -220,34 +221,25 @@ async function loadCaseBrowserReceiptSummary(input) {
|
|
|
220
221
|
function sha256(content) {
|
|
221
222
|
return createHash("sha256").update(content).digest("hex");
|
|
222
223
|
}
|
|
223
|
-
function markdownSection(body, names) {
|
|
224
|
-
const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
225
|
-
const marker = new RegExp(`^###\\s+(?:${escaped})\\s*$`, "mi");
|
|
226
|
-
const hit = marker.exec(body);
|
|
227
|
-
if (!hit)
|
|
228
|
-
return "";
|
|
229
|
-
const rest = body.slice(hit.index + hit[0].length);
|
|
230
|
-
const next = /^###\s+/m.exec(rest);
|
|
231
|
-
return rest.slice(0, next?.index ?? rest.length).trim();
|
|
232
|
-
}
|
|
233
|
-
function markdownList(value) {
|
|
234
|
-
const items = value.split(/\r?\n/).map((line) => line.replace(/^\s*(?:\d+[.)]|[-*+])\s+/, "").trim()).filter(Boolean);
|
|
235
|
-
return items;
|
|
236
|
-
}
|
|
237
224
|
/**
|
|
238
225
|
* Read planned case facts from the case Markdown. Recognizes both English and
|
|
239
|
-
* Chinese headings
|
|
240
|
-
* test steps), alongside the legacy `操作步骤`/`Steps`
|
|
226
|
+
* Chinese headings at `##` or `###`, including `测试点` (test points) and
|
|
227
|
+
* `测试步骤` (planned test steps), alongside the legacy `操作步骤`/`Steps`
|
|
228
|
+
* headings. Numbered and bulleted lists are extracted; a missing 测试目的
|
|
229
|
+
* falls back to the case H1 title.
|
|
241
230
|
*/
|
|
242
231
|
async function readFrontendCaseContent(workspaceRoot, casePath) {
|
|
243
232
|
if (!safeRelativePathSchema.safeParse(casePath).success || !casePath.startsWith("testcase/frontend/cases/")) {
|
|
244
233
|
throw new Error(`unsafe frontend case path: ${casePath}`);
|
|
245
234
|
}
|
|
246
235
|
const body = await readFile(path.resolve(workspaceRoot, casePath), "utf8");
|
|
236
|
+
const purpose = markdownSection(body, ["Test Purpose", "测试目的", "测试场景"]) ||
|
|
237
|
+
markdownH1Title(body).replace(/^FE-[^\s::]+[::]\s*/, "").trim() ||
|
|
238
|
+
"未提供测试目的";
|
|
247
239
|
return {
|
|
248
240
|
caseContent: {
|
|
249
|
-
purpose
|
|
250
|
-
preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件"])),
|
|
241
|
+
purpose,
|
|
242
|
+
preconditions: markdownList(markdownSection(body, ["Preconditions", "前置条件", "前置条件与重置"])),
|
|
251
243
|
steps: markdownList(markdownSection(body, ["Test Steps", "测试步骤", "Steps", "操作步骤"])),
|
|
252
244
|
expectedResults: markdownList(markdownSection(body, ["Expected Results", "预期结果"])),
|
|
253
245
|
},
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME = "frontend-test-standard-scenarios.v1.json";
|
|
4
|
+
export const FRONTEND_TEST_STANDARD_SCENARIOS_DEST = "testcase/frontend/rag/standard-scenarios.v1.json";
|
|
5
|
+
const MINIMAL_STANDARD_SCENARIOS = {
|
|
6
|
+
schemaVersion: 1,
|
|
7
|
+
id: "frontend-test-standard-scenarios-v1",
|
|
8
|
+
scenarios: [
|
|
9
|
+
{
|
|
10
|
+
id: "STD-FE-SMOKE-ENTRY",
|
|
11
|
+
title: "入口可打开",
|
|
12
|
+
category: "smoke",
|
|
13
|
+
priority: "must",
|
|
14
|
+
testPoints: ["open"],
|
|
15
|
+
minCases: 1,
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
function isSafeRelativeDir(value) {
|
|
20
|
+
if (!value || path.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
24
|
+
}
|
|
25
|
+
export function listFrontendTestStandardScenarioCandidates(input) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const add = (relative) => {
|
|
28
|
+
const normalized = relative.replaceAll("\\", "/");
|
|
29
|
+
if (!seen.has(normalized))
|
|
30
|
+
seen.add(normalized);
|
|
31
|
+
};
|
|
32
|
+
add(`docs/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
|
|
33
|
+
const governanceRoot = input.governanceRoot?.trim().replaceAll("\\", "/").replace(/\/+$/, "");
|
|
34
|
+
if (governanceRoot && isSafeRelativeDir(governanceRoot)) {
|
|
35
|
+
add(`${governanceRoot}/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
|
|
36
|
+
}
|
|
37
|
+
add(`ai_workspace/loop-agent/templates/${FRONTEND_TEST_STANDARD_SCENARIOS_FILENAME}`);
|
|
38
|
+
return [...seen];
|
|
39
|
+
}
|
|
40
|
+
async function readGovernanceRoot(workspaceRoot) {
|
|
41
|
+
try {
|
|
42
|
+
const raw = JSON.parse(await readFile(path.join(workspaceRoot, "harness.json"), "utf8"));
|
|
43
|
+
return typeof raw.governanceRoot === "string" ? raw.governanceRoot : undefined;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function copyFrontendTestStandardScenarios(input) {
|
|
50
|
+
const destRel = FRONTEND_TEST_STANDARD_SCENARIOS_DEST;
|
|
51
|
+
const destAbs = path.join(input.workspaceRoot, destRel);
|
|
52
|
+
const candidates = listFrontendTestStandardScenarioCandidates({
|
|
53
|
+
governanceRoot: await readGovernanceRoot(input.workspaceRoot),
|
|
54
|
+
});
|
|
55
|
+
await mkdir(path.dirname(destAbs), { recursive: true });
|
|
56
|
+
for (const relative of candidates) {
|
|
57
|
+
const sourceAbs = path.join(input.workspaceRoot, relative);
|
|
58
|
+
try {
|
|
59
|
+
await copyFile(sourceAbs, destAbs);
|
|
60
|
+
return { status: "copied", from: relative, to: destRel };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// try the next candidate
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await writeFile(destAbs, `${JSON.stringify(MINIMAL_STANDARD_SCENARIOS, null, 2)}\n`, "utf8");
|
|
67
|
+
return { status: "fallback", to: destRel };
|
|
68
|
+
}
|
|
@@ -4389,7 +4389,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
4389
4389
|
"Coverage priority is strict inside the declared scope: P0 product requirements/task hard constraints always remain in scope; P1 exhaustively supplements documented operations, fields, business rules, statuses and errors only for Affected Operations; P2 adds bounded protocol robustness only when it is relevant to the change and does not invent product behavior. Coverage percentages describe the declared affected scope, never whole-API completeness unless every operation is explicitly listed. Conflicts or undefined expectations must stay visible as GAP/CONFLICT with precise source pointers, never guessed.",
|
|
4390
4390
|
"For uniqueness/lifecycle rules cover absent, active-existing, deleted-existing, create-delete-recreate, restore-then-recreate and documented scope/case-normalization states. For every enum cover every valid value plus bounded invalid equivalence classes (unknown, case variant, whitespace, empty, null/missing and wrong types as applicable). For every length/number rule cover min-1, min, nominal, max and max+1. For format rules cover each allowed class separately plus a valid mixed value, and representative forbidden classes including uppercase, internal/leading/trailing whitespace, tab/newline, unsupported punctuation, slash, emoji or control characters when the source contract supports that expectation.",
|
|
4391
4391
|
"Mandatory module index: include a `## Module Index` table in README that lists every planned module as a canonical relative link of the exact form `[label](./<stem>.md)` plus a `testcase/md/<stem>.md` path cell, so a downstream deterministic manifest can parse the module list. Group by stable business resource/domain, not by CRUD operation: one resource's list/detail/create/update/delete cases belong in one module such as `resource_notes`; split only when a single module would exceed the per-child 16K output protocol, keep the total module count at the smallest safe value, and never exceed 8 modules. Name each module file with a stable lowercase business stem such as `health` or `resource_notes`. Pure hexadecimal/hash-like opaque stems such as `a401606` or `deadbeef` are forbidden. Do not use priority-only stems `p0`, `p1` or `p2`; Priority belongs only in the Coverage Matrix and never defines module files. Do not use Case-ID-like module filenames such as `BE-HEALTH.md` or `BE-NOTES.md`. The relative link target MUST equal the on-disk filename stem the sharded writer will create. For every automatable case, `自动化映射` must name exactly `testcase/test_<module>.py`, where <module> is that Markdown filename without `.md`, lowercased, with non-alphanumeric characters replaced by underscores. Example: `testcase/md/health.md` → `testcase/test_health.py`; `testcase/md/resource_notes.md` → `testcase/test_resource_notes.py`. Never invent a different pytest path in Markdown than the module stem implies.",
|
|
4392
|
-
"Scenario Partitions (query/filter axes): for every affected GET/list operation, declare one row per enum or classification axis used for filtering (query/path parameters such as type/status/category). Add a mandatory machine-readable `## Scenario Partitions` section after the Coverage Matrix using exactly `| Partition ID | Operation | Axis | Domain | Required Slots | Expected by Slot | Bind Rule |` with the separator row. Partition ID is a stable `SP-<OPERATION>-<AXIS>` token; Domain must copy the legal values verbatim from the bound OpenAPI enum or requirement sentence (never guess); Required Slots writes `each-value` plus `omitted` only when the parameter is optional; Expected by Slot states the documented expectation per slot kind (`domain-value`, `default-behavior`, `empty-result`/`excluded-result` when documented, or `GAP` when the source does not document the complement expectation — never invent 空列表/400). POST/PUT body field-validation enums stay in the Coverage Matrix as `TP-<FIELD>-ENUM-*` and MUST NOT get a Scenario Partition row. Do not create partitions for axes without a documented legal-value domain. Cross-axis combinations stay as ONE nominal Case; never declare a cross-axis cartesian partition.",
|
|
4392
|
+
"Scenario Partitions (query/filter axes): for every affected GET/list operation, declare one row per enum or classification axis used for filtering (query/path parameters such as type/status/category). Add a mandatory machine-readable `## Scenario Partitions` section after the Coverage Matrix using exactly `| Partition ID | Operation | Axis | Domain | Required Slots | Expected by Slot | Bind Rule |` with the separator row. Partition ID is a stable `SP-<OPERATION>-<AXIS>` token; Domain must copy the legal values verbatim from the bound OpenAPI enum or requirement sentence (never guess); Required Slots writes `each-value` plus `omitted` only when the parameter is optional; Expected by Slot states the documented expectation per slot kind (`domain-value`, `default-behavior`, `empty-result`/`excluded-result` when documented, or `GAP` when the source does not document the complement expectation — never invent 空列表/400). POST/PUT body field-validation enums stay in the Coverage Matrix as `TP-<FIELD>-ENUM-*` and MUST NOT get a Scenario Partition row. Do not create partitions for axes without a documented legal-value domain. Only GET/list query or path parameters whose bound source documents a finite enum or classification set may become a Scenario Partition. Do not create partitions for free-form strings, primary keys, required-or-optional-only parameters, or boundary/format-only axes. If an axis has no finite legal-value domain, do not declare a Partition row and do not invent NOT-IN-SET cases. Cross-axis combinations stay as ONE nominal Case; never declare a cross-axis cartesian partition.",
|
|
4393
4393
|
"Before finalizing README, calculate the predicted collected-item count as `sum(max(1, number of variant Test Points in each Case))`. If the task declares an item budget, the prediction must not exceed it. Reduce excess only by removing duplicate execution and converting same-request checkpoints to assertions; never drop required rules, boundaries, enums, operation-specific inputs, or business states. Record the prediction in README. Use only environment-supported fixtures/targets/isolation, record evidence gaps in Chinese, and do not emit JSON, pytest, or execute commands.",
|
|
4394
4394
|
...(sharedSetupPrompt ? [sharedSetupPrompt] : []),
|
|
4395
4395
|
intake.boundedSourceContext,
|
|
@@ -4499,7 +4499,7 @@ async function buildBackendTestHybridDag(sources) {
|
|
|
4499
4499
|
"Correct testcase/md/** directly: add documented omissions, remove unsupported cases, rename module files to stable lowercase stems when needed, normalize every Case ID to hyphen-separated module segments plus exactly three zero-padded digits (`BE-RESOURCE_NOTES-01` → `BE-RESOURCE-NOTES-001`; `BE-RN-011A` must be renumbered or merged) consistently across headings/index/mappings, fix automation mappings so each automatable case points at `testcase/test_<module>.py` derived from that module filename and declares exactly one primary symbol (evidence-only meta cases may keep `脚本/primary symbol=无` with empty variants), assign every Test Point exactly one of `变体测试点`/`场景断言测试点`/`横切证据测试点`, then perform an exact-set check: each Case's `### 测试点` set must equal (not merely contain) the union of those three binding lists; delete stale/legacy aliases and ensure every binding-list Test Point is present, expand every variant parameter row into its own atomic TP ID, make every non-cross-cutting TP Case-specific and owned by exactly one Case, require every primary symbol to start with the canonical Case prefix, ensure every explicit AC ID appears in an applicable Case `验收标准`, merge execution duplicates, improve navigation/tables/Chinese wording, or record gaps in Chinese. Remove every credential/header value, placeholder, fake token and anti-example from Markdown. Sensitive key names may remain only as a plain list; values must be described as runtime-only and omitted, with no colon/value pair or literal example anywhere, including details blocks and explanatory text. Keep Case IDs, AC/REQ/BR IDs, HTTP methods, paths, fields, enum values, filenames, code symbols and source citations as exact machine-readable identifiers; only normalize Case ID separator/sequence formatting as specified above. Recalculate predicted collected items as `sum(max(1, variant count per Case))`; when the task declares a budget, directly merge redundant journeys/reclassify same-request checkpoints until the prediction is within budget, while preserving all required coverage. The validator accepts Chinese and legacy English section aliases; retain or converge to the Chinese human-readable headings without losing structure.",
|
|
4500
4500
|
"This is the single Markdown incremental synchronization round. Read every authoritative reference index entry whose role hints include acceptance-criteria, api-contract, data-contract or business-rule; do not rely on the derived PRD as a complete inventory. Preserve every explicit AC/REQ/BR ID, every documented HTTP/business error code, every DTO/JSON field, enum value, boundary, format, nested shape, transaction/state/idempotency/uniqueness/auth/tenant/cross-field rule. For each natural-language normative business rule preserved as required scope, include its exact source sentence without paraphrase together with source path and line/heading anchor so the deterministic ledger can verify quote/hash provenance. Ensure every Case declares exactly `Payload Contract: none` or the three labels `Payload Required Paths`, `Payload Allowed Paths`, and `Payload Enum`; every label must occupy its own machine-readable list line, and a Case must never concatenate target/setup operations or multiple `Payload Contract` tokens onto one line, and explanatory prose/details must not repeat any `Payload Contract:` token; never infer missing keys or enum values. A target GET/DELETE operation with no request body must remain `Payload Contract: none` even when its setup journey performs POST/PUT with a DTO; setup payloads never redefine the target Case payload contract. Add only missing Matrix rows/Test Points/Cases/assertions or repair exact drift; do not rewrite already-valid unrelated modules. Work gap-targeted: inspect source anchors and affected modules first, leave unrelated valid modules byte-stable, and return `already-satisfied` without restating the full suite when no gap exists.",
|
|
4501
4501
|
"For affected API fields, use one valid nominal payload plus atomic required/missing/null/empty/wrong-type, every documented enum value plus bounded invalid classes, documented min-1/min/nominal/max/max+1, formats and nested object/array constraints. Do not generate a Cartesian product or invent undocumented constraints. Do not invent a concrete identifier type when the source only requires presence; for a missing-resource 404 path with unspecified identifier syntax/type, synchronize the Case to a create-delete-derived valid identifier journey rather than an arbitrary UUID/text placeholder.",
|
|
4502
|
-
"Scenario Partitions synchronization: when README declares `## Scenario Partitions`, verify each declared partition's slots are fully materialized as variant Test Points with exact `TP-<Partition ID>-...` IDs (each-value per Domain value, OMITTED only for optional axes, exactly one NOT-IN-SET with intent=enum-invalid). Directly add missing slot rows/Cases
|
|
4502
|
+
"Scenario Partitions synchronization: when README declares `## Scenario Partitions`, verify each declared partition's slots are fully materialized as variant Test Points with exact `TP-<Partition ID>-...` IDs (each-value per Domain value, OMITTED only for optional axes, exactly one NOT-IN-SET with intent=enum-invalid). Directly add missing slot rows/Cases. You may delete an illegal Partition row that has no source-backed finite domain, together with its derived `TP-SP-*` slots/Cases. Never delete a legal source-backed partition or drop its complement slot to force coverage green. When the bound source does not document the complement expectation, keep the slot with GAP expected instead of guessing. Body-field validation enums (`TP-<FIELD>-ENUM-*`) are NOT partitions — do not add partition rows for them.",
|
|
4503
4503
|
"Before returning, verify that every explicit source AC/REQ/BR, error code and strong DTO field token appears in README or an applicable module Case. If a fact cannot be safely automated, retain it as GAP/CONFLICT with its exact source pointer instead of dropping it. Return already-satisfied only when no target file needs an incremental edit.",
|
|
4504
4504
|
"Read only indexed source paths. Do not scan the repository, modify source/**, generate pytest, execute tests, or emit JSON.",
|
|
4505
4505
|
...(sharedSetupPrompt ? [sharedSetupPrompt] : []),
|
|
@@ -4978,15 +4978,11 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
4978
4978
|
writeSet: ragWriteSet,
|
|
4979
4979
|
allowedPaths: [...ragWriteSet],
|
|
4980
4980
|
forbiddenPaths: forbidden,
|
|
4981
|
-
outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage.",
|
|
4982
|
-
subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package.",
|
|
4981
|
+
outputContract: "Write testcase/frontend/rag/standard-scenarios.v1.json for generate-time standard scenario coverage. Copy docs/templates or harness.json governanceRoot templates (including ai_workspace/loop-agent/templates) when present; otherwise write the minimal STD-FE-SMOKE-ENTRY fallback.",
|
|
4982
|
+
subtask_prompt: "Prepare frontend-test package: materialize standard-scenarios.v1.json into the RAG package from docs/templates, governanceRoot/templates, or the init-projected ai_workspace/loop-agent/templates path.",
|
|
4983
4983
|
shell: {
|
|
4984
|
-
commands: [
|
|
4985
|
-
|
|
4986
|
-
"node -e",
|
|
4987
|
-
JSON.stringify("const fs=require('fs'),path=require('path');const dest='testcase/frontend/rag/standard-scenarios.v1.json';const candidates=[path.join('docs','templates','frontend-test-standard-scenarios.v1.json')];let src=null;for(const c of candidates){if(fs.existsSync(c)){src=c;break;}}fs.mkdirSync(path.dirname(dest),{recursive:true});if(src){fs.copyFileSync(src,dest);process.stdout.write(JSON.stringify({status:'copied',from:src,to:dest}));}else{const minimal={schemaVersion:1,id:'frontend-test-standard-scenarios-v1',scenarios:[{id:'STD-FE-SMOKE-ENTRY',title:'入口可打开',category:'smoke',priority:'must',testPoints:['open'],minCases:1}]};fs.writeFileSync(dest,JSON.stringify(minimal,null,2)+'\n');process.stdout.write(JSON.stringify({status:'fallback',to:dest}));}"),
|
|
4988
|
-
].join(" "),
|
|
4989
|
-
],
|
|
4984
|
+
commands: [],
|
|
4985
|
+
frontendTestStandardScenarios: {},
|
|
4990
4986
|
cwd: ".",
|
|
4991
4987
|
timeoutMs: 60_000,
|
|
4992
4988
|
},
|
|
@@ -5022,15 +5018,11 @@ function buildFrontendTestHybridDag(sources) {
|
|
|
5022
5018
|
writeSet: ragWriteSet,
|
|
5023
5019
|
allowedPaths: [...ragWriteSet],
|
|
5024
5020
|
forbiddenPaths: forbidden,
|
|
5025
|
-
outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable (
|
|
5026
|
-
subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. 4xx/5xx/DNS/timeout/
|
|
5021
|
+
outputContract: "Fail-closed environment preflight: absolute non-production baseUrl + curl HTTP reachability; writes environmentProbe facts; unreachable => blockedReason frontend-base-url-unreachable with errorClass (connection-refused / dns-unresolved / connect-timeout / http-N / curl-exit-N). Node ERROR so generate/map do not run. Does not start the app.",
|
|
5022
|
+
subtask_prompt: "Parse the resolved absolute baseUrl from testcase/frontend/rag/context.md, reject production/non-http(s)/credential/query/fragment URLs, then probe it with curl HEAD and GET fallback (connect/max-time; no auth/cookie). 2xx/3xx => reachable and continue. Connection refused (curl 7) records errorClass=connection-refused and tells the operator to start the local app (scripts/serve.sh or npm start) then rerun from this node. 4xx/5xx/DNS/timeout/TLS => blockedReason frontend-base-url-unreachable with a distinct errorClass. Missing curl => blockedReason curl-unavailable. Do not start the app. Fixture/reset remain soft guidance.",
|
|
5027
5023
|
shell: {
|
|
5028
|
-
commands: [
|
|
5029
|
-
|
|
5030
|
-
"node -e",
|
|
5031
|
-
JSON.stringify(`const fs=require('fs');const {spawnSync}=require('child_process');const p='testcase/frontend/rag/context.md';const probePath='testcase/frontend/rag/environment-probe.json';function writeProbe(obj){try{fs.mkdirSync('testcase/frontend/rag',{recursive:true});fs.writeFileSync(probePath,JSON.stringify(obj,null,2)+'\\n');let ctx=fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const line='environmentProbe: '+obj.status+(obj.blockedReason?(' ('+obj.blockedReason+')'):'');if(/environmentProbe\\s*[:=]/i.test(ctx)){ctx=ctx.replace(/environmentProbe\\s*[:=]\\s*.*/i,line);}else{ctx=ctx.trimEnd()+'\\n\\n'+line+'\\n';}fs.writeFileSync(p,ctx);}catch(e){console.error('probe-write-failed',e&&e.message||e);}}function redactUrl(u){try{const x=new URL(u);x.username='';x.password='';if(x.search){x.search='';}return x.toString();}catch(_){return String(u).replace(/\\/\\/[^@\\s]+@/g,'//');}}function failBlocked(reason,extra){const payload=Object.assign({status:'unreachable',blockedReason:reason,baseUrlRedacted:extra&&extra.baseUrlRedacted||null,httpStatus:extra&&extra.httpStatus||null,method:extra&&extra.method||null,curlExit:extra&&extra.curlExit||null,errorClass:extra&&extra.errorClass||null},extra||{});writeProbe(payload);console.error('frontend-test preflight blocked: '+JSON.stringify({blockedReason:reason,baseUrl:payload.baseUrlRedacted,httpStatus:payload.httpStatus,errorClass:payload.errorClass}));throw new Error('frontend-test preflight blocked: '+reason);}if(!fs.existsSync(p))throw new Error('missing '+p);const s=fs.readFileSync(p,'utf8');const patterns=[/baseUrl\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/base[-_ ]url\\s*[:=]\\s*["'\\x60]?((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/playwright-cli open --browser=chrome\\s+((?:https?):\\/\\/[^\\s"'\\x60<>]+)/i,/(https?:\\/\\/(?:localhost|127\\.0\\.0\\.1)[^\\s)\\}\\],"']*)/i];let baseUrl=null;for(const re of patterns){const m=s.match(re);if(m){baseUrl=m[1];break;}}if(!baseUrl)throw new Error('frontend-test preflight missing absolute baseUrl from context.md');baseUrl=baseUrl.replace(/[)\\}\\],."'\\x60]+$/,'');const sourceMatch=s.match(/baseUrlSource\\s*[:=]\\s*([^\\r\\n]+)/i);const baseUrlSource=sourceMatch?sourceMatch[1].trim():'context.md';let parsed;try{parsed=new URL(baseUrl);}catch(_){throw new Error('baseUrl must be absolute http(s): '+baseUrl);}if((parsed.protocol!=='http:'&&parsed.protocol!=='https:')||parsed.username||parsed.password||parsed.search||parsed.hash)throw new Error('unsafe baseUrl from context.md: '+redactUrl(baseUrl));if(/(?:^|\\.)(?:www\\.)?[^.]*(?:prod|production)/i.test(parsed.hostname))throw new Error('production URL forbidden: '+redactUrl(baseUrl));baseUrl=parsed.toString();const safe=redactUrl(baseUrl);const curlCheck=spawnSync('curl',['--version'],{encoding:'utf8'});if(curlCheck.error||curlCheck.status!==0){failBlocked('curl-unavailable',{baseUrlRedacted:safe,errorClass:'curl-missing'});}function probe(method){const args=['-sS','-o','/dev/null','-w','%{http_code}','--connect-timeout','3','--max-time','8','-X',method,'-L','--max-redirs','3','--http1.1','--proto-redir','=http,https',safe];const r=spawnSync('curl',args,{encoding:'utf8'});return r;}let used='HEAD';let r=probe('HEAD');let code=String(r.stdout||'').trim();let statusNum=parseInt(code,10);const headRejected=r.status!==0||!statusNum||statusNum===405||statusNum===501;if(headRejected){used='GET';r=probe('GET');code=String(r.stdout||'').trim();statusNum=parseInt(code,10);}const ok=statusNum>=200&&statusNum<400;if(!ok){const errClass=r.error?'spawn-error':(r.status!==0?'curl-exit-'+r.status:('http-'+statusNum));failBlocked('frontend-base-url-unreachable',{baseUrlRedacted:safe,httpStatus:statusNum||null,method:used,curlExit:r.status,errorClass:errClass});}writeProbe({status:'reachable',blockedReason:null,baseUrl:safe,baseUrlRedacted:safe,baseUrlSource,httpStatus:statusNum,method:used,curlExit:r.status});console.log('frontend-test-execution-v1 validated contextBaseUrl='+safe+' source='+baseUrlSource+' probe=reachable method='+used+' httpStatus='+statusNum);`),
|
|
5032
|
-
].join(" "),
|
|
5033
|
-
],
|
|
5024
|
+
commands: [],
|
|
5025
|
+
frontendTestEnvironmentProbe: {},
|
|
5034
5026
|
cwd: ".",
|
|
5035
5027
|
timeoutMs: 60000,
|
|
5036
5028
|
},
|