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.
@@ -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
  }
@@ -81,6 +182,11 @@ var MIN_STAGES = 5;
81
182
  var MIN_MILESTONES = 3;
82
183
  var MAX_TRACE_VALUE_LENGTH = 140;
83
184
  var MAX_TRACE_LENGTH = 720;
185
+ function eventTargetsCanvas(event) {
186
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
187
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
188
+ return event.target instanceof HTMLCanvasElement;
189
+ }
84
190
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
85
191
  function truncateTraceValue(value, limit) {
86
192
  const compact = value.replace(/\s+/g, " ").trim();
@@ -110,14 +216,18 @@ function sampleObservedState(observe, stage) {
110
216
  try {
111
217
  value = observe();
112
218
  } catch (error) {
113
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
219
+ throw codedError(
220
+ "OBSERVE_FAILED",
221
+ `observe() threw at ${stage}: ${String(error)}`
222
+ );
114
223
  }
115
224
  try {
116
225
  const fingerprint = JSON.stringify(value);
117
226
  if (fingerprint === void 0) throw new Error("unsupported value");
118
227
  return { fingerprint, formatted: formatState(fingerprint) };
119
228
  } catch {
120
- throw new Error(
229
+ throw codedError(
230
+ "OBSERVE_NOT_SERIALIZABLE",
121
231
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
122
232
  );
123
233
  }
@@ -130,7 +240,7 @@ function createEvidence() {
130
240
  return { domInputEvents: 0, stages: [], verified: false };
131
241
  }
132
242
  function createMetadata(waiverReason) {
133
- return { version: 4, waiverReason, evidence: createEvidence() };
243
+ return { version: 5, waiverReason, evidence: createEvidence() };
134
244
  }
135
245
  function stageLabel(kind, name) {
136
246
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -166,6 +276,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
166
276
  async ({ annotate, expect, onTestFailed, signal }) => {
167
277
  metadata.evidence = createEvidence();
168
278
  metadata.trace = void 0;
279
+ metadata.failure = void 0;
169
280
  const evidence = metadata.evidence;
170
281
  let entered = false;
171
282
  let finished = false;
@@ -174,9 +285,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
174
285
  let stepTrace;
175
286
  let failureTraceFactory;
176
287
  let acceptingStageInput = false;
288
+ let activeStageTargetedCanvas = false;
177
289
  let inputCaptureAttached = false;
178
- const recordInput = () => {
179
- if (acceptingStageInput) evidence.domInputEvents += 1;
290
+ const recordInput = (event) => {
291
+ if (!acceptingStageInput) return;
292
+ evidence.domInputEvents += 1;
293
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
180
294
  };
181
295
  const stopInputCapture = () => {
182
296
  acceptingStageInput = false;
@@ -193,8 +307,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
193
307
  return "stages=none";
194
308
  }
195
309
  };
196
- onTestFailed(() => {
310
+ onTestFailed(({ task }) => {
197
311
  metadata.trace ??= captureFailureTrace();
312
+ metadata.failure = appendCurrentAttemptFailures(
313
+ metadata.failure,
314
+ task.result?.errors ?? []
315
+ );
198
316
  });
199
317
  for (const event of INPUT_EVENTS) {
200
318
  document.addEventListener(event, recordInput, true);
@@ -204,7 +322,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
204
322
  try {
205
323
  const view = render(element);
206
324
  if (view.container.childNodes.length === 0) {
207
- throw new Error(
325
+ throw codedError(
326
+ "PRODUCTION_ENTRY_NOT_RENDERED",
208
327
  "playthroughTest must render the production game entry."
209
328
  );
210
329
  }
@@ -233,29 +352,42 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
233
352
  const executeStage = async (name, kind, stage) => {
234
353
  const normalizedName = name.trim();
235
354
  if (normalizedName.length === 0) {
236
- 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
+ );
237
359
  }
238
360
  if (evidence.stages.some(
239
361
  (completed) => completed.name === normalizedName
240
362
  )) {
241
- throw new Error(
363
+ throw codedError(
364
+ "DUPLICATE_STAGE_NAME",
242
365
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
243
366
  );
244
367
  }
245
368
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
246
- throw new Error(
369
+ throw codedError(
370
+ "RESERVED_STAGE_NAME",
247
371
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
248
372
  );
249
373
  }
250
374
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
251
375
  activeStage = { name: normalizedName, kind, before };
252
376
  stepTrace = { bound: stage.maxSteps ?? 120 };
377
+ if (stage.step && !playthroughOptions?.observe) {
378
+ throw codedError(
379
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
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.`
381
+ );
382
+ }
253
383
  if (stage.until()) {
254
- throw new Error(
384
+ throw codedError(
385
+ "STAGE_OUTCOME_ALREADY_REACHED",
255
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.`
256
387
  );
257
388
  }
258
389
  const inputsBefore = evidence.domInputEvents;
390
+ activeStageTargetedCanvas = false;
259
391
  if (stage.act) {
260
392
  acceptingStageInput = true;
261
393
  try {
@@ -264,10 +396,17 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
264
396
  acceptingStageInput = false;
265
397
  }
266
398
  if (evidence.domInputEvents === inputsBefore) {
267
- throw new Error(
399
+ throw codedError(
400
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
268
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.`
269
402
  );
270
403
  }
404
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
405
+ throw codedError(
406
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
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.`
408
+ );
409
+ }
271
410
  }
272
411
  let advancedSteps = 0;
273
412
  const stepBound = stage.maxSteps ?? 120;
@@ -283,7 +422,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
283
422
  });
284
423
  stepTrace = { bound: stepBound, completed: steps };
285
424
  if (!stage.act && advancedSteps === 0) {
286
- throw new Error(
425
+ throw codedError(
426
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
287
427
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
288
428
  );
289
429
  }
@@ -291,14 +431,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
291
431
  await stage.assert({ expect, user, view });
292
432
  const assertions = expect.getState().assertionCalls - assertionsBefore;
293
433
  if (assertions === 0) {
294
- throw new Error(
434
+ throw codedError(
435
+ "STAGE_ASSERTION_MISSING",
295
436
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
296
437
  );
297
438
  }
298
439
  const after = sampleState(`after ${normalizedName}`);
299
440
  if (after.fingerprint === before.fingerprint) {
300
441
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
301
- throw new Error(
442
+ throw codedError(
443
+ "STAGE_STATE_UNCHANGED",
302
444
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
303
445
  );
304
446
  }
@@ -320,27 +462,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
320
462
  user,
321
463
  async enter(stage) {
322
464
  if (entered) {
323
- throw new Error("enter may only be called once.");
465
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
324
466
  }
325
467
  if (evidence.stages.length > 0) {
326
- throw new Error("enter must be the first playthrough stage.");
468
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
327
469
  }
328
470
  await executeStage("entered", "entered", stage);
329
471
  entered = true;
330
472
  },
331
473
  async milestone(name, stage) {
332
474
  if (!entered) {
333
- throw new Error("milestone must follow enter.");
475
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
334
476
  }
335
477
  if (finished) {
336
- throw new Error("milestone cannot run after finish.");
478
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
337
479
  }
338
480
  await executeStage(name, "milestone", stage);
339
481
  },
340
482
  async finish(name, stage) {
341
- if (!entered) throw new Error("finish must follow enter.");
483
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
342
484
  if (finished) {
343
- throw new Error("finish may only be called once.");
485
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
344
486
  }
345
487
  await executeStage(name, stage.kind, stage);
346
488
  finished = true;
@@ -349,19 +491,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
349
491
  const milestones = evidence.stages.filter(
350
492
  (stage) => stage.kind === "milestone"
351
493
  );
352
- if (!entered) throw new Error("playthroughTest must call enter once.");
494
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
353
495
  if (milestones.length < MIN_MILESTONES) {
354
- throw new Error(
496
+ throw codedError(
497
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
355
498
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
356
499
  );
357
500
  }
358
501
  if (!finished) {
359
- throw new Error(
502
+ throw codedError(
503
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
360
504
  'playthroughTest must call finish with kind "progress" or "terminal".'
361
505
  );
362
506
  }
363
507
  if (evidence.stages.length < MIN_STAGES) {
364
- throw new Error(
508
+ throw codedError(
509
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
365
510
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
366
511
  );
367
512
  }
@@ -369,6 +514,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
369
514
  } catch (error) {
370
515
  const trace = captureFailureTrace();
371
516
  metadata.trace = trace;
517
+ metadata.failure = createFailureDiagnostic("playthrough", error);
372
518
  try {
373
519
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
374
520
  } catch {
@@ -447,10 +593,18 @@ function auditReactPlaythroughRun(tests) {
447
593
 
448
594
  // src/react/react-playthrough-reporter.ts
449
595
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
596
+ 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.";
450
597
  function isMetadata(value) {
451
598
  if (!value || typeof value !== "object") return false;
452
599
  const metadata = value;
453
- 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
+ }
454
608
  const evidence = metadata.evidence;
455
609
  if (!evidence || typeof evidence !== "object") return false;
456
610
  return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
@@ -465,10 +619,72 @@ function toAuditInput(test2) {
465
619
  metadata: isMetadata(metadata) ? metadata : void 0
466
620
  };
467
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
+ }
468
669
  function firstLine(value) {
469
670
  if (typeof value !== "string") return void 0;
470
671
  return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
471
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
+ }
472
688
  function failureHint(value) {
473
689
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
474
690
  (match) => match[1]
@@ -502,30 +718,45 @@ function truncateReporterLine(value, limit) {
502
718
  }
503
719
  function toModuleResult(module, projectRoot) {
504
720
  const tests = [...module.children.allTests()];
505
- 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
+ );
506
725
  const errors = [
507
726
  ...moduleErrors,
508
727
  ...tests.flatMap(
509
- (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
728
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
729
+ (entry) => firstLine(entry.message) ?? entry.message
730
+ )
510
731
  )
511
- ].filter((message) => Boolean(message));
732
+ ];
512
733
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
513
- 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
+ );
514
742
  return {
515
743
  test: test2.fullName,
516
- cause: firstLine(raw) ?? "Unknown failure",
517
- location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
518
- 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),
519
749
  trace: failureTrace(test2)
520
750
  };
521
751
  });
522
752
  if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
523
- const raw = module.errors()[0]?.message ?? "Module failed to load";
524
753
  failures.push({
525
754
  test: "<collection>",
526
- cause: firstLine(raw) ?? "Module failed to load",
527
- location: errorLocation(raw),
528
- 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)
529
760
  });
530
761
  }
531
762
  return {
@@ -533,6 +764,8 @@ function toModuleResult(module, projectRoot) {
533
764
  state: module.state(),
534
765
  errors,
535
766
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
767
+ primaryCauseCode: failures[0]?.causeCode,
768
+ relatedErrors: failures[0]?.related,
536
769
  tests: tests.map(toAuditInput),
537
770
  failures
538
771
  };
@@ -549,7 +782,14 @@ function formatReactFailureSummary(modules) {
549
782
  for (const [index, failure] of failures.entries()) {
550
783
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
551
784
  lines.push(`TEST: ${failure.test}`);
785
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
552
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
+ }
553
793
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
554
794
  if (failure.location) lines.push(`AT: ${failure.location}`);
555
795
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
@@ -557,26 +797,36 @@ function formatReactFailureSummary(modules) {
557
797
  lines.push("TEST_RESULT: FAIL");
558
798
  return lines;
559
799
  }
560
- function repairGuidance(cause) {
561
- if (/snapshot\(\) returned the same reference/i.test(cause)) {
562
- 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.";
563
- }
564
- if (/until condition must be false before its driver runs/i.test(cause)) {
565
- 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.";
566
- }
567
- if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
568
- 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.";
569
- }
570
- if (/No step callback was provided/i.test(cause)) {
571
- 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.";
572
- }
573
- if (/outcome was not reached within \d+ steps/i.test(cause)) {
574
- 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.";
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.";
575
829
  }
576
- if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
577
- 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.";
578
- }
579
- 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.";
580
830
  }
581
831
  function assessReactPlaythroughReport(input) {
582
832
  const base = { file: input.expectedFile };
@@ -592,6 +842,7 @@ function assessReactPlaythroughReport(input) {
592
842
  return {
593
843
  ...base,
594
844
  status: "FAILED",
845
+ causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
595
846
  cause: "The required production playthrough test file does not exist.",
596
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.",
597
848
  failsRun: true
@@ -615,6 +866,7 @@ function assessReactPlaythroughReport(input) {
615
866
  return {
616
867
  ...base,
617
868
  status: "NOT_RUN",
869
+ causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
618
870
  cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
619
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.",
620
872
  failsRun: true
@@ -627,8 +879,12 @@ function assessReactPlaythroughReport(input) {
627
879
  return {
628
880
  ...base,
629
881
  status: "FAILED",
882
+ causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
630
883
  cause,
631
- next: repairGuidance(cause),
884
+ related: productionModule.relatedErrors,
885
+ next: repairGuidance(
886
+ productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
887
+ ),
632
888
  failsRun: true
633
889
  };
634
890
  }
@@ -644,11 +900,21 @@ function assessReactPlaythroughReport(input) {
644
900
  }
645
901
  function formatReactPlaythroughReport(report) {
646
902
  const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
903
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
647
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
+ }
648
911
  if (report.waiverReasons?.length) {
649
912
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
650
913
  }
651
914
  if (report.next) lines.push(`NEXT: ${report.next}`);
915
+ if (report.status === "FAILED") {
916
+ lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
917
+ }
652
918
  return `
653
919
  ${lines.join("\n")}`;
654
920
  }
@@ -674,6 +940,10 @@ var ReactPlaythroughReporter = class {
674
940
  }
675
941
  /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
676
942
  onTestRunEnd(testModules, unhandledErrors) {
943
+ const pendingProductTests = findPendingProductTests(
944
+ testModules,
945
+ this.projectRoot
946
+ );
677
947
  const report = assessReactPlaythroughReport({
678
948
  expectedFile: this.expectedFile,
679
949
  expectedFileExists: existsSync(this.expectedModuleId),
@@ -682,7 +952,9 @@ var ReactPlaythroughReporter = class {
682
952
  modules: testModules.map(
683
953
  (module) => toModuleResult(module, this.projectRoot)
684
954
  ),
685
- unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
955
+ unhandledErrors: extractFailureEntries(unhandledErrors).map(
956
+ (entry) => firstLine(entry.message) ?? entry.message
957
+ )
686
958
  });
687
959
  const output = formatReactPlaythroughReport(report);
688
960
  if (report.failsRun) {
@@ -693,10 +965,15 @@ var ReactPlaythroughReporter = class {
693
965
  } else {
694
966
  console.log(output);
695
967
  }
968
+ const pendingOutput = formatPendingProductTestReport(pendingProductTests);
969
+ if (pendingOutput) {
970
+ console.error(pendingOutput);
971
+ process.exitCode = 1;
972
+ }
696
973
  const summary = formatReactFailureSummary(
697
974
  testModules.map((module) => toModuleResult(module, this.projectRoot))
698
975
  );
699
- if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
976
+ if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
700
977
  summary[0] = "TEST_RESULT: FAIL";
701
978
  }
702
979
  console.log(`