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.
@@ -47,14 +47,112 @@ var import_react2 = require("@testing-library/react");
47
47
  var import_user_event = __toESM(require("@testing-library/user-event"));
48
48
  var import_vitest = require("vitest");
49
49
 
50
+ // src/react/react-error-diagnostics.ts
51
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
52
+ function truncate(value) {
53
+ const trimmed = value.trim();
54
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
55
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
56
+ }
57
+ function safeJson(value) {
58
+ const seen = /* @__PURE__ */ new WeakSet();
59
+ try {
60
+ return JSON.stringify(value, (_key, nested) => {
61
+ if (typeof nested === "bigint") return `${nested}n`;
62
+ if (typeof nested === "function") {
63
+ return `Function<${nested.name || "anonymous"}>`;
64
+ }
65
+ if (typeof nested === "symbol") return nested.toString();
66
+ if (nested && typeof nested === "object") {
67
+ if (seen.has(nested)) return "[Circular]";
68
+ seen.add(nested);
69
+ }
70
+ return nested;
71
+ });
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
76
+ function collectEntries(value, fallbackCode, seen) {
77
+ if (typeof value === "string") {
78
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
79
+ }
80
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
81
+ return [{ code: fallbackCode, message: String(value) }];
82
+ }
83
+ if (typeof value === "function") {
84
+ return [
85
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
86
+ ];
87
+ }
88
+ if (seen.has(value)) return [];
89
+ seen.add(value);
90
+ if (Array.isArray(value)) {
91
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
92
+ }
93
+ const record = value;
94
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
95
+ const entries = [];
96
+ if (typeof record.message === "string" && record.message.trim()) {
97
+ entries.push({ code, message: truncate(record.message) });
98
+ }
99
+ if (record.cause !== void 0) {
100
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
101
+ }
102
+ if (Array.isArray(record.errors)) {
103
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
104
+ }
105
+ if (entries.length > 0) return entries;
106
+ if (typeof record.stack === "string" && record.stack.trim()) {
107
+ return [{ code, message: truncate(record.stack) }];
108
+ }
109
+ const json = safeJson(value);
110
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
111
+ }
112
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
113
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
114
+ const keys = /* @__PURE__ */ new Set();
115
+ return entries.filter((entry) => {
116
+ const key = `${entry.code}\0${entry.message}`;
117
+ if (keys.has(key)) return false;
118
+ keys.add(key);
119
+ return true;
120
+ });
121
+ }
122
+ function createFailureDiagnostic(source, value) {
123
+ return { source, entries: extractFailureEntries(value) };
124
+ }
125
+ function appendCurrentAttemptFailures(current, runnerValue) {
126
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
127
+ if (!current || current.entries.length === 0) return runner;
128
+ const primary = current.entries[0];
129
+ const currentStart = runner.entries.findIndex(
130
+ (entry) => entry.code === primary.code && entry.message === primary.message
131
+ );
132
+ if (currentStart < 0) return current;
133
+ return {
134
+ source: current.source,
135
+ entries: extractFailureEntries([
136
+ ...current.entries,
137
+ ...runner.entries.slice(currentStart + 1)
138
+ ])
139
+ };
140
+ }
141
+ function codedError(code, message) {
142
+ const error = new Error(message);
143
+ error.code = code;
144
+ return error;
145
+ }
146
+
50
147
  // src/react/react-playthrough-core.ts
51
148
  var import_react = require("@testing-library/react");
52
149
  function throwIfAborted(signal) {
53
150
  if (!signal?.aborted) return;
54
151
  if (signal.reason instanceof Error) throw signal.reason;
55
- throw new Error("Playthrough advancement was cancelled.", {
56
- cause: signal.reason
57
- });
152
+ throw codedError(
153
+ "PLAYTHROUGH_CANCELLED",
154
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
155
+ );
58
156
  }
