miaoda-game-devkit 0.6.1 → 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
  }
@@ -115,6 +216,11 @@ var MIN_STAGES = 5;
115
216
  var MIN_MILESTONES = 3;
116
217
  var MAX_TRACE_VALUE_LENGTH = 140;
117
218
  var MAX_TRACE_LENGTH = 720;
219
+ function eventTargetsCanvas(event) {
220
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
221
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
222
+ return event.target instanceof HTMLCanvasElement;
223
+ }
118
224
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
119
225
  function truncateTraceValue(value, limit) {
120
226
  const compact = value.replace(/\s+/g, " ").trim();
@@ -144,14 +250,18 @@ function sampleObservedState(observe, stage) {
144
250
  try {
145
251
  value = observe();
146
252
  } catch (error) {
147
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
253
+ throw codedError(
254
+ "OBSERVE_FAILED",
255
+ `observe() threw at ${stage}: ${String(error)}`
256
+ );
148
257
  }
149
258
  try {
150
259
  const fingerprint = JSON.stringify(value);
151
260
  if (fingerprint === void 0) throw new Error("unsupported value");
152
261
  return { fingerprint, formatted: formatState(fingerprint) };
153
262
  } catch {
154
- throw new Error(
263
+ throw codedError(
264
+ "OBSERVE_NOT_SERIALIZABLE",
155
265
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
156
266
  );
157
267
  }
@@ -164,7 +274,7 @@ function createEvidence() {
164
274
  return { domInputEvents: 0, stages: [], verified: false };
165
275
  }
166
276
  function createMetadata(waiverReason) {
167
- return { version: 4, waiverReason, evidence: createEvidence() };
277
+ return { version: 5, waiverReason, evidence: createEvidence() };
168
278
  }
169
279
  function stageLabel(kind, name) {
170
280
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -200,6 +310,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
200
310
  async ({ annotate, expect, onTestFailed, signal }) => {
201
311
  metadata.evidence = createEvidence();
202
312
  metadata.trace = void 0;
313
+ metadata.failure = void 0;
203
314
  const evidence = metadata.evidence;
204
315
  let entered = false;
205
316
  let finished = false;
@@ -208,9 +319,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
208
319
  let stepTrace;
209
320
  let failureTraceFactory;
210
321
  let acceptingStageInput = false;
322
+ let activeStageTargetedCanvas = false;
211
323
  let inputCaptureAttached = false;
212
- const recordInput = () => {
213
- if (acceptingStageInput) evidence.domInputEvents += 1;
324
+ const recordInput = (event) => {
325
+ if (!acceptingStageInput) return;
326
+ evidence.domInputEvents += 1;
327
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
214
328
  };
215
329
  const stopInputCapture = () => {
216
330
  acceptingStageInput = false;
@@ -227,8 +341,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
227
341
  return "stages=none";
228
342
  }
229
343
  };
230
- onTestFailed(() => {
344
+ onTestFailed(({ task }) => {
231
345
  metadata.trace ??= captureFailureTrace();
346
+ metadata.failure = appendCurrentAttemptFailures(
347
+ metadata.failure,
348
+ task.result?.errors ?? []
349
+ );
232
350
  });
233
351
  for (const event of INPUT_EVENTS) {
234
352
  document.addEventListener(event, recordInput, true);
@@ -238,7 +356,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
238
356
  try {
239
357
  const view = (0, import_react2.render)(element);
240
358
  if (view.container.childNodes.length === 0) {
241
- throw new Error(
359
+ throw codedError(
360
+ "PRODUCTION_ENTRY_NOT_RENDERED",
242
361
  "playthroughTest must render the production game entry."
243
362
  );
244
363
  }
@@ -267,29 +386,42 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
267
386
  const executeStage = async (name, kind, stage) => {
268
387
  const normalizedName = name.trim();
269
388
  if (normalizedName.length === 0) {
270
- 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
+ );
271
393
  }
272
394
  if (evidence.stages.some(
273
395
  (completed) => completed.name === normalizedName
274
396
  )) {
275
- throw new Error(
397
+ throw codedError(
398
+ "DUPLICATE_STAGE_NAME",
276
399
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
277
400
  );
278
401
  }
279
402
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
280
- throw new Error(
403
+ throw codedError(
404
+ "RESERVED_STAGE_NAME",
281
405
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
282
406
  );
283
407
  }
284
408
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
285
409
  activeStage = { name: normalizedName, kind, before };
286
410
  stepTrace = { bound: stage.maxSteps ?? 120 };
411
+ if (stage.step && !playthroughOptions?.observe) {
412
+ throw codedError(
413
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
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.`
415
+ );
416
+ }
287
417
  if (stage.until()) {
288
- throw new Error(
418
+ throw codedError(
419
+ "STAGE_OUTCOME_ALREADY_REACHED",
289
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.`
290
421
  );
291
422
  }
292
423
  const inputsBefore = evidence.domInputEvents;
424
+ activeStageTargetedCanvas = false;
293
425
  if (stage.act) {
294
426
  acceptingStageInput = true;
295
427
  try {
@@ -298,10 +430,17 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
298
430
  acceptingStageInput = false;
299
431
  }
300
432
  if (evidence.domInputEvents === inputsBefore) {
301
- throw new Error(
433
+ throw codedError(
434
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
302
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.`
303
436
  );
304
437
  }
438
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
439
+ throw codedError(
440
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
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.`
442
+ );
443
+ }
305
444
  }
306
445
  let advancedSteps = 0;
307
446
  const stepBound = stage.maxSteps ?? 120;
@@ -317,7 +456,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
317
456
  });
318
457
  stepTrace = { bound: stepBound, completed: steps };
319
458
  if (!stage.act && advancedSteps === 0) {
320
- throw new Error(
459
+ throw codedError(
460
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
321
461
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
322
462
  );
323
463
  }
@@ -325,14 +465,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
325
465
  await stage.assert({ expect, user, view });
326
466
  const assertions = expect.getState().assertionCalls - assertionsBefore;
327
467
  if (assertions === 0) {
328
- throw new Error(
468
+ throw codedError(
469
+ "STAGE_ASSERTION_MISSING",
329
470
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
330
471
  );
331
472
  }
332
473
  const after = sampleState(`after ${normalizedName}`);
333
474
  if (after.fingerprint === before.fingerprint) {
334
475
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
335
- throw new Error(
476
+ throw codedError(
477
+ "STAGE_STATE_UNCHANGED",
336
478
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
337
479
  );
338
480
  }
@@ -354,27 +496,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
354
496
  user,
355
497
  async enter(stage) {
356
498
  if (entered) {
357
- throw new Error("enter may only be called once.");
499
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
358
500
  }
359
501
  if (evidence.stages.length > 0) {
360
- throw new Error("enter must be the first playthrough stage.");
502
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
361
503
  }
362
504
  await executeStage("entered", "entered", stage);
363
505
  entered = true;
364
506
  },
365
507
  async milestone(name, stage) {
366
508
  if (!entered) {
367
- throw new Error("milestone must follow enter.");
509
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
368
510
  }
369
511
  if (finished) {
370
- throw new Error("milestone cannot run after finish.");
512
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
371
513
  }
372
514
  await executeStage(name, "milestone", stage);
373
515
  },
374
516
  async finish(name, stage) {
375
- if (!entered) throw new Error("finish must follow enter.");
517
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
376
518
  if (finished) {
377
- throw new Error("finish may only be called once.");
519
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
378
520
  }
379
521
  await executeStage(name, stage.kind, stage);
380
522
  finished = true;
@@ -383,19 +525,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
383
525
  const milestones = evidence.stages.filter(
384
526
  (stage) => stage.kind === "milestone"
385
527
  );
386
- if (!entered) throw new Error("playthroughTest must call enter once.");
528
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
387
529
  if (milestones.length < MIN_MILESTONES) {
388
- throw new Error(
530
+ throw codedError(
531
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
389
532
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
390
533
  );
391
534
  }
392
535
  if (!finished) {
393
- throw new Error(
536
+ throw codedError(
537
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
394
538
  'playthroughTest must call finish with kind "progress" or "terminal".'
395
539
  );
396
540
  }
397
541
  if (evidence.stages.length < MIN_STAGES) {
398
- throw new Error(
542
+ throw codedError(
543
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
399
544
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
400
545
  );
401
546
  }
@@ -403,6 +548,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
403
548
  } catch (error) {
404
549
  const trace = captureFailureTrace();
405
550
  metadata.trace = trace;
551
+ metadata.failure = createFailureDiagnostic("playthrough", error);
406
552
  try {
407
553
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
408
554
  } catch {
@@ -481,10 +627,18 @@ function auditReactPlaythroughRun(tests) {
481
627
 
482
628
  // src/react/react-playthrough-reporter.ts
483
629
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
630
+ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
484
631
  function isMetadata(value) {
485
632
  if (!value || typeof value !== "object") return false;
486
633
  const metadata = value;
487
- 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
+ }
488
642
  const evidence = metadata.evidence;
489
643
  if (!evidence || typeof evidence !== "object") return false;
490
644
  return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
@@ -499,10 +653,72 @@ function toAuditInput(test2) {
499
653
  metadata: isMetadata(metadata) ? metadata : void 0
500
654
  };
501
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
+ }
502
703
  function firstLine(value) {
503
704
  if (typeof value !== "string") return void 0;
504
705
  return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
505
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
+ }
506
722
  function failureHint(value) {
507
723
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
508
724
  (match) => match[1]
@@ -536,30 +752,45 @@ function truncateReporterLine(value, limit) {
536
752
  }
537
753
  function toModuleResult(module2, projectRoot) {
538
754
  const tests = [...module2.children.allTests()];
539
- 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
+ );
540
759
  const errors = [
541
760
  ...moduleErrors,
542
761
  ...tests.flatMap(
543
- (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
762
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
763
+ (entry) => firstLine(entry.message) ?? entry.message
764
+ )
544
765
  )
545
- ].filter((message) => Boolean(message));
766
+ ];
546
767
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
547
- 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
+ );
548
776
  return {
549
777
  test: test2.fullName,
550
- cause: firstLine(raw) ?? "Unknown failure",
551
- location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
552
- 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),
553
783
  trace: failureTrace(test2)
554
784
  };
555
785
  });
556
786
  if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
557
- const raw = module2.errors()[0]?.message ?? "Module failed to load";
558
787
  failures.push({
559
788
  test: "<collection>",
560
- cause: firstLine(raw) ?? "Module failed to load",
561
- location: errorLocation(raw),
562
- 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)
563
794
  });
564
795
  }
565
796
  return {
@@ -567,6 +798,8 @@ function toModuleResult(module2, projectRoot) {
567
798
  state: module2.state(),
568
799
  errors,
569
800
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
801
+ primaryCauseCode: failures[0]?.causeCode,
802
+ relatedErrors: failures[0]?.related,
570
803
  tests: tests.map(toAuditInput),
571
804
  failures
572
805
  };
@@ -583,7 +816,14 @@ function formatReactFailureSummary(modules) {
583
816
  for (const [index, failure] of failures.entries()) {
584
817
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
585
818
  lines.push(`TEST: ${failure.test}`);
819
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
586
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
+ }
587
827
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
588
828
  if (failure.location) lines.push(`AT: ${failure.location}`);
589
829
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
@@ -591,26 +831,36 @@ function formatReactFailureSummary(modules) {
591
831
  lines.push("TEST_RESULT: FAIL");
592
832
  return lines;
593
833
  }
594
- function repairGuidance(cause) {
595
- if (/snapshot\(\) returned the same reference/i.test(cause)) {
596
- 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.";
597
- }
598
- if (/until condition must be false before its driver runs/i.test(cause)) {
599
- 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.";
600
- }
601
- if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
602
- return "The stage ran and asserted, but its observable state matched the previous milestone. Wait for a genuinely new gameplay result. Canvas or Controller games should make observe read the same production Controller that React renders.";
603
- }
604
- if (/No step callback was provided/i.test(cause)) {
605
- 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.";
606
- }
607
- if (/outcome was not reached within \d+ steps/i.test(cause)) {
608
- return "The stage driver ran, but gameplay did not reach the outcome within the bound. Confirm that its production input or deterministic step changed the intended rule state, then inspect Last diagnostics to locate the stalled stage.";
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.";
609
863
  }
610
- if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
611
- 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.";
612
- }
613
- 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.";
614
864
  }
615
865
  function assessReactPlaythroughReport(input) {
616
866
  const base = { file: input.expectedFile };
@@ -626,6 +876,7 @@ function assessReactPlaythroughReport(input) {
626
876
  return {
627
877
  ...base,
628
878
  status: "FAILED",
879
+ causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
629
880
  cause: "The required production playthrough test file does not exist.",
630
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.",
631
882
  failsRun: true
@@ -649,6 +900,7 @@ function assessReactPlaythroughReport(input) {
649
900
  return {
650
901
  ...base,
651
902
  status: "NOT_RUN",
903
+ causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
652
904
  cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
653
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.",
654
906
  failsRun: true
@@ -661,8 +913,12 @@ function assessReactPlaythroughReport(input) {
661
913
  return {
662
914
  ...base,
663
915
  status: "FAILED",
916
+ causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
664
917
  cause,
665
- next: repairGuidance(cause),
918
+ related: productionModule.relatedErrors,
919
+ next: repairGuidance(
920
+ productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
921
+ ),
666
922
  failsRun: true
667
923
  };
668
924
  }
@@ -678,11 +934,21 @@ function assessReactPlaythroughReport(input) {
678
934
  }
679
935
  function formatReactPlaythroughReport(report) {
680
936
  const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
937
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
681
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
+ }
682
945
  if (report.waiverReasons?.length) {
683
946
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
684
947
  }
685
948
  if (report.next) lines.push(`NEXT: ${report.next}`);
949
+ if (report.status === "FAILED") {
950
+ lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
951
+ }
686
952
  return `
687
953
  ${lines.join("\n")}`;
688
954
  }
@@ -708,6 +974,10 @@ var ReactPlaythroughReporter = class {
708
974
  }
709
975
  /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
710
976
  onTestRunEnd(testModules, unhandledErrors) {
977
+ const pendingProductTests = findPendingProductTests(
978
+ testModules,
979
+ this.projectRoot
980
+ );
711
981
  const report = assessReactPlaythroughReport({
712
982
  expectedFile: this.expectedFile,
713
983
  expectedFileExists: (0, import_node_fs.existsSync)(this.expectedModuleId),
@@ -716,7 +986,9 @@ var ReactPlaythroughReporter = class {
716
986
  modules: testModules.map(
717
987
  (module2) => toModuleResult(module2, this.projectRoot)
718
988
  ),
719
- unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
989
+ unhandledErrors: extractFailureEntries(unhandledErrors).map(
990
+ (entry) => firstLine(entry.message) ?? entry.message
991
+ )
720
992
  });
721
993
  const output = formatReactPlaythroughReport(report);
722
994
  if (report.failsRun) {
@@ -727,10 +999,15 @@ var ReactPlaythroughReporter = class {
727
999
  } else {
728
1000
  console.log(output);
729
1001
  }
1002
+ const pendingOutput = formatPendingProductTestReport(pendingProductTests);
1003
+ if (pendingOutput) {
1004
+ console.error(pendingOutput);
1005
+ process.exitCode = 1;
1006
+ }
730
1007
  const summary = formatReactFailureSummary(
731
1008
  testModules.map((module2) => toModuleResult(module2, this.projectRoot))
732
1009
  );
733
- if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
1010
+ if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
734
1011
  summary[0] = "TEST_RESULT: FAIL";
735
1012
  }
736
1013
  console.log(`