@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.
@@ -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-v2"),
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 attempted collection, exit 0, complete mapped scripts and bound assets" });
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
- if (input.exitCode === 0) {
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-v2",
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-v2",
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-v2",
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 testId = record.testId ?? nodeId;
455
- const { classname, name, filePath } = splitPytestNodeId(testId);
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 (result.executionStatus === "collection-error") {
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
  });