miaoda-game-devkit 0.6.2 → 0.6.3

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.
@@ -13,14 +13,112 @@ import { render } from "@testing-library/react";
13
13
  import userEvent from "@testing-library/user-event";
14
14
  import { test } from "vitest";
15
15
 
16
+ // src/react/react-error-diagnostics.ts
17
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
18
+ function truncate(value) {
19
+ const trimmed = value.trim();
20
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
21
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
22
+ }
23
+ function safeJson(value) {
24
+ const seen = /* @__PURE__ */ new WeakSet();
25
+ try {
26
+ return JSON.stringify(value, (_key, nested) => {
27
+ if (typeof nested === "bigint") return `${nested}n`;
28
+ if (typeof nested === "function") {
29
+ return `Function<${nested.name || "anonymous"}>`;
30
+ }
31
+ if (typeof nested === "symbol") return nested.toString();
32
+ if (nested && typeof nested === "object") {
33
+ if (seen.has(nested)) return "[Circular]";
34
+ seen.add(nested);
35
+ }
36
+ return nested;
37
+ });
38
+ } catch {
39
+ return void 0;
40
+ }
41
+ }
42
+ function collectEntries(value, fallbackCode, seen) {
43
+ if (typeof value === "string") {
44
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
45
+ }
46
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
47
+ return [{ code: fallbackCode, message: String(value) }];
48
+ }
49
+ if (typeof value === "function") {
50
+ return [
51
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
52
+ ];
53
+ }
54
+ if (seen.has(value)) return [];
55
+ seen.add(value);
56
+ if (Array.isArray(value)) {
57
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
58
+ }
59
+ const record = value;
60
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
61
+ const entries = [];
62
+ if (typeof record.message === "string" && record.message.trim()) {
63
+ entries.push({ code, message: truncate(record.message) });
64
+ }
65
+ if (record.cause !== void 0) {
66
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
67
+ }
68
+ if (Array.isArray(record.errors)) {
69
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
70
+ }
71
+ if (entries.length > 0) return entries;
72
+ if (typeof record.stack === "string" && record.stack.trim()) {
73
+ return [{ code, message: truncate(record.stack) }];
74
+ }
75
+ const json = safeJson(value);
76
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
77
+ }
78
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
79
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
80
+ const keys = /* @__PURE__ */ new Set();
81
+ return entries.filter((entry) => {
82
+ const key = `${entry.code}\0${entry.message}`;
83
+ if (keys.has(key)) return false;
84
+ keys.add(key);
85
+ return true;
86
+ });
87
+ }
88
+ function createFailureDiagnostic(source, value) {
89
+ return { source, entries: extractFailureEntries(value) };
90
+ }
91
+ function appendCurrentAttemptFailures(current, runnerValue) {
92
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
93
+ if (!current || current.entries.length === 0) return runner;
94
+ const primary = current.entries[0];
95
+ const currentStart = runner.entries.findIndex(
96
+ (entry) => entry.code === primary.code && entry.message === primary.message
97
+ );
98
+ if (currentStart < 0) return current;
99
+ return {
100
+ source: current.source,
101
+ entries: extractFailureEntries([
102
+ ...current.entries,
103
+ ...runner.entries.slice(currentStart + 1)
104
+ ])
105
+ };
106
+ }
107
+ function codedError(code, message) {
108
+ const error = new Error(message);
109
+ error.code = code;
110
+ return error;
111
+ }
112
+
16
113
  // src/react/react-playthrough-core.ts
17
114
  import { act } from "@testing-library/react";
18
115
  function throwIfAborted(signal) {
19
116
  if (!signal?.aborted) return;
20
117
  if (signal.reason instanceof Error) throw signal.reason;
21
- throw new Error("Playthrough advancement was cancelled.", {
22
- cause: signal.reason
23
- });
118
+ throw codedError(
119
+ "PLAYTHROUGH_CANCELLED",
120
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
121
+ );
24
122
  }
