@tea-agent/loop-agent 0.31.0 → 0.31.1
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 +28 -0
- package/dist/executors/dag-pi-executor.js +69 -2
- package/dist/executors/pi-sdk-executor.js +61 -0
- package/dist/executors/shell-executor.js +136 -79
- package/dist/workflows/dag/backend-test-pytest-collection.js +162 -7
- package/dist/workflows/dag/backend-test-result-contract.js +105 -67
- package/dist/workflows/dag/backend-test-scenario-param.js +92 -30
- package/dist/workflows/dag/backend-test-writer-completeness.js +55 -0
- package/dist/workflows/dag/init-hybrid.js +44 -47
- package/dist/workflows/dag/rerun-task.js +86 -0
- package/docs/templates/backend-test-dag.json +40 -60
- package/package.json +1 -1
|
@@ -11,12 +11,16 @@ export const backendPytestCollectionFindingSchema = z.object({
|
|
|
11
11
|
detail: z.string().min(1),
|
|
12
12
|
}).strict();
|
|
13
13
|
export const backendPytestCollectionFactsSchema = z.object({
|
|
14
|
-
schemaId: z.literal("backend-test-pytest-collection-
|
|
14
|
+
schemaId: z.literal("backend-test-pytest-collection-v3"),
|
|
15
15
|
phase: z.enum(["initial", "final", "effective"]),
|
|
16
16
|
status: z.enum(["PASS", "REPAIRABLE", "BLOCKED"]),
|
|
17
17
|
repairEligible: z.boolean(),
|
|
18
18
|
repairAttempt: z.number().int().min(0).max(1),
|
|
19
19
|
collectionAttempted: z.boolean(),
|
|
20
|
+
fixtureResolutionAttempted: z.boolean(),
|
|
21
|
+
fixtureResolutionStatus: z.enum(["NOT_RUN", "PASS", "REPAIRABLE", "BLOCKED"]),
|
|
22
|
+
fixtureResolutionExitCode: z.number().int().nullable(),
|
|
23
|
+
repairPaths: z.array(z.string()),
|
|
20
24
|
mappedScripts: z.array(z.string()).min(1),
|
|
21
25
|
existingMappedScripts: z.array(z.string()),
|
|
22
26
|
missingMappedScripts: z.array(z.string()),
|
|
@@ -28,17 +32,37 @@ export const backendPytestCollectionFactsSchema = z.object({
|
|
|
28
32
|
findings: z.array(backendPytestCollectionFindingSchema),
|
|
29
33
|
stdoutExcerpt: z.string(),
|
|
30
34
|
stderrExcerpt: z.string(),
|
|
35
|
+
fixtureStdoutExcerpt: z.string(),
|
|
36
|
+
fixtureStderrExcerpt: z.string(),
|
|
31
37
|
collectionSource: z.enum(["initial", "final"]).optional(),
|
|
32
38
|
}).strict().superRefine((facts, context) => {
|
|
33
39
|
if (facts.status === "PASS") {
|
|
34
|
-
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
35
|
-
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires
|
|
40
|
+
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || !facts.fixtureResolutionAttempted || facts.fixtureResolutionStatus !== "PASS" || facts.fixtureResolutionExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
41
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires collection and fixture-resolution PASS, complete mapped scripts and bound assets" });
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
if (!facts.collectionAttempted && facts.pytestExitCode !== null) {
|
|
39
45
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection without an attempt cannot have an exit code" });
|
|
40
46
|
}
|
|
47
|
+
if (!facts.fixtureResolutionAttempted && facts.fixtureResolutionExitCode !== null) {
|
|
48
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
|
|
49
|
+
}
|
|
41
50
|
});
|
|
51
|
+
export const backendTestExecutionReadinessSchema = z.object({
|
|
52
|
+
schemaId: z.literal("backend-test-execution-readiness-v1"),
|
|
53
|
+
status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
|
|
54
|
+
collectionStatus: z.literal("PASS"),
|
|
55
|
+
fixtureResolutionStatus: z.literal("PASS"),
|
|
56
|
+
scenarioParamStatus: z.enum(["PASS", "PARTIAL", "FAIL", "UNAVAILABLE"]),
|
|
57
|
+
repairAttempts: z.object({
|
|
58
|
+
collection: z.number().int().min(0).max(1),
|
|
59
|
+
scenarioParam: z.number().int().min(0).max(1),
|
|
60
|
+
}).strict(),
|
|
61
|
+
mappedScripts: z.array(z.string()).min(1),
|
|
62
|
+
collectedItemIds: z.array(z.string()),
|
|
63
|
+
fixtureIssues: z.array(z.string()),
|
|
64
|
+
assetHashes: z.record(z.string(), z.string().regex(SHA256)),
|
|
65
|
+
}).strict();
|
|
42
66
|
function repoRef(workspaceRoot, absolutePath) {
|
|
43
67
|
return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
|
|
44
68
|
}
|
|
@@ -106,6 +130,28 @@ function collectionItems(stdout) {
|
|
|
106
130
|
return /^testcase\//.test(line.replaceAll("\\", "/"));
|
|
107
131
|
}).map((line) => line.replaceAll("\\", "/")))];
|
|
108
132
|
}
|
|
133
|
+
function testcasePythonPaths(output) {
|
|
134
|
+
return [...new Set((output.replaceAll("\\", "/").match(/testcase\/[A-Za-z0-9_./-]+\.py/gi) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
|
|
135
|
+
}
|
|
136
|
+
function classifyFixtureResolutionFailure(output) {
|
|
137
|
+
const normalized = output.replaceAll("\\", "/");
|
|
138
|
+
const paths = testcasePythonPaths(normalized);
|
|
139
|
+
const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
|
|
140
|
+
if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
status: "REPAIRABLE",
|
|
143
|
+
kind: "missing-generated-fixture",
|
|
144
|
+
detail: "generated pytest fixture dependency or plugin registration is incomplete",
|
|
145
|
+
repairPaths: generatedProviderPaths,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: "BLOCKED",
|
|
150
|
+
kind: "unresolved-fixture-dependency",
|
|
151
|
+
detail: "fixture resolution failed without a safely attributable generated provider",
|
|
152
|
+
repairPaths: paths,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
109
155
|
function classifyCollectionFailure(output) {
|
|
110
156
|
const normalized = output.replaceAll("\\", "/");
|
|
111
157
|
const blockedPatterns = [
|
|
@@ -134,14 +180,19 @@ function classifyCollectionFailure(output) {
|
|
|
134
180
|
}
|
|
135
181
|
export function assessBackendPytestCollection(input) {
|
|
136
182
|
const items = collectionItems(input.stdout);
|
|
137
|
-
|
|
183
|
+
const fixtureResolution = input.fixtureResolution ?? { exitCode: 0, stdout: "fixture resolution assumed by direct assessor", stderr: "" };
|
|
184
|
+
if (input.exitCode === 0 && fixtureResolution.exitCode === 0) {
|
|
138
185
|
return backendPytestCollectionFactsSchema.parse({
|
|
139
|
-
schemaId: "backend-test-pytest-collection-
|
|
186
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
140
187
|
phase: input.phase,
|
|
141
188
|
status: "PASS",
|
|
142
189
|
repairEligible: false,
|
|
143
190
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
144
191
|
collectionAttempted: true,
|
|
192
|
+
fixtureResolutionAttempted: true,
|
|
193
|
+
fixtureResolutionStatus: "PASS",
|
|
194
|
+
fixtureResolutionExitCode: 0,
|
|
195
|
+
repairPaths: [],
|
|
145
196
|
mappedScripts: input.inventory.mappedScripts,
|
|
146
197
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
147
198
|
missingMappedScripts: [],
|
|
@@ -153,16 +204,55 @@ export function assessBackendPytestCollection(input) {
|
|
|
153
204
|
findings: [],
|
|
154
205
|
stdoutExcerpt: bounded(input.stdout),
|
|
155
206
|
stderrExcerpt: bounded(input.stderr),
|
|
207
|
+
fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
|
|
208
|
+
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (input.exitCode === 0) {
|
|
212
|
+
const classification = classifyFixtureResolutionFailure(`${fixtureResolution.stdout}\n${fixtureResolution.stderr}`);
|
|
213
|
+
return backendPytestCollectionFactsSchema.parse({
|
|
214
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
215
|
+
phase: input.phase,
|
|
216
|
+
status: classification.status,
|
|
217
|
+
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
218
|
+
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
219
|
+
collectionAttempted: true,
|
|
220
|
+
fixtureResolutionAttempted: true,
|
|
221
|
+
fixtureResolutionStatus: classification.status,
|
|
222
|
+
fixtureResolutionExitCode: fixtureResolution.exitCode,
|
|
223
|
+
repairPaths: classification.repairPaths,
|
|
224
|
+
mappedScripts: input.inventory.mappedScripts,
|
|
225
|
+
existingMappedScripts: input.inventory.mappedScripts,
|
|
226
|
+
missingMappedScripts: [],
|
|
227
|
+
assetFiles: input.inventory.assetFiles,
|
|
228
|
+
inputHashes: input.inventory.inputHashes,
|
|
229
|
+
pytestExitCode: input.exitCode,
|
|
230
|
+
collectedItemCount: items.length,
|
|
231
|
+
collectedItemIds: items,
|
|
232
|
+
findings: [{
|
|
233
|
+
kind: classification.kind,
|
|
234
|
+
classification: "test-asset-defect",
|
|
235
|
+
repairability: classification.status === "REPAIRABLE" ? "repairable" : "blocked",
|
|
236
|
+
detail: classification.detail,
|
|
237
|
+
}],
|
|
238
|
+
stdoutExcerpt: bounded(input.stdout),
|
|
239
|
+
stderrExcerpt: bounded(input.stderr),
|
|
240
|
+
fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
|
|
241
|
+
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
156
242
|
});
|
|
157
243
|
}
|
|
158
244
|
const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
|
|
159
245
|
return backendPytestCollectionFactsSchema.parse({
|
|
160
|
-
schemaId: "backend-test-pytest-collection-
|
|
246
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
161
247
|
phase: input.phase,
|
|
162
248
|
status: classification.status,
|
|
163
249
|
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
164
250
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
165
251
|
collectionAttempted: true,
|
|
252
|
+
fixtureResolutionAttempted: false,
|
|
253
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
254
|
+
fixtureResolutionExitCode: null,
|
|
255
|
+
repairPaths: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
|
|
166
256
|
mappedScripts: input.inventory.mappedScripts,
|
|
167
257
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
168
258
|
missingMappedScripts: [],
|
|
@@ -179,16 +269,22 @@ export function assessBackendPytestCollection(input) {
|
|
|
179
269
|
}],
|
|
180
270
|
stdoutExcerpt: bounded(input.stdout),
|
|
181
271
|
stderrExcerpt: bounded(input.stderr),
|
|
272
|
+
fixtureStdoutExcerpt: "",
|
|
273
|
+
fixtureStderrExcerpt: "",
|
|
182
274
|
});
|
|
183
275
|
}
|
|
184
276
|
export function assessMissingBackendPytestScripts(input) {
|
|
185
277
|
return backendPytestCollectionFactsSchema.parse({
|
|
186
|
-
schemaId: "backend-test-pytest-collection-
|
|
278
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
187
279
|
phase: "initial",
|
|
188
280
|
status: "REPAIRABLE",
|
|
189
281
|
repairEligible: true,
|
|
190
282
|
repairAttempt: 0,
|
|
191
283
|
collectionAttempted: false,
|
|
284
|
+
fixtureResolutionAttempted: false,
|
|
285
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
286
|
+
fixtureResolutionExitCode: null,
|
|
287
|
+
repairPaths: [...input.missingMappedScripts],
|
|
192
288
|
mappedScripts: [...input.mappedScripts],
|
|
193
289
|
existingMappedScripts: [...input.existingMappedScripts],
|
|
194
290
|
missingMappedScripts: [...input.missingMappedScripts],
|
|
@@ -205,6 +301,8 @@ export function assessMissingBackendPytestScripts(input) {
|
|
|
205
301
|
})),
|
|
206
302
|
stdoutExcerpt: "",
|
|
207
303
|
stderrExcerpt: "",
|
|
304
|
+
fixtureStdoutExcerpt: "",
|
|
305
|
+
fixtureStderrExcerpt: "",
|
|
208
306
|
});
|
|
209
307
|
}
|
|
210
308
|
export function renderBackendPytestCollectionReport(facts) {
|
|
@@ -219,6 +317,10 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
219
317
|
`- Repair attempt: ${facts.repairAttempt}`,
|
|
220
318
|
`- Collection attempted: ${facts.collectionAttempted}`,
|
|
221
319
|
`- Pytest exit code: ${facts.pytestExitCode ?? "not-run"}`,
|
|
320
|
+
`- Fixture resolution attempted: ${facts.fixtureResolutionAttempted}`,
|
|
321
|
+
`- Fixture resolution status: ${facts.fixtureResolutionStatus}`,
|
|
322
|
+
`- Fixture resolution exit code: ${facts.fixtureResolutionExitCode ?? "not-run"}`,
|
|
323
|
+
`- Repair paths: ${facts.repairPaths.join(", ") || "none"}`,
|
|
222
324
|
`- Mapped scripts: ${facts.mappedScripts.length}`,
|
|
223
325
|
`- Existing mapped scripts: ${facts.existingMappedScripts.length}`,
|
|
224
326
|
`- Missing mapped scripts: ${facts.missingMappedScripts.length}`,
|
|
@@ -245,6 +347,18 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
245
347
|
facts.stderrExcerpt,
|
|
246
348
|
"```",
|
|
247
349
|
"",
|
|
350
|
+
"## Fixture Resolution stdout",
|
|
351
|
+
"",
|
|
352
|
+
"```text",
|
|
353
|
+
facts.fixtureStdoutExcerpt,
|
|
354
|
+
"```",
|
|
355
|
+
"",
|
|
356
|
+
"## Fixture Resolution stderr",
|
|
357
|
+
"",
|
|
358
|
+
"```text",
|
|
359
|
+
facts.fixtureStderrExcerpt,
|
|
360
|
+
"```",
|
|
361
|
+
"",
|
|
248
362
|
].join("\n");
|
|
249
363
|
}
|
|
250
364
|
export async function writeBackendPytestCollectionArtifacts(input) {
|
|
@@ -289,6 +403,47 @@ function assertSameInventory(expected, actual) {
|
|
|
289
403
|
throw new Error(`backend pytest collection hash drift: ${file}`);
|
|
290
404
|
}
|
|
291
405
|
}
|
|
406
|
+
export async function materializeBackendTestExecutionReadiness(input) {
|
|
407
|
+
if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
|
|
408
|
+
throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
|
|
409
|
+
}
|
|
410
|
+
const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
|
|
411
|
+
assertSameInventory(input.effective, current);
|
|
412
|
+
const status = input.scenarioParamStatus === "FAIL"
|
|
413
|
+
? "BLOCKED"
|
|
414
|
+
: input.scenarioParamStatus === "PASS"
|
|
415
|
+
? "PASS"
|
|
416
|
+
: "PARTIAL";
|
|
417
|
+
const readiness = backendTestExecutionReadinessSchema.parse({
|
|
418
|
+
schemaId: "backend-test-execution-readiness-v1",
|
|
419
|
+
status,
|
|
420
|
+
collectionStatus: "PASS",
|
|
421
|
+
fixtureResolutionStatus: "PASS",
|
|
422
|
+
scenarioParamStatus: input.scenarioParamStatus,
|
|
423
|
+
repairAttempts: {
|
|
424
|
+
collection: input.effective.repairAttempt,
|
|
425
|
+
scenarioParam: input.scenarioParamRepairAttempt,
|
|
426
|
+
},
|
|
427
|
+
mappedScripts: input.effective.mappedScripts,
|
|
428
|
+
collectedItemIds: input.effective.collectedItemIds,
|
|
429
|
+
fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
|
|
430
|
+
assetHashes: input.effective.inputHashes,
|
|
431
|
+
});
|
|
432
|
+
const contractsDir = path.join(input.runDir, "contracts");
|
|
433
|
+
await mkdir(contractsDir, { recursive: true });
|
|
434
|
+
await writeFile(path.join(contractsDir, "backend-test-execution-readiness.json"), `${JSON.stringify(readiness, null, 2)}\n`, "utf8");
|
|
435
|
+
return readiness;
|
|
436
|
+
}
|
|
437
|
+
export async function readBackendTestExecutionReadiness(filePath) {
|
|
438
|
+
return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
|
|
439
|
+
}
|
|
440
|
+
export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
|
|
441
|
+
if (!["PASS", "PARTIAL"].includes(readiness.status)) {
|
|
442
|
+
throw new Error(`backend pytest execution readiness is ${readiness.status}`);
|
|
443
|
+
}
|
|
444
|
+
const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
|
|
445
|
+
assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
|
|
446
|
+
}
|
|
292
447
|
export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
|
|
293
448
|
if (effective.phase !== "effective" || effective.status !== "PASS") {
|
|
294
449
|
throw new Error("backend pytest execution requires effective collection PASS facts");
|
|
@@ -17,6 +17,8 @@ export const BACKEND_TEST_CLASSIFICATION_CATEGORIES = [
|
|
|
17
17
|
export const backendTestExecutionStatusSchema = z.enum([
|
|
18
18
|
"completed",
|
|
19
19
|
"collection-error",
|
|
20
|
+
"setup-error",
|
|
21
|
+
"teardown-error",
|
|
20
22
|
"command-error",
|
|
21
23
|
"report-error",
|
|
22
24
|
]);
|
|
@@ -38,6 +40,8 @@ export const backendTestOutcomeSchema = z.enum([
|
|
|
38
40
|
"passed",
|
|
39
41
|
"completed-with-failures",
|
|
40
42
|
"collection-error",
|
|
43
|
+
"setup-error",
|
|
44
|
+
"teardown-error",
|
|
41
45
|
"command-error",
|
|
42
46
|
"report-error",
|
|
43
47
|
]);
|
|
@@ -55,6 +59,10 @@ const failureSummarySchema = z
|
|
|
55
59
|
name: z.string().min(1),
|
|
56
60
|
message: z.string().min(1),
|
|
57
61
|
kind: z.enum(["failure", "error"]).default("failure"),
|
|
62
|
+
nodeId: z.string().min(1).optional(),
|
|
63
|
+
caseId: z.string().min(1).optional(),
|
|
64
|
+
tpId: z.string().min(1).optional(),
|
|
65
|
+
phase: z.enum(["collection", "setup", "call", "teardown", "unknown"]).optional(),
|
|
58
66
|
})
|
|
59
67
|
.strict();
|
|
60
68
|
export const backendTestResultContractSchema = z
|
|
@@ -70,6 +78,12 @@ export const backendTestResultContractSchema = z
|
|
|
70
78
|
failed: z.number().int().min(0),
|
|
71
79
|
error: z.number().int().min(0),
|
|
72
80
|
skipped: z.number().int().min(0),
|
|
81
|
+
collectedItemCount: z.number().int().min(0),
|
|
82
|
+
setupStartedCount: z.number().int().min(0),
|
|
83
|
+
businessTestBodyExecutedCount: z.number().int().min(0),
|
|
84
|
+
setupErrorCount: z.number().int().min(0),
|
|
85
|
+
testFailureCount: z.number().int().min(0),
|
|
86
|
+
teardownErrorCount: z.number().int().min(0),
|
|
73
87
|
durationMs: z.number().nonnegative().optional(),
|
|
74
88
|
junit: z
|
|
75
89
|
.object({
|
|
@@ -403,6 +417,39 @@ function parseDurationLabelMs(raw) {
|
|
|
403
417
|
// pytest-html commonly emits bare milliseconds without a unit suffix.
|
|
404
418
|
return Math.round(value);
|
|
405
419
|
}
|
|
420
|
+
function canonicalPytestItemId(testId) {
|
|
421
|
+
return testId.replace(/::(?:setup|teardown)$/i, "");
|
|
422
|
+
}
|
|
423
|
+
function pytestHtmlPhase(testId, log) {
|
|
424
|
+
if (/::setup$/i.test(testId) || /ERROR at setup|Captured setup/i.test(log))
|
|
425
|
+
return "setup";
|
|
426
|
+
if (/::teardown$/i.test(testId) || /ERROR at teardown|Captured teardown/i.test(log))
|
|
427
|
+
return "teardown";
|
|
428
|
+
if (/collecting|ERROR collecting/i.test(log))
|
|
429
|
+
return "collection";
|
|
430
|
+
return "call";
|
|
431
|
+
}
|
|
432
|
+
function pytestFailureIdentity(nodeId) {
|
|
433
|
+
const caseToken = nodeId.match(/BE[-_][A-Z0-9_-]+[-_]\d{2,3}/i)?.[0];
|
|
434
|
+
const caseId = caseToken
|
|
435
|
+
? caseToken.replace(/^BE_/i, "BE-").replaceAll("_", "-").toUpperCase()
|
|
436
|
+
: undefined;
|
|
437
|
+
const tpId = nodeId.match(/\[(TP-[A-Z0-9-]+)\]/i)?.[1]?.toUpperCase();
|
|
438
|
+
return { ...(caseId ? { caseId } : {}), ...(tpId ? { tpId } : {}) };
|
|
439
|
+
}
|
|
440
|
+
function parsedExecutionCounts(parsed) {
|
|
441
|
+
const setupErrorCount = parsed.cases.filter((item) => item.status === "error" && (item.phase === "setup" || /setup|fixture ['"][^'"]+['"] not found/i.test(`${item.name} ${item.message ?? ""} ${item.details ?? ""}`))).length;
|
|
442
|
+
const teardownErrorCount = parsed.cases.filter((item) => item.status === "error" && (item.phase === "teardown" || /teardown/i.test(`${item.name} ${item.message ?? ""} ${item.details ?? ""}`))).length;
|
|
443
|
+
const businessTestBodyExecutedCount = parsed.cases.filter((item) => item.status === "passed" || item.status === "failure" || (item.status === "error" && item.phase === "call" && !/fixture ['"][^'"]+['"] not found/i.test(`${item.message ?? ""} ${item.details ?? ""}`))).length;
|
|
444
|
+
return {
|
|
445
|
+
collectedItemCount: parsed.tests,
|
|
446
|
+
setupStartedCount: Math.max(0, parsed.tests - parsed.cases.filter((item) => item.phase === "collection").length),
|
|
447
|
+
businessTestBodyExecutedCount,
|
|
448
|
+
setupErrorCount,
|
|
449
|
+
testFailureCount: parsed.failed,
|
|
450
|
+
teardownErrorCount,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
406
453
|
/**
|
|
407
454
|
* Extract the pytest-html 4.x JSON island from a self-contained HTML report.
|
|
408
455
|
*
|
|
@@ -451,93 +498,46 @@ export function parsePytestHtmlReport(html) {
|
|
|
451
498
|
if (!record)
|
|
452
499
|
continue;
|
|
453
500
|
const rawResult = (record.result ?? "").toLowerCase();
|
|
454
|
-
const
|
|
455
|
-
const
|
|
501
|
+
const rawTestId = record.testId ?? nodeId;
|
|
502
|
+
const capturedLog = record.log ?? "";
|
|
503
|
+
const phase = pytestHtmlPhase(rawTestId, capturedLog);
|
|
504
|
+
const canonicalNodeId = canonicalPytestItemId(nodeId.includes("::") ? nodeId : rawTestId);
|
|
505
|
+
const { classname, name, filePath } = splitPytestNodeId(canonicalNodeId);
|
|
506
|
+
const identity = pytestFailureIdentity(canonicalNodeId);
|
|
456
507
|
const filePathFields = filePath ? { filePath } : {};
|
|
457
508
|
const durationMs = parseDurationLabelMs(record.duration);
|
|
458
|
-
const capturedLog = record.log ?? "";
|
|
459
509
|
const decodedCapturedLog = decodeHtmlEntitiesDeep(capturedLog);
|
|
460
|
-
// pytest-html collapses captured stdout/stderr into a single `log` field
|
|
461
|
-
// annotated with section markers. Preserve the whole log as stdout so the
|
|
462
|
-
// HTTP_REQUEST/HTTP_RESPONSE lines stay reachable for the per-case card.
|
|
463
510
|
const stdout = splitPytestHtmlLogSections(capturedLog).stdout || undefined;
|
|
464
511
|
const stderr = splitPytestHtmlLogSections(capturedLog).stderr || undefined;
|
|
512
|
+
const baseCase = { classname, name, nodeId: canonicalNodeId, phase, ...filePathFields, durationMs };
|
|
465
513
|
if (rawResult === "passed") {
|
|
466
514
|
passed += 1;
|
|
467
|
-
cases.push({
|
|
468
|
-
classname,
|
|
469
|
-
name,
|
|
470
|
-
...filePathFields,
|
|
471
|
-
...filePathFields,
|
|
472
|
-
durationMs,
|
|
473
|
-
status: "passed",
|
|
474
|
-
...(stdout ? { stdout } : {}),
|
|
475
|
-
...(stderr ? { stderr } : {}),
|
|
476
|
-
});
|
|
515
|
+
cases.push({ ...baseCase, status: "passed", ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}) });
|
|
477
516
|
}
|
|
478
517
|
else if (rawResult === "failed") {
|
|
479
518
|
failed += 1;
|
|
480
519
|
const message = extractPytestHtmlFailureMessage(capturedLog) || "failure";
|
|
481
520
|
const summary = truncate(message);
|
|
482
|
-
failures.push({ classname, name, message: summary, kind: "failure" });
|
|
483
|
-
cases.push({
|
|
484
|
-
classname,
|
|
485
|
-
name,
|
|
486
|
-
...filePathFields,
|
|
487
|
-
durationMs,
|
|
488
|
-
status: "failure",
|
|
489
|
-
message: summary,
|
|
490
|
-
details: decodedCapturedLog || summary,
|
|
491
|
-
...(stdout ? { stdout } : {}),
|
|
492
|
-
...(stderr ? { stderr } : {}),
|
|
493
|
-
});
|
|
521
|
+
failures.push({ classname, name, message: summary, kind: "failure", nodeId: canonicalNodeId, phase, ...identity });
|
|
522
|
+
cases.push({ ...baseCase, status: "failure", message: summary, details: decodedCapturedLog || summary, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}) });
|
|
494
523
|
}
|
|
495
524
|
else if (rawResult === "error") {
|
|
496
525
|
errors += 1;
|
|
497
526
|
const message = extractPytestHtmlFailureMessage(capturedLog) || "error";
|
|
498
527
|
const summary = truncate(message);
|
|
499
|
-
failures.push({ classname, name, message: summary, kind: "error" });
|
|
500
|
-
cases.push({
|
|
501
|
-
classname,
|
|
502
|
-
name,
|
|
503
|
-
...filePathFields,
|
|
504
|
-
durationMs,
|
|
505
|
-
status: "error",
|
|
506
|
-
message: summary,
|
|
507
|
-
details: decodedCapturedLog || summary,
|
|
508
|
-
...(stdout ? { stdout } : {}),
|
|
509
|
-
...(stderr ? { stderr } : {}),
|
|
510
|
-
});
|
|
528
|
+
failures.push({ classname, name, message: summary, kind: "error", nodeId: canonicalNodeId, phase, ...identity });
|
|
529
|
+
cases.push({ ...baseCase, status: "error", message: summary, details: decodedCapturedLog || summary, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}) });
|
|
511
530
|
}
|
|
512
531
|
else if (rawResult === "skipped" || rawResult === "xfailed") {
|
|
513
532
|
skipped += 1;
|
|
514
|
-
cases.push({
|
|
515
|
-
classname,
|
|
516
|
-
name,
|
|
517
|
-
...filePathFields,
|
|
518
|
-
durationMs,
|
|
519
|
-
status: "skipped",
|
|
520
|
-
...(stdout ? { stdout } : {}),
|
|
521
|
-
...(stderr ? { stderr } : {}),
|
|
522
|
-
});
|
|
533
|
+
cases.push({ ...baseCase, status: "skipped", ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}) });
|
|
523
534
|
}
|
|
524
535
|
else {
|
|
525
|
-
// Unknown outcome label: treat as error to stay fail-safe.
|
|
526
536
|
errors += 1;
|
|
527
537
|
const message = `unexpected pytest-html result label: ${record.result ?? "(empty)"}`;
|
|
528
538
|
const summary = truncate(message);
|
|
529
|
-
failures.push({ classname, name, message: summary, kind: "error" });
|
|
530
|
-
cases.push({
|
|
531
|
-
classname,
|
|
532
|
-
name,
|
|
533
|
-
...filePathFields,
|
|
534
|
-
durationMs,
|
|
535
|
-
status: "error",
|
|
536
|
-
message: summary,
|
|
537
|
-
details: decodedCapturedLog || summary,
|
|
538
|
-
...(stdout ? { stdout } : {}),
|
|
539
|
-
...(stderr ? { stderr } : {}),
|
|
540
|
-
});
|
|
539
|
+
failures.push({ classname, name, message: summary, kind: "error", nodeId: canonicalNodeId, phase, ...identity });
|
|
540
|
+
cases.push({ ...baseCase, status: "error", message: summary, details: decodedCapturedLog || summary, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}) });
|
|
541
541
|
}
|
|
542
542
|
}
|
|
543
543
|
const tests = cases.length;
|
|
@@ -666,6 +666,12 @@ export function deriveBackendTestResult(input) {
|
|
|
666
666
|
failed: 0,
|
|
667
667
|
error: 0,
|
|
668
668
|
skipped: 0,
|
|
669
|
+
collectedItemCount: 0,
|
|
670
|
+
setupStartedCount: 0,
|
|
671
|
+
businessTestBodyExecutedCount: 0,
|
|
672
|
+
setupErrorCount: 0,
|
|
673
|
+
testFailureCount: 0,
|
|
674
|
+
teardownErrorCount: 0,
|
|
669
675
|
junit: emptyJunitMeta,
|
|
670
676
|
commandSummary: input.commandSummary,
|
|
671
677
|
failures: [],
|
|
@@ -682,13 +688,11 @@ export function deriveBackendTestResult(input) {
|
|
|
682
688
|
}
|
|
683
689
|
const sha256 = createHash("sha256").update(input.junitXml).digest("hex");
|
|
684
690
|
const exit = input.pytestExitCode;
|
|
691
|
+
const executionCounts = parsedExecutionCounts(parsed);
|
|
685
692
|
let executionStatus = "completed";
|
|
686
693
|
let collectionStatus = "ok";
|
|
687
694
|
let outcome = "passed";
|
|
688
|
-
// Collection-heavy signals: pytest exit 2 is common for collection errors;
|
|
689
|
-
// also when error cases exist with zero/low completed tests.
|
|
690
695
|
const looksLikeCollection = exit === 2 ||
|
|
691
|
-
(parsed.errors > 0 && parsed.passed + parsed.failed === 0) ||
|
|
692
696
|
parsed.failures.some((f) => f.kind === "error" &&
|
|
693
697
|
/collect|import|syntax/i.test(`${f.name} ${f.message}`));
|
|
694
698
|
if (looksLikeCollection && (parsed.errors > 0 || exit >= 2)) {
|
|
@@ -696,6 +700,16 @@ export function deriveBackendTestResult(input) {
|
|
|
696
700
|
collectionStatus = "error";
|
|
697
701
|
outcome = "collection-error";
|
|
698
702
|
}
|
|
703
|
+
else if (executionCounts.setupErrorCount > 0) {
|
|
704
|
+
executionStatus = "setup-error";
|
|
705
|
+
collectionStatus = "ok";
|
|
706
|
+
outcome = "setup-error";
|
|
707
|
+
}
|
|
708
|
+
else if (executionCounts.teardownErrorCount > 0) {
|
|
709
|
+
executionStatus = "teardown-error";
|
|
710
|
+
collectionStatus = "ok";
|
|
711
|
+
outcome = "teardown-error";
|
|
712
|
+
}
|
|
699
713
|
else if (exit >= 2 && parsed.failed === 0 && parsed.errors === 0) {
|
|
700
714
|
executionStatus = "command-error";
|
|
701
715
|
collectionStatus = "unknown";
|
|
@@ -726,6 +740,7 @@ export function deriveBackendTestResult(input) {
|
|
|
726
740
|
failed: parsed.failed,
|
|
727
741
|
error: parsed.errors,
|
|
728
742
|
skipped: parsed.skipped,
|
|
743
|
+
...executionCounts,
|
|
729
744
|
durationMs: parsed.durationMs,
|
|
730
745
|
junit: {
|
|
731
746
|
relativePath: input.junitRelativePath,
|
|
@@ -737,6 +752,10 @@ export function deriveBackendTestResult(input) {
|
|
|
737
752
|
name: f.name || "unknown",
|
|
738
753
|
message: truncate(f.message || "failure"),
|
|
739
754
|
kind: f.kind,
|
|
755
|
+
...(f.nodeId ? { nodeId: f.nodeId } : {}),
|
|
756
|
+
...(f.caseId ? { caseId: f.caseId } : {}),
|
|
757
|
+
...(f.tpId ? { tpId: f.tpId } : {}),
|
|
758
|
+
...(f.phase ? { phase: f.phase } : {}),
|
|
740
759
|
})),
|
|
741
760
|
outcome,
|
|
742
761
|
};
|
|
@@ -759,15 +778,19 @@ export function classifyCategoryHints(result) {
|
|
|
759
778
|
// Single observation cannot prove flakiness.
|
|
760
779
|
forbidden.add("FlakyTest");
|
|
761
780
|
if (result.executionStatus === "collection-error" ||
|
|
781
|
+
result.executionStatus === "setup-error" ||
|
|
782
|
+
result.executionStatus === "teardown-error" ||
|
|
762
783
|
result.executionStatus === "command-error" ||
|
|
763
784
|
result.executionStatus === "report-error" ||
|
|
764
785
|
result.outcome === "collection-error" ||
|
|
786
|
+
result.outcome === "setup-error" ||
|
|
787
|
+
result.outcome === "teardown-error" ||
|
|
765
788
|
result.outcome === "command-error" ||
|
|
766
789
|
result.outcome === "report-error") {
|
|
767
790
|
forbidden.add("ProductBug");
|
|
768
791
|
suggested.add("EnvFailure");
|
|
769
792
|
suggested.add("Unknown");
|
|
770
|
-
if (
|
|
793
|
+
if (["collection-error", "setup-error", "teardown-error"].includes(result.executionStatus)) {
|
|
771
794
|
suggested.add("TestBug");
|
|
772
795
|
}
|
|
773
796
|
return {
|
|
@@ -865,11 +888,11 @@ export async function materializeBackendTestResultFromPytestHtml(input) {
|
|
|
865
888
|
}
|
|
866
889
|
const sha256 = createHash("sha256").update(html).digest("hex");
|
|
867
890
|
const exit = input.pytestExitCode;
|
|
891
|
+
const executionCounts = parsedExecutionCounts(parsed);
|
|
868
892
|
let executionStatus = "completed";
|
|
869
893
|
let collectionStatus = "ok";
|
|
870
894
|
let outcome = "passed";
|
|
871
895
|
const looksLikeCollection = exit === 2 ||
|
|
872
|
-
(parsed.errors > 0 && parsed.passed + parsed.failed === 0) ||
|
|
873
896
|
parsed.failures.some((f) => f.kind === "error" &&
|
|
874
897
|
/collect|import|syntax/i.test(`${f.name} ${f.message}`));
|
|
875
898
|
if (looksLikeCollection && (parsed.errors > 0 || exit >= 2)) {
|
|
@@ -877,6 +900,16 @@ export async function materializeBackendTestResultFromPytestHtml(input) {
|
|
|
877
900
|
collectionStatus = "error";
|
|
878
901
|
outcome = "collection-error";
|
|
879
902
|
}
|
|
903
|
+
else if (executionCounts.setupErrorCount > 0) {
|
|
904
|
+
executionStatus = "setup-error";
|
|
905
|
+
collectionStatus = "ok";
|
|
906
|
+
outcome = "setup-error";
|
|
907
|
+
}
|
|
908
|
+
else if (executionCounts.teardownErrorCount > 0) {
|
|
909
|
+
executionStatus = "teardown-error";
|
|
910
|
+
collectionStatus = "ok";
|
|
911
|
+
outcome = "teardown-error";
|
|
912
|
+
}
|
|
880
913
|
else if (exit >= 2 && parsed.failed === 0 && parsed.errors === 0) {
|
|
881
914
|
executionStatus = "command-error";
|
|
882
915
|
collectionStatus = "unknown";
|
|
@@ -910,6 +943,7 @@ export async function materializeBackendTestResultFromPytestHtml(input) {
|
|
|
910
943
|
failed: parsed.failed,
|
|
911
944
|
error: parsed.errors,
|
|
912
945
|
skipped: parsed.skipped,
|
|
946
|
+
...executionCounts,
|
|
913
947
|
durationMs: parsed.durationMs,
|
|
914
948
|
junit: {
|
|
915
949
|
relativePath: htmlRelativePath,
|
|
@@ -921,6 +955,10 @@ export async function materializeBackendTestResultFromPytestHtml(input) {
|
|
|
921
955
|
name: f.name || "unknown",
|
|
922
956
|
message: truncate(f.message || "failure"),
|
|
923
957
|
kind: f.kind,
|
|
958
|
+
...(f.nodeId ? { nodeId: f.nodeId } : {}),
|
|
959
|
+
...(f.caseId ? { caseId: f.caseId } : {}),
|
|
960
|
+
...(f.tpId ? { tpId: f.tpId } : {}),
|
|
961
|
+
...(f.phase ? { phase: f.phase } : {}),
|
|
924
962
|
})),
|
|
925
963
|
outcome,
|
|
926
964
|
});
|