59
157
  function formatDiagnostics(read) {
60
158
  if (!read) return void 0;
@@ -70,7 +168,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
70
168
  if (waiverReason === void 0) return void 0;
71
169
  const reason = waiverReason.trim();
72
170
  if (reason.length < 20) {
73
- throw new Error(
171
+ throw codedError(
172
+ "INVALID_PLAYTHROUGH_WAIVER",
74
173
  "playthroughTest.skip reason must contain at least 20 characters."
75
174
  );
76
175
  }
@@ -79,7 +178,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
79
178
  async function runBoundedUntil(condition, options = {}) {
80
179
  const maxSteps = options.maxSteps ?? 120;
81
180
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
82
- throw new RangeError(
181
+ throw codedError(
182
+ "INVALID_STEP_BOUND",
83
183
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
84
184
  );
85
185
  }
@@ -96,7 +196,8 @@ async function runBoundedUntil(condition, options = {}) {
96
196
  const diagnostics = formatDiagnostics(options.diagnostics);
97
197
  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().";
98
198
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
99
- throw new Error(
199
+ throw codedError(
200
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
100
201
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
101
202
  );
102
203
  }
@@ -149,14 +250,18 @@ function sampleObservedState(observe, stage) {
149
250
  try {
150
251
  value = observe();
151
252
  } catch (error) {
152
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
253
+ throw codedError(
254
+ "OBSERVE_FAILED",
255
+ `observe() threw at ${stage}: ${String(error)}`
256
+ );
153
257
  }
154
258
  try {
155
259
  const fingerprint = JSON.stringify(value);
156
260
  if (fingerprint === void 0) throw new Error("unsupported value");
157
261
  return { fingerprint, formatted: formatState(fingerprint) };
158
262
  } catch {
159
- throw new Error(
263
+ throw codedError(
264
+ "OBSERVE_NOT_SERIALIZABLE",
160
265
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
161
266
  );
162
267
  }
@@ -169,7 +274,7 @@ function createEvidence() {
169
274
  return { domInputEvents: 0, stages: [], verified: false };
170
275
  }
171
276
  function createMetadata(waiverReason) {
172
- return { version: 4, waiverReason, evidence: createEvidence() };
277
+ return { version: 5, waiverReason, evidence: createEvidence() };
173
278
  }
174
279
  function stageLabel(kind, name) {
175
280
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -205,6 +310,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
205
310
  async ({ annotate, expect, onTestFailed, signal }) => {
206
311
  metadata.evidence = createEvidence();
207
312
  metadata.trace = void 0;
313
+ metadata.failure = void 0;
208
314
  const evidence = metadata.evidence;
209
315
  let entered = false;
210
316
  let finished = false;
@@ -235,8 +341,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
235
341
  return "stages=none";
236
342
  }
237
343
  };
238
- onTestFailed(() => {
344
+ onTestFailed(({ task }) => {
239
345
  metadata.trace ??= captureFailureTrace();
346
+ metadata.failure = appendCurrentAttemptFailures(
347
+ metadata.failure,
348
+ task.result?.errors ?? []
349
+ );
240
350
  });
241
351
  for (const event of INPUT_EVENTS) {
242
352
  document.addEventListener(event, recordInput, true);
@@ -246,7 +356,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
246
356
  try {
247
357
  const view = (0, import_react2.render)(element);
248
358
  if (view.container.childNodes.length === 0) {
249
- throw new Error(
359
+ throw codedError(
360
+ "PRODUCTION_ENTRY_NOT_RENDERED",
250
361
  "playthroughTest must render the production game entry."
251
362
  );
252
363
  }
@@ -275,17 +386,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
275
386
  const executeStage = async (name, kind, stage) => {
276
387
  const normalizedName = name.trim();
277
388
  if (normalizedName.length === 0) {
278
- throw new Error("playthrough stage names must be non-empty strings.");
389
+ throw codedError(
390
+ "INVALID_STAGE_NAME",
391
+ "playthrough stage names must be non-empty strings."
392
+ );
279
393
  }
280
394
  if (evidence.stages.some(
281
395
  (completed) => completed.name === normalizedName
282
396
  )) {
283
- throw new Error(
397
+ throw codedError(
398
+ "DUPLICATE_STAGE_NAME",
284
399
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
285
400
  );
286
401
  }
287
402
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
288
- throw new Error(
403
+ throw codedError(
404
+ "RESERVED_STAGE_NAME",
289
405
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
290
406
  );
291
407
  }
@@ -293,12 +409,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
293
409
  activeStage = { name: normalizedName, kind, before };
294
410
  stepTrace = { bound: stage.maxSteps ?? 120 };
295
411
  if (stage.step && !playthroughOptions?.observe) {
296
- throw new Error(
412
+ throw codedError(
413
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
297
414
  `${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.`
298
415
  );
299
416
  }
300
417
  if (stage.until()) {
301
- throw new Error(
418
+ throw codedError(
419
+ "STAGE_OUTCOME_ALREADY_REACHED",
302
420
  `${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.`
303
421
  );
304
422
  }
@@ -312,12 +430,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
312
430
  acceptingStageInput = false;
313
431
  }
314
432
  if (evidence.domInputEvents === inputsBefore) {
315
- throw new Error(
433
+ throw codedError(
434
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
316
435
  `${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.`
317
436
  );
318
437
  }
319
438
  if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
320
- throw new Error(
439
+ throw codedError(
440
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
321
441
  `${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.`
322
442
  );
323
443
  }
@@ -336,7 +456,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
336
456
  });
337
457
  stepTrace = { bound: stepBound, completed: steps };
338
458
  if (!stage.act && advancedSteps === 0) {
339
- throw new Error(
459
+ throw codedError(
460
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
340
461
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
341
462
  );
342
463
  }
@@ -344,14 +465,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
344
465
  await stage.assert({ expect, user, view });
345
466
  const assertions = expect.getState().assertionCalls - assertionsBefore;
346
467
  if (assertions === 0) {
347
- throw new Error(
468
+ throw codedError(
469
+ "STAGE_ASSERTION_MISSING",
348
470
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
349
471
  );
350
472
  }
351
473
  const after = sampleState(`after ${normalizedName}`);
352
474
  if (after.fingerprint === before.fingerprint) {
353
475
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
354
- throw new Error(
476
+ throw codedError(
477
+ "STAGE_STATE_UNCHANGED",
355
478
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
356
479
  );
357
480
  }
@@ -373,27 +496,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
373
496
  user,
374
497
  async enter(stage) {
375
498
  if (entered) {
376
- throw new Error("enter may only be called once.");
499
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
377
500
  }
378
501
  if (evidence.stages.length > 0) {
379
- throw new Error("enter must be the first playthrough stage.");
502
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
380
503
  }
381
504
  await executeStage("entered", "entered", stage);
382
505
  entered = true;
383
506
  },
384
507
  async milestone(name, stage) {
385
508
  if (!entered) {
386
- throw new Error("milestone must follow enter.");
509
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
387
510
  }
388
511
  if (finished) {
389
- throw new Error("milestone cannot run after finish.");
512
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
390
513
  }
391
514
  await executeStage(name, "milestone", stage);
392
515
  },
393
516
  async finish(name, stage) {
394
- if (!entered) throw new Error("finish must follow enter.");
517
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
395
518
  if (finished) {
396
- throw new Error("finish may only be called once.");
519
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
397
520
  }
398
521
  await executeStage(name, stage.kind, stage);
399
522
  finished = true;
@@ -402,19 +525,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
402
525
  const milestones = evidence.stages.filter(
403
526
  (stage) => stage.kind === "milestone"
404
527
  );
405
- if (!entered) throw new Error("playthroughTest must call enter once.");
528
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
406
529
  if (milestones.length < MIN_MILESTONES) {
407
- throw new Error(
530
+ throw codedError(
531
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
408
532
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
409
533
  );
410
534
  }
411
535
  if (!finished) {
412
- throw new Error(
536
+ throw codedError(
537
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
413
538
  'playthroughTest must call finish with kind "progress" or "terminal".'
414
539
  );
415
540
  }
416
541
  if (evidence.stages.length < MIN_STAGES) {
417
- throw new Error(
542
+ throw codedError(
543
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
418
544
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
419
545
  );
420
546
  }
@@ -422,6 +548,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
422
548
  } catch (error) {
423
549
  const trace = captureFailureTrace();
424
550
  metadata.trace = trace;
551
+ metadata.failure = createFailureDiagnostic("playthrough", error);
425
552
  try {
426
553
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
427
554
  } catch {
@@ -504,7 +631,14 @@ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the product
504
631
  function isMetadata(value) {
505
632
  if (!value || typeof value !== "object") return false;
506
633
  const metadata = value;
507
- if (metadata.version !== 4) return false;
634
+ if (metadata.version !== 5) return false;
635
+ if (metadata.failure !== void 0) {
636
+ if (!metadata.failure || typeof metadata.failure !== "object" || typeof metadata.failure.source !== "string" || !Array.isArray(metadata.failure.entries) || !metadata.failure.entries.every(
637
+ (entry) => Boolean(entry) && typeof entry === "object" && typeof entry.code === "string" && typeof entry.message === "string"
638
+ )) {
639
+ return false;
640
+ }
641
+ }
508
642
  const evidence = metadata.evidence;
509
643
  if (!evidence || typeof evidence !== "object") return false;
510
644
  return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
@@ -519,10 +653,72 @@ function toAuditInput(test2) {
519
653
  metadata: isMetadata(metadata) ? metadata : void 0
520
654
  };
521
655
  }
656
+ function findPendingProductTests(modules, projectRoot) {
657
+ return modules.flatMap((module2) => {
658
+ const file = (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
659
+ if (file.split("/").includes("examples")) return [];
660
+ const tests = [...module2.children.allTests()];
661
+ const pending = tests.filter((test2) => {
662
+ const mode = test2.options.mode;
663
+ if (mode !== "todo" && mode !== "skip") return false;
664
+ const metadata = test2.meta().reactPlaythrough;
665
+ const approvedWaiver = mode === "skip" && isMetadata(metadata) && Boolean(metadata.waiverReason);
666
+ return !approvedWaiver;
667
+ });
668
+ const fileOnlyContainsPendingTests = tests.length > 0 && pending.length === tests.length;
669
+ return pending.map((test2) => ({
670
+ file,
671
+ test: test2.fullName,
672
+ mode: test2.options.mode,
673
+ fileOnlyContainsPendingTests
674
+ }));
675
+ });
676
+ }
677
+ function formatPendingProductTestReport(pending) {
678
+ if (pending.length === 0) return void 0;
679
+ const lines = [
680
+ "REACT_FOCUSED_TESTS: FAILED",
681
+ "CAUSE_CODE: TODO_OR_SKIP_TESTS",
682
+ "CAUSE: Product tests still contain explicit todo/skip cases."
683
+ ];
684
+ const reportedIncompleteFiles = /* @__PURE__ */ new Set();
685
+ for (const item of pending) {
686
+ if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
687
+ lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
688
+ lines.push(
689
+ `FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
690
+ );
691
+ reportedIncompleteFiles.add(item.file);
692
+ }
693
+ lines.push(`FILE: ${item.file}`);
694
+ lines.push(`TEST: ${item.test}`);
695
+ lines.push(`MODE: ${item.mode}`);
696
+ }
697
+ lines.push(
698
+ "NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
699
+ );
700
+ return `
701
+ ${lines.join("\n")}`;
702
+ }
522
703
  function firstLine(value) {
523
704
  if (typeof value !== "string") return void 0;
524
705
  return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
525
706
  }
707
+ function selectReactFailure(values, errorRecordCount = values.length) {
708
+ const entries = extractFailureEntries(values);
709
+ if (entries.length === 0) {
710
+ return {
711
+ code: "MISSING_FAILURE_DETAILS",
712
+ cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
713
+ rawCause: "",
714
+ related: []
715
+ };
716
+ }
717
+ const primary = entries[0];
718
+ const rawCause = primary.message;
719
+ const cause = firstLine(rawCause) ?? rawCause;
720
+ return { code: primary.code, cause, rawCause, related: entries.slice(1) };
721
+ }
526
722
  function failureHint(value) {
527
723
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
528
724
  (match) => match[1]
@@ -556,30 +752,45 @@ function truncateReporterLine(value, limit) {
556
752
  }
557
753
  function toModuleResult(module2, projectRoot) {
558
754
  const tests = [...module2.children.allTests()];
559
- const moduleErrors = module2.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
755
+ const moduleFailure = selectReactFailure(module2.errors(), module2.errors().length);
756
+ const moduleErrors = extractFailureEntries(module2.errors()).map(
757
+ (entry) => firstLine(entry.message) ?? entry.message
758
+ );
560
759
  const errors = [
561
760
  ...moduleErrors,
562
761
  ...tests.flatMap(
563
- (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
762
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
763
+ (entry) => firstLine(entry.message) ?? entry.message
764
+ )
564
765
  )
565
- ].filter((message) => Boolean(message));
766
+ ];
566
767
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
567
- const raw = test2.result().errors?.at(-1)?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
768
+ const metadata = test2.meta().reactPlaythrough;
769
+ const testErrors = test2.result().errors ?? [];
770
+ const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
771
+ const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
772
+ const selected = selectReactFailure(
773
+ [...attemptErrors, ...module2.errors()],
774
+ testErrors.length + module2.errors().length
775
+ );
568
776
  return {
569
777
  test: test2.fullName,
570
- cause: firstLine(raw) ?? "Unknown failure",
571
- location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
572
- hint: failureHint(raw),
778
+ causeCode: selected.code,
779
+ cause: selected.cause,
780
+ related: selected.related,
781
+ location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
782
+ hint: failureHint(selected.rawCause),
573
783
  trace: failureTrace(test2)
574
784
  };
575
785
  });
576
786
  if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
577
- const raw = module2.errors()[0]?.message ?? "Module failed to load";
578
787
  failures.push({
579
788
  test: "<collection>",
580
- cause: firstLine(raw) ?? "Module failed to load",
581
- location: errorLocation(raw),
582
- hint: failureHint(raw)
789
+ causeCode: moduleFailure.code,
790
+ cause: moduleFailure.cause,
791
+ related: moduleFailure.related,
792
+ location: errorLocation(moduleFailure.rawCause),
793
+ hint: failureHint(moduleFailure.rawCause)
583
794
  });
584
795
  }
585
796
  return {
@@ -587,6 +798,8 @@ function toModuleResult(module2, projectRoot) {
587
798
  state: module2.state(),
588
799
  errors,
589
800
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
801
+ primaryCauseCode: failures[0]?.causeCode,
802
+ relatedErrors: failures[0]?.related,
590
803
  tests: tests.map(toAuditInput),
591
804
  failures
592
805
  };
@@ -603,7 +816,14 @@ function formatReactFailureSummary(modules) {
603
816
  for (const [index, failure] of failures.entries()) {
604
817
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
605
818
  lines.push(`TEST: ${failure.test}`);
819
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
606
820
  lines.push(`CAUSE: ${failure.cause}`);
821
+ for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
822
+ lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
823
+ lines.push(
824
+ `RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
825
+ );
826
+ }
607
827
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
608
828
  if (failure.location) lines.push(`AT: ${failure.location}`);
609
829
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
@@ -611,32 +831,36 @@ function formatReactFailureSummary(modules) {
611
831
  lines.push("TEST_RESULT: FAIL");
612
832
  return lines;
613
833
  }
614
- function repairGuidance(cause) {
615
- if (/snapshot\(\) returned the same reference/i.test(cause)) {
616
- 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.";
617
- }
618
- if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
619
- 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.";
620
- }
621
- if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
622
- 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.";
623
- }
624
- if (/until condition must be false before its driver runs/i.test(cause)) {
625
- 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.";
626
- }
627
- if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
628
- 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.";
629
- }
630
- if (/No step callback was provided/i.test(cause)) {
631
- 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.";
834
+ function repairGuidance(code) {
835
+ switch (code) {
836
+ case "GAME_SNAPSHOT_REFERENCE_REUSED":
837
+ 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.";
838
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP":
839
+ 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.";
840
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS":
841
+ 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.";
842
+ case "STAGE_OUTCOME_ALREADY_REACHED":
843
+ 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.";
844
+ case "STAGE_STATE_UNCHANGED":
845
+ 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.";
846
+ case "PLAYTHROUGH_BOUND_EXHAUSTED":
847
+ 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.";
848
+ case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
849
+ 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.";
850
+ case "INVALID_STAGE_ORDER":
851
+ case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
852
+ case "INVALID_STAGE_NAME":
853
+ case "DUPLICATE_STAGE_NAME":
854
+ case "RESERVED_STAGE_NAME":
855
+ case "PRODUCTION_INPUT_NOT_DISPATCHED":
856
+ case "AUTONOMOUS_STAGE_NOT_ADVANCED":
857
+ case "STAGE_ASSERTION_MISSING":
858
+ 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.";
859
+ case "MISSING_FAILURE_DETAILS":
860
+ 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.";
861
+ default:
862
+ return "Fix the first reported CAUSE, then rerun the same test. RELATED entries preserve the remaining Vitest errors in their original order.";
632
863
  }
633
- if (/outcome was not reached within \d+ steps/i.test(cause)) {
634
- 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.";
635
- }
636
- if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
637
- 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.";
638
- }
639
- 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.";
640
864
  }
641
865
  function assessReactPlaythroughReport(input) {
642
866
  const base = { file: input.expectedFile };
@@ -652,6 +876,7 @@ function assessReactPlaythroughReport(input) {
652
876
  return {
653
877
  ...base,
654
878
  status: "FAILED",
879
+ causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
655
880
  cause: "The required production playthrough test file does not exist.",
656
881
  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.",
657
882
  failsRun: true
@@ -675,6 +900,7 @@ function assessReactPlaythroughReport(input) {
675
900
  return {
676
901
  ...base,
677
902
  status: "NOT_RUN",
903
+ causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
678
904
  cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
679
905
  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.",
680
906
  failsRun: true
@@ -687,8 +913,12 @@ function assessReactPlaythroughReport(input) {
687
913
  return {
688
914
  ...base,
689
915
  status: "FAILED",
916
+ causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
690
917
  cause,
691
- next: repairGuidance(cause),
918
+ related: productionModule.relatedErrors,
919
+ next: repairGuidance(
920
+ productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
921
+ ),
692
922
  failsRun: true
693
923
  };
694
924
  }
@@ -704,7 +934,14 @@ function assessReactPlaythroughReport(input) {
704
934
  }
705
935
  function formatReactPlaythroughReport(report) {
706
936
  const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
937
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
707
938
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
939
+ for (const [index, related] of (report.related ?? []).entries()) {
940
+ lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
941
+ lines.push(
942
+ `RELATED_${index + 1}: ${firstLine(related.message) ?? related.message}`
943
+ );
944
+ }
708
945
  if (report.waiverReasons?.length) {
709
946
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
710
947
  }
@@ -737,6 +974,10 @@ var ReactPlaythroughReporter = class {
737
974
  }
738
975
  /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
739
976
  onTestRunEnd(testModules, unhandledErrors) {
977
+ const pendingProductTests = findPendingProductTests(
978
+ testModules,
979
+ this.projectRoot
980
+ );
740
981
  const report = assessReactPlaythroughReport({
741
982
  expectedFile: this.expectedFile,
742
983
  expectedFileExists: (0, import_node_fs.existsSync)(this.expectedModuleId),
@@ -745,7 +986,9 @@ var ReactPlaythroughReporter = class {
745
986
  modules: testModules.map(
746
987
  (module2) => toModuleResult(module2, this.projectRoot)
747
988
  ),
748
- unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
989
+ unhandledErrors: extractFailureEntries(unhandledErrors).map(
990
+ (entry) => firstLine(entry.message) ?? entry.message
991
+ )
749
992
  });
750
993
  const output = formatReactPlaythroughReport(report);
751
994
  if (report.failsRun) {
@@ -756,10 +999,15 @@ var ReactPlaythroughReporter = class {
756
999
  } else {
757
1000
  console.log(output);
758
1001
  }
1002
+ const pendingOutput = formatPendingProductTestReport(pendingProductTests);
1003
+ if (pendingOutput) {
1004
+ console.error(pendingOutput);
1005
+ process.exitCode = 1;
1006
+ }
759
1007
  const summary = formatReactFailureSummary(
760
1008
  testModules.map((module2) => toModuleResult(module2, this.projectRoot))
761
1009
  );
762
- if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
1010
+ if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
763
1011
  summary[0] = "TEST_RESULT: FAIL";
764
1012
  }
765
1013
  console.log(`