25
123
  function formatDiagnostics(read) {
26
124
  if (!read) return void 0;
@@ -36,7 +134,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
36
134
  if (waiverReason === void 0) return void 0;
37
135
  const reason = waiverReason.trim();
38
136
  if (reason.length < 20) {
39
- throw new Error(
137
+ throw codedError(
138
+ "INVALID_PLAYTHROUGH_WAIVER",
40
139
  "playthroughTest.skip reason must contain at least 20 characters."
41
140
  );
42
141
  }
@@ -45,7 +144,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
45
144
  async function runBoundedUntil(condition, options = {}) {
46
145
  const maxSteps = options.maxSteps ?? 120;
47
146
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
48
- throw new RangeError(
147
+ throw codedError(
148
+ "INVALID_STEP_BOUND",
49
149
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
50
150
  );
51
151
  }
@@ -62,7 +162,8 @@ async function runBoundedUntil(condition, options = {}) {
62
162
  const diagnostics = formatDiagnostics(options.diagnostics);
63
163
  const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
64
164
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
65
- throw new Error(
165
+ throw codedError(
166
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
66
167
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
67
168
  );
68
169
  }
@@ -115,14 +216,18 @@ function sampleObservedState(observe, stage) {
115
216
  try {
116
217
  value = observe();
117
218
  } catch (error) {
118
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
219
+ throw codedError(
220
+ "OBSERVE_FAILED",
221
+ `observe() threw at ${stage}: ${String(error)}`
222
+ );
119
223
  }
120
224
  try {
121
225
  const fingerprint = JSON.stringify(value);
122
226
  if (fingerprint === void 0) throw new Error("unsupported value");
123
227
  return { fingerprint, formatted: formatState(fingerprint) };
124
228
  } catch {
125
- throw new Error(
229
+ throw codedError(
230
+ "OBSERVE_NOT_SERIALIZABLE",
126
231
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
127
232
  );
128
233
  }
@@ -135,7 +240,7 @@ function createEvidence() {
135
240
  return { domInputEvents: 0, stages: [], verified: false };
136
241
  }
137
242
  function createMetadata(waiverReason) {
138
- return { version: 4, waiverReason, evidence: createEvidence() };
243
+ return { version: 5, waiverReason, evidence: createEvidence() };
139
244
  }
140
245
  function stageLabel(kind, name) {
141
246
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -171,6 +276,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
171
276
  async ({ annotate, expect, onTestFailed, signal }) => {
172
277
  metadata.evidence = createEvidence();
173
278
  metadata.trace = void 0;
279
+ metadata.failure = void 0;
174
280
  const evidence = metadata.evidence;
175
281
  let entered = false;
176
282
  let finished = false;
@@ -201,8 +307,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
201
307
  return "stages=none";
202
308
  }
203
309
  };
204
- onTestFailed(() => {
310
+ onTestFailed(({ task }) => {
205
311
  metadata.trace ??= captureFailureTrace();
312
+ metadata.failure = appendCurrentAttemptFailures(
313
+ metadata.failure,
314
+ task.result?.errors ?? []
315
+ );
206
316
  });
207
317
  for (const event of INPUT_EVENTS) {
208
318
  document.addEventListener(event, recordInput, true);
@@ -212,7 +322,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
212
322
  try {
213
323
  const view = render(element);
214
324
  if (view.container.childNodes.length === 0) {
215
- throw new Error(
325
+ throw codedError(
326
+ "PRODUCTION_ENTRY_NOT_RENDERED",
216
327
  "playthroughTest must render the production game entry."
217
328
  );
218
329
  }
@@ -241,17 +352,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
241
352
  const executeStage = async (name, kind, stage) => {
242
353
  const normalizedName = name.trim();
243
354
  if (normalizedName.length === 0) {
244
- throw new Error("playthrough stage names must be non-empty strings.");
355
+ throw codedError(
356
+ "INVALID_STAGE_NAME",
357
+ "playthrough stage names must be non-empty strings."
358
+ );
245
359
  }
246
360
  if (evidence.stages.some(
247
361
  (completed) => completed.name === normalizedName
248
362
  )) {
249
- throw new Error(
363
+ throw codedError(
364
+ "DUPLICATE_STAGE_NAME",
250
365
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
251
366
  );
252
367
  }
253
368
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
254
- throw new Error(
369
+ throw codedError(
370
+ "RESERVED_STAGE_NAME",
255
371
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
256
372
  );
257
373
  }
@@ -259,12 +375,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
259
375
  activeStage = { name: normalizedName, kind, before };
260
376
  stepTrace = { bound: stage.maxSteps ?? 120 };
261
377
  if (stage.step && !playthroughOptions?.observe) {
262
- throw new Error(
378
+ throw codedError(
379
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
263
380
  `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
264
381
  );
265
382
  }
266
383
  if (stage.until()) {
267
- throw new Error(
384
+ throw codedError(
385
+ "STAGE_OUTCOME_ALREADY_REACHED",
268
386
  `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
269
387
  );
270
388
  }
@@ -278,12 +396,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
278
396
  acceptingStageInput = false;
279
397
  }
280
398
  if (evidence.domInputEvents === inputsBefore) {
281
- throw new Error(
399
+ throw codedError(
400
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
282
401
  `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
283
402
  );
284
403
  }
285
404
  if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
286
- throw new Error(
405
+ throw codedError(
406
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
287
407
  `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
288
408
  );
289
409
  }
@@ -302,7 +422,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
302
422
  });
303
423
  stepTrace = { bound: stepBound, completed: steps };
304
424
  if (!stage.act && advancedSteps === 0) {
305
- throw new Error(
425
+ throw codedError(
426
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
306
427
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
307
428
  );
308
429
  }
@@ -310,14 +431,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
310
431
  await stage.assert({ expect, user, view });
311
432
  const assertions = expect.getState().assertionCalls - assertionsBefore;
312
433
  if (assertions === 0) {
313
- throw new Error(
434
+ throw codedError(
435
+ "STAGE_ASSERTION_MISSING",
314
436
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
315
437
  );
316
438
  }
317
439
  const after = sampleState(`after ${normalizedName}`);
318
440
  if (after.fingerprint === before.fingerprint) {
319
441
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
320
- throw new Error(
442
+ throw codedError(
443
+ "STAGE_STATE_UNCHANGED",
321
444
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
322
445
  );
323
446
  }
@@ -339,27 +462,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
339
462
  user,
340
463
  async enter(stage) {
341
464
  if (entered) {
342
- throw new Error("enter may only be called once.");
465
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
343
466
  }
344
467
  if (evidence.stages.length > 0) {
345
- throw new Error("enter must be the first playthrough stage.");
468
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
346
469
  }
347
470
  await executeStage("entered", "entered", stage);
348
471
  entered = true;
349
472
  },
350
473
  async milestone(name, stage) {
351
474
  if (!entered) {
352
- throw new Error("milestone must follow enter.");
475
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
353
476
  }
354
477
  if (finished) {
355
- throw new Error("milestone cannot run after finish.");
478
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
356
479
  }
357
480
  await executeStage(name, "milestone", stage);
358
481
  },
359
482
  async finish(name, stage) {
360
- if (!entered) throw new Error("finish must follow enter.");
483
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
361
484
  if (finished) {
362
- throw new Error("finish may only be called once.");
485
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
363
486
  }
364
487
  await executeStage(name, stage.kind, stage);
365
488
  finished = true;
@@ -368,19 +491,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
368
491
  const milestones = evidence.stages.filter(
369
492
  (stage) => stage.kind === "milestone"
370
493
  );
371
- if (!entered) throw new Error("playthroughTest must call enter once.");
494
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
372
495
  if (milestones.length < MIN_MILESTONES) {
373
- throw new Error(
496
+ throw codedError(
497
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
374
498
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
375
499
  );
376
500
  }
377
501
  if (!finished) {
378
- throw new Error(
502
+ throw codedError(
503
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
379
504
  'playthroughTest must call finish with kind "progress" or "terminal".'
380
505
  );
381
506
  }
382
507
  if (evidence.stages.length < MIN_STAGES) {
383
- throw new Error(
508
+ throw codedError(
509
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
384
510
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
385
511
  );
386
512
  }
@@ -388,6 +514,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
388
514
  } catch (error) {
389
515
  const trace = captureFailureTrace();
390
516
  metadata.trace = trace;
517
+ metadata.failure = createFailureDiagnostic("playthrough", error);
391
518
  try {
392
519
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
393
520
  } catch {
@@ -470,7 +597,14 @@ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the product
470
597
  function isMetadata(value) {
471
598
  if (!value || typeof value !== "object") return false;
472
599
  const metadata = value;
473
- if (metadata.version !== 4) return false;
600
+ if (metadata.version !== 5) return false;
601
+ if (metadata.failure !== void 0) {
602
+ if (!metadata.failure || typeof metadata.failure !== "object" || typeof metadata.failure.source !== "string" || !Array.isArray(metadata.failure.entries) || !metadata.failure.entries.every(
603
+ (entry) => Boolean(entry) && typeof entry === "object" && typeof entry.code === "string" && typeof entry.message === "string"
604
+ )) {
605
+ return false;
606
+ }
607
+ }
474
608
  const evidence = metadata.evidence;
475
609
  if (!evidence || typeof evidence !== "object") return false;
476
610
  return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
@@ -485,10 +619,72 @@ function toAuditInput(test2) {
485
619
  metadata: isMetadata(metadata) ? metadata : void 0
486
620
  };
487
621
  }
622
+ function findPendingProductTests(modules, projectRoot) {
623
+ return modules.flatMap((module) => {
624
+ const file = relative(projectRoot, module.moduleId).replaceAll("\\", "/");
625
+ if (file.split("/").includes("examples")) return [];
626
+ const tests = [...module.children.allTests()];
627
+ const pending = tests.filter((test2) => {
628
+ const mode = test2.options.mode;
629
+ if (mode !== "todo" && mode !== "skip") return false;
630
+ const metadata = test2.meta().reactPlaythrough;
631
+ const approvedWaiver = mode === "skip" && isMetadata(metadata) && Boolean(metadata.waiverReason);
632
+ return !approvedWaiver;
633
+ });
634
+ const fileOnlyContainsPendingTests = tests.length > 0 && pending.length === tests.length;
635
+ return pending.map((test2) => ({
636
+ file,
637
+ test: test2.fullName,
638
+ mode: test2.options.mode,
639
+ fileOnlyContainsPendingTests
640
+ }));
641
+ });
642
+ }
643
+ function formatPendingProductTestReport(pending) {
644
+ if (pending.length === 0) return void 0;
645
+ const lines = [
646
+ "REACT_FOCUSED_TESTS: FAILED",
647
+ "CAUSE_CODE: TODO_OR_SKIP_TESTS",
648
+ "CAUSE: Product tests still contain explicit todo/skip cases."
649
+ ];
650
+ const reportedIncompleteFiles = /* @__PURE__ */ new Set();
651
+ for (const item of pending) {
652
+ if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
653
+ lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
654
+ lines.push(
655
+ `FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
656
+ );
657
+ reportedIncompleteFiles.add(item.file);
658
+ }
659
+ lines.push(`FILE: ${item.file}`);
660
+ lines.push(`TEST: ${item.test}`);
661
+ lines.push(`MODE: ${item.mode}`);
662
+ }
663
+ lines.push(
664
+ "NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
665
+ );
666
+ return `
667
+ ${lines.join("\n")}`;
668
+ }
488
669
  function firstLine(value) {
489
670
  if (typeof value !== "string") return void 0;
490
671
  return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
491
672
  }
673
+ function selectReactFailure(values, errorRecordCount = values.length) {
674
+ const entries = extractFailureEntries(values);
675
+ if (entries.length === 0) {
676
+ return {
677
+ code: "MISSING_FAILURE_DETAILS",
678
+ cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
679
+ rawCause: "",
680
+ related: []
681
+ };
682
+ }
683
+ const primary = entries[0];
684
+ const rawCause = primary.message;
685
+ const cause = firstLine(rawCause) ?? rawCause;
686
+ return { code: primary.code, cause, rawCause, related: entries.slice(1) };
687
+ }
492
688
  function failureHint(value) {
493
689
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
494
690
  (match) => match[1]
@@ -522,30 +718,45 @@ function truncateReporterLine(value, limit) {
522
718
  }
523
719
  function toModuleResult(module, projectRoot) {
524
720
  const tests = [...module.children.allTests()];
525
- const moduleErrors = module.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
721
+ const moduleFailure = selectReactFailure(module.errors(), module.errors().length);
722
+ const moduleErrors = extractFailureEntries(module.errors()).map(
723
+ (entry) => firstLine(entry.message) ?? entry.message
724
+ );
526
725
  const errors = [
527
726
  ...moduleErrors,
528
727
  ...tests.flatMap(
529
- (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
728
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
729
+ (entry) => firstLine(entry.message) ?? entry.message
730
+ )
530
731
  )
531
- ].filter((message) => Boolean(message));
732
+ ];
532
733
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
533
- const raw = test2.result().errors?.at(-1)?.message ?? module.errors()[0]?.message ?? "Unknown failure";
734
+ const metadata = test2.meta().reactPlaythrough;
735
+ const testErrors = test2.result().errors ?? [];
736
+ const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
737
+ const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
738
+ const selected = selectReactFailure(
739
+ [...attemptErrors, ...module.errors()],
740
+ testErrors.length + module.errors().length
741
+ );
534
742
  return {
535
743
  test: test2.fullName,
536
- cause: firstLine(raw) ?? "Unknown failure",
537
- location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
538
- hint: failureHint(raw),
744
+ causeCode: selected.code,
745
+ cause: selected.cause,
746
+ related: selected.related,
747
+ location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
748
+ hint: failureHint(selected.rawCause),
539
749
  trace: failureTrace(test2)
540
750
  };
541
751
  });
542
752
  if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
543
- const raw = module.errors()[0]?.message ?? "Module failed to load";
544
753
  failures.push({
545
754
  test: "<collection>",
546
- cause: firstLine(raw) ?? "Module failed to load",
547
- location: errorLocation(raw),
548
- hint: failureHint(raw)
755
+ causeCode: moduleFailure.code,
756
+ cause: moduleFailure.cause,
757
+ related: moduleFailure.related,
758
+ location: errorLocation(moduleFailure.rawCause),
759
+ hint: failureHint(moduleFailure.rawCause)
549
760
  });
550
761
  }
551
762
  return {
@@ -553,6 +764,8 @@ function toModuleResult(module, projectRoot) {
553
764
  state: module.state(),
554
765
  errors,
555
766
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
767
+ primaryCauseCode: failures[0]?.causeCode,
768
+ relatedErrors: failures[0]?.related,
556
769
  tests: tests.map(toAuditInput),
557
770
  failures
558
771
  };
@@ -569,7 +782,14 @@ function formatReactFailureSummary(modules) {
569
782
  for (const [index, failure] of failures.entries()) {
570
783
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
571
784
  lines.push(`TEST: ${failure.test}`);
785
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
572
786
  lines.push(`CAUSE: ${failure.cause}`);
787
+ for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
788
+ lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
789
+ lines.push(
790
+ `RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
791
+ );
792
+ }
573
793
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
574
794
  if (failure.location) lines.push(`AT: ${failure.location}`);
575
795
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
@@ -577,32 +797,36 @@ function formatReactFailureSummary(modules) {
577
797
  lines.push("TEST_RESULT: FAIL");
578
798
  return lines;
579
799
  }
580
- function repairGuidance(cause) {
581
- if (/snapshot\(\) returned the same reference/i.test(cause)) {
582
- return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
583
- }
584
- if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
585
- return "This stage advances production time or frames, so DOM text is not a sufficient state boundary. Keep the intended outcome, inject ManualGameClock through the production <App /> factory, and make observe read the same production Controller through Telemetry. Do not remove step or replace the outcome with an immediate UI transition.";
586
- }
587
- if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
588
- return "The player input reached the production Canvas, but JSDOM cannot verify its pixels. Keep the real Canvas input and make observe read the authoritative state from the same production Controller rendered by <App /> through Telemetry. Do not replace Canvas input with a test-only Controller command or button-label assertion.";
589
- }
590
- if (/until condition must be false before its driver runs/i.test(cause)) {
591
- return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
592
- }
593
- if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
594
- return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe that result directly. Canvas or Controller games should make observe read the same production Controller that React renders. Do not substitute arbitrary labels, button visibility, navigation, or another weaker state change merely to produce a different fingerprint.";
595
- }
596
- if (/No step callback was provided/i.test(cause)) {
597
- return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
800
+ function repairGuidance(code) {
801
+ switch (code) {
802
+ case "GAME_SNAPSHOT_REFERENCE_REUSED":
803
+ return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
804
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP":
805
+ return "This stage advances production time or frames, so DOM text is not a sufficient state boundary. Keep the intended outcome, inject ManualGameClock through the production <App /> factory, and make observe read the same production Controller through Telemetry. Do not remove step or replace the outcome with an immediate UI transition.";
806
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS":
807
+ return "The player input reached the production Canvas, but JSDOM cannot verify its pixels. Keep the real Canvas input and make observe read the authoritative state from the same production Controller rendered by <App /> through Telemetry. Do not replace Canvas input with a test-only Controller command or button-label assertion.";
808
+ case "STAGE_OUTCOME_ALREADY_REACHED":
809
+ return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
810
+ case "STAGE_STATE_UNCHANGED":
811
+ return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe the same production Controller rendered by <App />. Do not substitute arbitrary labels or a weaker state change.";
812
+ case "PLAYTHROUGH_BOUND_EXHAUSTED":
813
+ return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
814
+ case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
815
+ return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
816
+ case "INVALID_STAGE_ORDER":
817
+ case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
818
+ case "INVALID_STAGE_NAME":
819
+ case "DUPLICATE_STAGE_NAME":
820
+ case "RESERVED_STAGE_NAME":
821
+ case "PRODUCTION_INPUT_NOT_DISPATCHED":
822
+ case "AUTONOMOUS_STAGE_NOT_ADVANCED":
823
+ case "STAGE_ASSERTION_MISSING":
824
+ return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
825
+ case "MISSING_FAILURE_DETAILS":
826
+ return "Vitest reported a failed task without a readable serialized error. Inspect the RELATED records and rerun the focused file with the verbose reporter if no details are present.";
827
+ default:
828
+ return "Fix the first reported CAUSE, then rerun the same test. RELATED entries preserve the remaining Vitest errors in their original order.";
598
829
  }
599
- if (/outcome was not reached within \d+ steps/i.test(cause)) {
600
- return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
601
- }
602
- if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
603
- return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
604
- }
605
- return "Start from the production entry and describe the real game as enter, named milestones, and finish. Enter must act through production input; later stages may act or step. Every stage waits for and asserts a new player-visible or authoritative result. Do not jump to an internal level or mutate gameplay state.";
606
830
  }
607
831
  function assessReactPlaythroughReport(input) {
608
832
  const base = { file: input.expectedFile };
@@ -618,6 +842,7 @@ function assessReactPlaythroughReport(input) {
618
842
  return {
619
843
  ...base,
620
844
  status: "FAILED",
845
+ causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
621
846
  cause: "The required production playthrough test file does not exist.",
622
847
  next: "Create the file and render <App />. Compose one real-input enter stage, at least three named gameplay milestones, and one finish stage. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result.",
623
848
  failsRun: true
@@ -641,6 +866,7 @@ function assessReactPlaythroughReport(input) {
641
866
  return {
642
867
  ...base,
643
868
  status: "NOT_RUN",
869
+ causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
644
870
  cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
645
871
  next: "Fix the first Vitest syntax, import, environment, or collection error shown above, then run pnpm test again. Do not use skip to hide a load failure.",
646
872
  failsRun: true
@@ -653,8 +879,12 @@ function assessReactPlaythroughReport(input) {
653
879
  return {
654
880
  ...base,
655
881
  status: "FAILED",
882
+ causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
656
883
  cause,
657
- next: repairGuidance(cause),
884
+ related: productionModule.relatedErrors,
885
+ next: repairGuidance(
886
+ productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
887
+ ),
658
888
  failsRun: true
659
889
  };
660
890
  }
@@ -670,7 +900,14 @@ function assessReactPlaythroughReport(input) {
670
900
  }
671
901
  function formatReactPlaythroughReport(report) {
672
902
  const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
903
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
673
904
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
905
+ for (const [index, related] of (report.related ?? []).entries()) {
906
+ lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
907
+ lines.push(
908
+ `RELATED_${index + 1}: ${firstLine(related.message) ?? related.message}`
909
+ );
910
+ }
674
911
  if (report.waiverReasons?.length) {
675
912
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
676
913
  }
@@ -703,6 +940,10 @@ var ReactPlaythroughReporter = class {
703
940
  }
704
941
  /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
705
942
  onTestRunEnd(testModules, unhandledErrors) {
943
+ const pendingProductTests = findPendingProductTests(
944
+ testModules,
945
+ this.projectRoot
946
+ );
706
947
  const report = assessReactPlaythroughReport({
707
948
  expectedFile: this.expectedFile,
708
949
  expectedFileExists: existsSync(this.expectedModuleId),
@@ -711,7 +952,9 @@ var ReactPlaythroughReporter = class {
711
952
  modules: testModules.map(
712
953
  (module) => toModuleResult(module, this.projectRoot)
713
954
  ),
714
- unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
955
+ unhandledErrors: extractFailureEntries(unhandledErrors).map(
956
+ (entry) => firstLine(entry.message) ?? entry.message
957
+ )
715
958
  });
716
959
  const output = formatReactPlaythroughReport(report);
717
960
  if (report.failsRun) {
@@ -722,10 +965,15 @@ var ReactPlaythroughReporter = class {
722
965
  } else {
723
966
  console.log(output);
724
967
  }
968
+ const pendingOutput = formatPendingProductTestReport(pendingProductTests);
969
+ if (pendingOutput) {
970
+ console.error(pendingOutput);
971
+ process.exitCode = 1;
972
+ }
725
973
  const summary = formatReactFailureSummary(
726
974
  testModules.map((module) => toModuleResult(module, this.projectRoot))
727
975
  );
728
- if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
976
+ if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
729
977
  summary[0] = "TEST_RESULT: FAIL";
730
978
  }
731
979
  console.log(`