miaoda-game-devkit 0.6.2 → 0.6.4

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.
@@ -33,28 +33,359 @@ __export(react_vitest_config_exports, {
33
33
  defineReactGameVitestConfig: () => defineReactGameVitestConfig
34
34
  });
35
35
  module.exports = __toCommonJS(react_vitest_config_exports);
36
- var import_node_fs2 = require("fs");
37
- var import_node_path2 = require("path");
36
+ var import_node_fs3 = require("fs");
37
+ var import_node_path3 = require("path");
38
38
  var import_config = require("vitest/config");
39
39
 
40
40
  // src/react/react-playthrough-reporter.ts
41
+ var import_node_fs2 = require("fs");
42
+ var import_node_path2 = require("path");
43
+ var import_node_util = require("util");
44
+
45
+ // src/cli/react-authoritative-playthrough.ts
41
46
  var import_node_fs = require("fs");
42
47
  var import_node_path = require("path");
43
- var import_node_util = require("util");
48
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
49
+ var PRODUCTION_APP = "src/App.tsx";
50
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
51
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
52
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
53
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
54
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
55
+ "useGameController",
56
+ "useOwnedGameController"
57
+ ]);
58
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
59
+ function runnableTestFiles(root, directory = (0, import_node_path.join)(root, "tests")) {
60
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
61
+ const files = [];
62
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
63
+ const path = (0, import_node_path.join)(directory, entry.name);
64
+ if (entry.isDirectory()) {
65
+ files.push(...runnableTestFiles(root, path));
66
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
67
+ files.push(path);
68
+ }
69
+ }
70
+ return files;
71
+ }
72
+ function extension(path) {
73
+ const index = path.lastIndexOf(".");
74
+ return index < 0 ? "" : path.slice(index);
75
+ }
76
+ function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
77
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
78
+ const files = [];
79
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
80
+ const path = (0, import_node_path.join)(directory, entry.name);
81
+ const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
82
+ if (entry.isDirectory()) {
83
+ if (projectPath === "src/game/example") continue;
84
+ files.push(...sourceFiles(root, path));
85
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
86
+ files.push(path);
87
+ }
88
+ }
89
+ return files;
90
+ }
91
+ function withoutComments(source) {
92
+ let output = "";
93
+ let state = "code";
94
+ for (let index = 0; index < source.length; index += 1) {
95
+ const char = source[index];
96
+ const next = source[index + 1];
97
+ if (state === "line") {
98
+ if (char === "\n") {
99
+ state = "code";
100
+ output += char;
101
+ } else {
102
+ output += " ";
103
+ }
104
+ continue;
105
+ }
106
+ if (state === "block") {
107
+ if (char === "*" && next === "/") {
108
+ output += " ";
109
+ index += 1;
110
+ state = "code";
111
+ } else {
112
+ output += char === "\n" ? "\n" : " ";
113
+ }
114
+ continue;
115
+ }
116
+ if (state === "code" && char === "/" && next === "/") {
117
+ output += " ";
118
+ index += 1;
119
+ state = "line";
120
+ continue;
121
+ }
122
+ if (state === "code" && char === "/" && next === "*") {
123
+ output += " ";
124
+ index += 1;
125
+ state = "block";
126
+ continue;
127
+ }
128
+ if (state === "code" && char === "'") state = "single";
129
+ else if (state === "code" && char === '"') state = "double";
130
+ else if (state === "code" && char === "`") state = "template";
131
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
132
+ state = "code";
133
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
134
+ state = "code";
135
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
136
+ state = "code";
137
+ }
138
+ output += char;
139
+ }
140
+ return output;
141
+ }
142
+ function codePositions(source) {
143
+ const positions = Array.from({ length: source.length }, () => false);
144
+ let state = "code";
145
+ for (let index = 0; index < source.length; index += 1) {
146
+ const char = source[index];
147
+ if (state === "code") positions[index] = true;
148
+ if (state === "code" && char === "'") state = "single";
149
+ else if (state === "code" && char === '"') state = "double";
150
+ else if (state === "code" && char === "`") state = "template";
151
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
152
+ state = "code";
153
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
154
+ state = "code";
155
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
156
+ state = "code";
157
+ }
158
+ }
159
+ return positions;
160
+ }
161
+ function importedModuleSpecifiers(source) {
162
+ const clean = withoutComments(source);
163
+ const positions = codePositions(clean);
164
+ const modules = [];
165
+ const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
166
+ for (const match of clean.matchAll(pattern)) {
167
+ if (positions[match.index]) modules.push(match[1]);
168
+ }
169
+ return modules;
170
+ }
171
+ function productionFileImportsExample(file, projectRoot) {
172
+ const exampleRoot = (0, import_node_path.join)(projectRoot, "src/game/example");
173
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
174
+ (moduleName) => {
175
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
176
+ if (!moduleName.startsWith(".")) return false;
177
+ const target = (0, import_node_path.resolve)((0, import_node_path.dirname)(file), moduleName);
178
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path.sep}`);
179
+ }
180
+ );
181
+ }
182
+ function importsExampleAlias(source) {
183
+ return importedModuleSpecifiers(source).some(
184
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
185
+ );
186
+ }
187
+ function namedImports(source, moduleName) {
188
+ const names = /* @__PURE__ */ new Set();
189
+ const clean = withoutComments(source);
190
+ const positions = codePositions(clean);
191
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
192
+ const pattern = new RegExp(
193
+ `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
194
+ "gm"
195
+ );
196
+ for (const match of clean.matchAll(pattern)) {
197
+ if (!positions[match.index]) continue;
198
+ for (const specifier of match[1].split(",")) {
199
+ const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
200
+ if (imported) names.add(imported);
201
+ }
202
+ }
203
+ return names;
204
+ }
205
+ function containsAny(values, expected) {
206
+ return [...values].some((value) => expected.has(value));
207
+ }
208
+ function declaresObserve(source) {
209
+ const clean = withoutComments(source);
210
+ const positions = codePositions(clean);
211
+ for (const match of clean.matchAll(/\bobserve\s*:/g)) {
212
+ if (positions[match.index]) return true;
213
+ }
214
+ return false;
215
+ }
216
+ function auditReactAuthoritativePlaythrough(projectRoot) {
217
+ const clockFiles = [];
218
+ const controllerFiles = [];
219
+ const productionFiles = sourceFiles(projectRoot);
220
+ for (const file of productionFiles) {
221
+ const imports = namedImports(
222
+ (0, import_node_fs.readFileSync)(file, "utf8"),
223
+ REACT_RUNTIME_ENTRY
224
+ );
225
+ const projectPath = (0, import_node_path.relative)(projectRoot, file).replaceAll("\\", "/");
226
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
227
+ if (containsAny(imports, CONTROLLER_IMPORTS))
228
+ controllerFiles.push(projectPath);
229
+ }
230
+ const appPath = (0, import_node_path.join)(projectRoot, PRODUCTION_APP);
231
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
232
+ const productionUsesExample = productionFiles.some(
233
+ (file) => productionFileImportsExample(file, projectRoot)
234
+ );
235
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path.relative)(projectRoot, file).replaceAll("\\", "/"));
236
+ const issues = [];
237
+ if (staleExampleTestFiles.length > 0) {
238
+ issues.push(
239
+ `Production ${PRODUCTION_APP} no longer imports ${EXAMPLE_IMPORT_PREFIX}*, but runnable product tests still do: ${staleExampleTestFiles.join(", ")}. Replace those CollectGame tests with tests for the production game or delete genuinely inapplicable slots. Teaching examples under tests/examples/**/*.example.* remain allowed.`
240
+ );
241
+ }
242
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
243
+ return {
244
+ ok: issues.length === 0,
245
+ issues,
246
+ clockFiles,
247
+ controllerFiles,
248
+ productionEntryExists,
249
+ productionUsesExample,
250
+ staleExampleTestFiles
251
+ };
252
+ }
253
+ const testPath = (0, import_node_path.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
254
+ const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
255
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
256
+ const hasObserve = declaresObserve(testSource);
257
+ if (!hasObserve) {
258
+ issues.push(
259
+ `${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
260
+ );
261
+ }
262
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
263
+ issues.push(
264
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
265
+ );
266
+ }
267
+ return {
268
+ ok: issues.length === 0,
269
+ issues,
270
+ clockFiles,
271
+ controllerFiles,
272
+ productionEntryExists,
273
+ productionUsesExample,
274
+ staleExampleTestFiles
275
+ };
276
+ }
44
277
 
45
278
  // src/react/react-playthrough.ts
46
279
  var import_react2 = require("@testing-library/react");
47
280
  var import_user_event = __toESM(require("@testing-library/user-event"));
48
281
  var import_vitest = require("vitest");
49
282
 
283
+ // src/react/react-error-diagnostics.ts
284
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
285
+ function truncate(value) {
286
+ const trimmed = value.trim();
287
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
288
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
289
+ }
290
+ function safeJson(value) {
291
+ const seen = /* @__PURE__ */ new WeakSet();
292
+ try {
293
+ return JSON.stringify(value, (_key, nested) => {
294
+ if (typeof nested === "bigint") return `${nested}n`;
295
+ if (typeof nested === "function") {
296
+ return `Function<${nested.name || "anonymous"}>`;
297
+ }
298
+ if (typeof nested === "symbol") return nested.toString();
299
+ if (nested && typeof nested === "object") {
300
+ if (seen.has(nested)) return "[Circular]";
301
+ seen.add(nested);
302
+ }
303
+ return nested;
304
+ });
305
+ } catch {
306
+ return void 0;
307
+ }
308
+ }
309
+ function collectEntries(value, fallbackCode, seen) {
310
+ if (typeof value === "string") {
311
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
312
+ }
313
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
314
+ return [{ code: fallbackCode, message: String(value) }];
315
+ }
316
+ if (typeof value === "function") {
317
+ return [
318
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
319
+ ];
320
+ }
321
+ if (seen.has(value)) return [];
322
+ seen.add(value);
323
+ if (Array.isArray(value)) {
324
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
325
+ }
326
+ const record = value;
327
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
328
+ const entries = [];
329
+ if (typeof record.message === "string" && record.message.trim()) {
330
+ entries.push({ code, message: truncate(record.message) });
331
+ }
332
+ if (record.cause !== void 0) {
333
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
334
+ }
335
+ if (Array.isArray(record.errors)) {
336
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
337
+ }
338
+ if (entries.length > 0) return entries;
339
+ if (typeof record.stack === "string" && record.stack.trim()) {
340
+ return [{ code, message: truncate(record.stack) }];
341
+ }
342
+ const json = safeJson(value);
343
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
344
+ }
345
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
346
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
347
+ const keys = /* @__PURE__ */ new Set();
348
+ return entries.filter((entry) => {
349
+ const key = `${entry.code}\0${entry.message}`;
350
+ if (keys.has(key)) return false;
351
+ keys.add(key);
352
+ return true;
353
+ });
354
+ }
355
+ function createFailureDiagnostic(source, value) {
356
+ return { source, entries: extractFailureEntries(value) };
357
+ }
358
+ function appendCurrentAttemptFailures(current, runnerValue) {
359
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
360
+ if (!current || current.entries.length === 0) return runner;
361
+ const primary = current.entries[0];
362
+ const currentStart = runner.entries.findIndex(
363
+ (entry) => entry.code === primary.code && entry.message === primary.message
364
+ );
365
+ if (currentStart < 0) return current;
366
+ return {
367
+ source: current.source,
368
+ entries: extractFailureEntries([
369
+ ...current.entries,
370
+ ...runner.entries.slice(currentStart + 1)
371
+ ])
372
+ };
373
+ }
374
+ function codedError(code, message) {
375
+ const error = new Error(message);
376
+ error.code = code;
377
+ return error;
378
+ }
379
+
50
380
  // src/react/react-playthrough-core.ts
51
381
  var import_react = require("@testing-library/react");
52
382
  function throwIfAborted(signal) {
53
383
  if (!signal?.aborted) return;
54
384
  if (signal.reason instanceof Error) throw signal.reason;
55
- throw new Error("Playthrough advancement was cancelled.", {
56
- cause: signal.reason
57
- });
385
+ throw codedError(
386
+ "PLAYTHROUGH_CANCELLED",
387
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
388
+ );
58
389
  }
59
390
  function formatDiagnostics(read) {
60
391
  if (!read) return void 0;
@@ -70,7 +401,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
70
401
  if (waiverReason === void 0) return void 0;
71
402
  const reason = waiverReason.trim();
72
403
  if (reason.length < 20) {
73
- throw new Error(
404
+ throw codedError(
405
+ "INVALID_PLAYTHROUGH_WAIVER",
74
406
  "playthroughTest.skip reason must contain at least 20 characters."
75
407
  );
76
408
  }
@@ -79,7 +411,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
79
411
  async function runBoundedUntil(condition, options = {}) {
80
412
  const maxSteps = options.maxSteps ?? 120;
81
413
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
82
- throw new RangeError(
414
+ throw codedError(
415
+ "INVALID_STEP_BOUND",
83
416
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
84
417
  );
85
418
  }
@@ -96,7 +429,8 @@ async function runBoundedUntil(condition, options = {}) {
96
429
  const diagnostics = formatDiagnostics(options.diagnostics);
97
430
  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
431
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
99
- throw new Error(
432
+ throw codedError(
433
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
100
434
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
101
435
  );
102
436
  }
@@ -149,14 +483,18 @@ function sampleObservedState(observe, stage) {
149
483
  try {
150
484
  value = observe();
151
485
  } catch (error) {
152
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
486
+ throw codedError(
487
+ "OBSERVE_FAILED",
488
+ `observe() threw at ${stage}: ${String(error)}`
489
+ );
153
490
  }
154
491
  try {
155
492
  const fingerprint = JSON.stringify(value);
156
493
  if (fingerprint === void 0) throw new Error("unsupported value");
157
494
  return { fingerprint, formatted: formatState(fingerprint) };
158
495
  } catch {
159
- throw new Error(
496
+ throw codedError(
497
+ "OBSERVE_NOT_SERIALIZABLE",
160
498
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
161
499
  );
162
500
  }
@@ -169,7 +507,7 @@ function createEvidence() {
169
507
  return { domInputEvents: 0, stages: [], verified: false };
170
508
  }
171
509
  function createMetadata(waiverReason) {
172
- return { version: 4, waiverReason, evidence: createEvidence() };
510
+ return { version: 5, waiverReason, evidence: createEvidence() };
173
511
  }
174
512
  function stageLabel(kind, name) {
175
513
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -205,6 +543,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
205
543
  async ({ annotate, expect, onTestFailed, signal }) => {
206
544
  metadata.evidence = createEvidence();
207
545
  metadata.trace = void 0;
546
+ metadata.failure = void 0;
208
547
  const evidence = metadata.evidence;
209
548
  let entered = false;
210
549
  let finished = false;
@@ -235,8 +574,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
235
574
  return "stages=none";
236
575
  }
237
576
  };
238
- onTestFailed(() => {
577
+ onTestFailed(({ task }) => {
239
578
  metadata.trace ??= captureFailureTrace();
579
+ metadata.failure = appendCurrentAttemptFailures(
580
+ metadata.failure,
581
+ task.result?.errors ?? []
582
+ );
240
583
  });
241
584
  for (const event of INPUT_EVENTS) {
242
585
  document.addEventListener(event, recordInput, true);
@@ -246,7 +589,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
246
589
  try {
247
590
  const view = (0, import_react2.render)(element);
248
591
  if (view.container.childNodes.length === 0) {
249
- throw new Error(
592
+ throw codedError(
593
+ "PRODUCTION_ENTRY_NOT_RENDERED",
250
594
  "playthroughTest must render the production game entry."
251
595
  );
252
596
  }
@@ -275,17 +619,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
275
619
  const executeStage = async (name, kind, stage) => {
276
620
  const normalizedName = name.trim();
277
621
  if (normalizedName.length === 0) {
278
- throw new Error("playthrough stage names must be non-empty strings.");
622
+ throw codedError(
623
+ "INVALID_STAGE_NAME",
624
+ "playthrough stage names must be non-empty strings."
625
+ );
279
626
  }
280
627
  if (evidence.stages.some(
281
628
  (completed) => completed.name === normalizedName
282
629
  )) {
283
- throw new Error(
630
+ throw codedError(
631
+ "DUPLICATE_STAGE_NAME",
284
632
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
285
633
  );
286
634
  }
287
635
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
288
- throw new Error(
636
+ throw codedError(
637
+ "RESERVED_STAGE_NAME",
289
638
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
290
639
  );
291
640
  }
@@ -293,12 +642,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
293
642
  activeStage = { name: normalizedName, kind, before };
294
643
  stepTrace = { bound: stage.maxSteps ?? 120 };
295
644
  if (stage.step && !playthroughOptions?.observe) {
296
- throw new Error(
645
+ throw codedError(
646
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
297
647
  `${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
648
  );
299
649
  }
300
650
  if (stage.until()) {
301
- throw new Error(
651
+ throw codedError(
652
+ "STAGE_OUTCOME_ALREADY_REACHED",
302
653
  `${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
654
  );
304
655
  }
@@ -312,12 +663,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
312
663
  acceptingStageInput = false;
313
664
  }
314
665
  if (evidence.domInputEvents === inputsBefore) {
315
- throw new Error(
666
+ throw codedError(
667
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
316
668
  `${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
669
  );
318
670
  }
319
671
  if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
320
- throw new Error(
672
+ throw codedError(
673
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
321
674
  `${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
675
  );
323
676
  }
@@ -336,7 +689,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
336
689
  });
337
690
  stepTrace = { bound: stepBound, completed: steps };
338
691
  if (!stage.act && advancedSteps === 0) {
339
- throw new Error(
692
+ throw codedError(
693
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
340
694
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
341
695
  );
342
696
  }
@@ -344,14 +698,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
344
698
  await stage.assert({ expect, user, view });
345
699
  const assertions = expect.getState().assertionCalls - assertionsBefore;
346
700
  if (assertions === 0) {
347
- throw new Error(
701
+ throw codedError(
702
+ "STAGE_ASSERTION_MISSING",
348
703
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
349
704
  );
350
705
  }
351
706
  const after = sampleState(`after ${normalizedName}`);
352
707
  if (after.fingerprint === before.fingerprint) {
353
708
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
354
- throw new Error(
709
+ throw codedError(
710
+ "STAGE_STATE_UNCHANGED",
355
711
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
356
712
  );
357
713
  }
@@ -373,27 +729,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
373
729
  user,
374
730
  async enter(stage) {
375
731
  if (entered) {
376
- throw new Error("enter may only be called once.");
732
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
377
733
  }
378
734
  if (evidence.stages.length > 0) {
379
- throw new Error("enter must be the first playthrough stage.");
735
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
380
736
  }
381
737
  await executeStage("entered", "entered", stage);
382
738
  entered = true;
383
739
  },
384
740
  async milestone(name, stage) {
385
741
  if (!entered) {
386
- throw new Error("milestone must follow enter.");
742
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
387
743
  }
388
744
  if (finished) {
389
- throw new Error("milestone cannot run after finish.");
745
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
390
746
  }
391
747
  await executeStage(name, "milestone", stage);
392
748
  },
393
749
  async finish(name, stage) {
394
- if (!entered) throw new Error("finish must follow enter.");
750
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
395
751
  if (finished) {
396
- throw new Error("finish may only be called once.");
752
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
397
753
  }
398
754
  await executeStage(name, stage.kind, stage);
399
755
  finished = true;
@@ -402,19 +758,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
402
758
  const milestones = evidence.stages.filter(
403
759
  (stage) => stage.kind === "milestone"
404
760
  );
405
- if (!entered) throw new Error("playthroughTest must call enter once.");
761
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
406
762
  if (milestones.length < MIN_MILESTONES) {
407
- throw new Error(
763
+ throw codedError(
764
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
408
765
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
409
766
  );
410
767
  }
411
768
  if (!finished) {
412
- throw new Error(
769
+ throw codedError(
770
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
413
771
  'playthroughTest must call finish with kind "progress" or "terminal".'
414
772
  );
415
773
  }
416
774
  if (evidence.stages.length < MIN_STAGES) {
417
- throw new Error(
775
+ throw codedError(
776
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
418
777
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
419
778
  );
420
779
  }
@@ -422,6 +781,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
422
781
  } catch (error) {
423
782
  const trace = captureFailureTrace();
424
783
  metadata.trace = trace;
784
+ metadata.failure = createFailureDiagnostic("playthrough", error);
425
785
  try {
426
786
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
427
787
  } catch {
@@ -501,10 +861,36 @@ function auditReactPlaythroughRun(tests) {
501
861
  // src/react/react-playthrough-reporter.ts
502
862
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
503
863
  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.";
864
+ function assessProductTestAlignment(projectRoot) {
865
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
866
+ if (audit.staleExampleTestFiles.length > 0) {
867
+ return { status: "FAILED", cause: audit.issues[0] };
868
+ }
869
+ if (!audit.productionEntryExists) {
870
+ return {
871
+ status: "NOT_VERIFIED",
872
+ cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
873
+ };
874
+ }
875
+ if (audit.productionUsesExample) {
876
+ return {
877
+ status: "NOT_VERIFIED",
878
+ cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
879
+ };
880
+ }
881
+ return { status: "PASS" };
882
+ }
504
883
  function isMetadata(value) {
505
884
  if (!value || typeof value !== "object") return false;
506
885
  const metadata = value;
507
- if (metadata.version !== 4) return false;
886
+ if (metadata.version !== 5) return false;
887
+ if (metadata.failure !== void 0) {
888
+ if (!metadata.failure || typeof metadata.failure !== "object" || typeof metadata.failure.source !== "string" || !Array.isArray(metadata.failure.entries) || !metadata.failure.entries.every(
889
+ (entry) => Boolean(entry) && typeof entry === "object" && typeof entry.code === "string" && typeof entry.message === "string"
890
+ )) {
891
+ return false;
892
+ }
893
+ }
508
894
  const evidence = metadata.evidence;
509
895
  if (!evidence || typeof evidence !== "object") return false;
510
896
  return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
@@ -519,10 +905,72 @@ function toAuditInput(test2) {
519
905
  metadata: isMetadata(metadata) ? metadata : void 0
520
906
  };
521
907
  }
908
+ function findPendingProductTests(modules, projectRoot) {
909
+ return modules.flatMap((module2) => {
910
+ const file = (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
911
+ if (file.split("/").includes("examples")) return [];
912
+ const tests = [...module2.children.allTests()];
913
+ const pending = tests.filter((test2) => {
914
+ const mode = test2.options.mode;
915
+ if (mode !== "todo" && mode !== "skip") return false;
916
+ const metadata = test2.meta().reactPlaythrough;
917
+ const approvedWaiver = mode === "skip" && isMetadata(metadata) && Boolean(metadata.waiverReason);
918
+ return !approvedWaiver;
919
+ });
920
+ const fileOnlyContainsPendingTests = tests.length > 0 && pending.length === tests.length;
921
+ return pending.map((test2) => ({
922
+ file,
923
+ test: test2.fullName,
924
+ mode: test2.options.mode,
925
+ fileOnlyContainsPendingTests
926
+ }));
927
+ });
928
+ }
929
+ function formatPendingProductTestReport(pending) {
930
+ if (pending.length === 0) return void 0;
931
+ const lines = [
932
+ "REACT_FOCUSED_TESTS: FAILED",
933
+ "CAUSE_CODE: TODO_OR_SKIP_TESTS",
934
+ "CAUSE: Product tests still contain explicit todo/skip cases."
935
+ ];
936
+ const reportedIncompleteFiles = /* @__PURE__ */ new Set();
937
+ for (const item of pending) {
938
+ if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
939
+ lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
940
+ lines.push(
941
+ `FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
942
+ );
943
+ reportedIncompleteFiles.add(item.file);
944
+ }
945
+ lines.push(`FILE: ${item.file}`);
946
+ lines.push(`TEST: ${item.test}`);
947
+ lines.push(`MODE: ${item.mode}`);
948
+ }
949
+ lines.push(
950
+ "NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
951
+ );
952
+ return `
953
+ ${lines.join("\n")}`;
954
+ }
522
955
  function firstLine(value) {
523
956
  if (typeof value !== "string") return void 0;
524
957
  return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
525
958
  }
959
+ function selectReactFailure(values, errorRecordCount = values.length) {
960
+ const entries = extractFailureEntries(values);
961
+ if (entries.length === 0) {
962
+ return {
963
+ code: "MISSING_FAILURE_DETAILS",
964
+ cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
965
+ rawCause: "",
966
+ related: []
967
+ };
968
+ }
969
+ const primary = entries[0];
970
+ const rawCause = primary.message;
971
+ const cause = firstLine(rawCause) ?? rawCause;
972
+ return { code: primary.code, cause, rawCause, related: entries.slice(1) };
973
+ }
526
974
  function failureHint(value) {
527
975
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
528
976
  (match) => match[1]
@@ -556,37 +1004,57 @@ function truncateReporterLine(value, limit) {
556
1004
  }
557
1005
  function toModuleResult(module2, projectRoot) {
558
1006
  const tests = [...module2.children.allTests()];
559
- const moduleErrors = module2.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
1007
+ const moduleFailure = selectReactFailure(
1008
+ module2.errors(),
1009
+ module2.errors().length
1010
+ );
1011
+ const moduleErrors = extractFailureEntries(module2.errors()).map(
1012
+ (entry) => firstLine(entry.message) ?? entry.message
1013
+ );
560
1014
  const errors = [
561
1015
  ...moduleErrors,
562
1016
  ...tests.flatMap(
563
- (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
1017
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
1018
+ (entry) => firstLine(entry.message) ?? entry.message
1019
+ )
564
1020
  )
565
- ].filter((message) => Boolean(message));
1021
+ ];
566
1022
  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";
1023
+ const metadata = test2.meta().reactPlaythrough;
1024
+ const testErrors = test2.result().errors ?? [];
1025
+ const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
1026
+ const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
1027
+ const selected = selectReactFailure(
1028
+ [...attemptErrors, ...module2.errors()],
1029
+ testErrors.length + module2.errors().length
1030
+ );
568
1031
  return {
569
1032
  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),
1033
+ causeCode: selected.code,
1034
+ cause: selected.cause,
1035
+ related: selected.related,
1036
+ location: test2.location ? `${(0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
1037
+ hint: failureHint(selected.rawCause),
573
1038
  trace: failureTrace(test2)
574
1039
  };
575
1040
  });
576
1041
  if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
577
- const raw = module2.errors()[0]?.message ?? "Module failed to load";
578
1042
  failures.push({
579
1043
  test: "<collection>",
580
- cause: firstLine(raw) ?? "Module failed to load",
581
- location: errorLocation(raw),
582
- hint: failureHint(raw)
1044
+ causeCode: moduleFailure.code,
1045
+ cause: moduleFailure.cause,
1046
+ related: moduleFailure.related,
1047
+ location: errorLocation(moduleFailure.rawCause),
1048
+ hint: failureHint(moduleFailure.rawCause)
583
1049
  });
584
1050
  }
585
1051
  return {
586
- file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
1052
+ file: (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
587
1053
  state: module2.state(),
588
1054
  errors,
589
1055
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
1056
+ primaryCauseCode: failures[0]?.causeCode,
1057
+ relatedErrors: failures[0]?.related,
590
1058
  tests: tests.map(toAuditInput),
591
1059
  failures
592
1060
  };
@@ -603,7 +1071,14 @@ function formatReactFailureSummary(modules) {
603
1071
  for (const [index, failure] of failures.entries()) {
604
1072
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
605
1073
  lines.push(`TEST: ${failure.test}`);
1074
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
606
1075
  lines.push(`CAUSE: ${failure.cause}`);
1076
+ for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
1077
+ lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
1078
+ lines.push(
1079
+ `RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
1080
+ );
1081
+ }
607
1082
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
608
1083
  if (failure.location) lines.push(`AT: ${failure.location}`);
609
1084
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
@@ -611,32 +1086,36 @@ function formatReactFailureSummary(modules) {
611
1086
  lines.push("TEST_RESULT: FAIL");
612
1087
  return lines;
613
1088
  }
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.";
1089
+ function repairGuidance(code) {
1090
+ switch (code) {
1091
+ case "GAME_SNAPSHOT_REFERENCE_REUSED":
1092
+ 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.";
1093
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP":
1094
+ 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.";
1095
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS":
1096
+ 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.";
1097
+ case "STAGE_OUTCOME_ALREADY_REACHED":
1098
+ 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.";
1099
+ case "STAGE_STATE_UNCHANGED":
1100
+ 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.";
1101
+ case "PLAYTHROUGH_BOUND_EXHAUSTED":
1102
+ 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.";
1103
+ case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
1104
+ 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.";
1105
+ case "INVALID_STAGE_ORDER":
1106
+ case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
1107
+ case "INVALID_STAGE_NAME":
1108
+ case "DUPLICATE_STAGE_NAME":
1109
+ case "RESERVED_STAGE_NAME":
1110
+ case "PRODUCTION_INPUT_NOT_DISPATCHED":
1111
+ case "AUTONOMOUS_STAGE_NOT_ADVANCED":
1112
+ case "STAGE_ASSERTION_MISSING":
1113
+ 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.";
1114
+ case "MISSING_FAILURE_DETAILS":
1115
+ 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.";
1116
+ default:
1117
+ return "Fix the first reported CAUSE, then rerun the same test. RELATED entries preserve the remaining Vitest errors in their original order.";
632
1118
  }
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
1119
  }
641
1120
  function assessReactPlaythroughReport(input) {
642
1121
  const base = { file: input.expectedFile };
@@ -652,6 +1131,7 @@ function assessReactPlaythroughReport(input) {
652
1131
  return {
653
1132
  ...base,
654
1133
  status: "FAILED",
1134
+ causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
655
1135
  cause: "The required production playthrough test file does not exist.",
656
1136
  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
1137
  failsRun: true
@@ -675,6 +1155,7 @@ function assessReactPlaythroughReport(input) {
675
1155
  return {
676
1156
  ...base,
677
1157
  status: "NOT_RUN",
1158
+ causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
678
1159
  cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
679
1160
  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
1161
  failsRun: true
@@ -687,8 +1168,12 @@ function assessReactPlaythroughReport(input) {
687
1168
  return {
688
1169
  ...base,
689
1170
  status: "FAILED",
1171
+ causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
690
1172
  cause,
691
- next: repairGuidance(cause),
1173
+ related: productionModule.relatedErrors,
1174
+ next: repairGuidance(
1175
+ productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
1176
+ ),
692
1177
  failsRun: true
693
1178
  };
694
1179
  }
@@ -702,9 +1187,21 @@ function assessReactPlaythroughReport(input) {
702
1187
  }
703
1188
  return { ...base, status: "PASS", failsRun: false };
704
1189
  }
705
- function formatReactPlaythroughReport(report) {
706
- const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
1190
+ function formatReactPlaythroughReport(report, alignment) {
1191
+ const lines = [
1192
+ `REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
1193
+ `PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
1194
+ `FILE: ${report.file}`
1195
+ ];
1196
+ if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
1197
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
707
1198
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
1199
+ for (const [index, related] of (report.related ?? []).entries()) {
1200
+ lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
1201
+ lines.push(
1202
+ `RELATED_${index + 1}: ${firstLine(related.message) ?? related.message}`
1203
+ );
1204
+ }
708
1205
  if (report.waiverReasons?.length) {
709
1206
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
710
1207
  }
@@ -719,7 +1216,7 @@ var ReactPlaythroughReporter = class {
719
1216
  /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
720
1217
  constructor(projectRoot) {
721
1218
  this.projectRoot = projectRoot;
722
- this.expectedModuleId = (0, import_node_path.resolve)(projectRoot, this.expectedFile);
1219
+ this.expectedModuleId = (0, import_node_path2.resolve)(projectRoot, this.expectedFile);
723
1220
  }
724
1221
  projectRoot;
725
1222
  expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
@@ -729,7 +1226,7 @@ var ReactPlaythroughReporter = class {
729
1226
  /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
730
1227
  onTestRunStart(specifications) {
731
1228
  this.expectedFileScheduled = specifications.some(
732
- (specification) => (0, import_node_path.resolve)(specification.moduleId) === this.expectedModuleId
1229
+ (specification) => (0, import_node_path2.resolve)(specification.moduleId) === this.expectedModuleId
733
1230
  );
734
1231
  this.focusedSelection = specifications.some(
735
1232
  (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
@@ -737,18 +1234,25 @@ var ReactPlaythroughReporter = class {
737
1234
  }
738
1235
  /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
739
1236
  onTestRunEnd(testModules, unhandledErrors) {
1237
+ const pendingProductTests = findPendingProductTests(
1238
+ testModules,
1239
+ this.projectRoot
1240
+ );
740
1241
  const report = assessReactPlaythroughReport({
741
1242
  expectedFile: this.expectedFile,
742
- expectedFileExists: (0, import_node_fs.existsSync)(this.expectedModuleId),
1243
+ expectedFileExists: (0, import_node_fs2.existsSync)(this.expectedModuleId),
743
1244
  expectedFileScheduled: this.expectedFileScheduled,
744
1245
  focusedSelection: this.focusedSelection,
745
1246
  modules: testModules.map(
746
1247
  (module2) => toModuleResult(module2, this.projectRoot)
747
1248
  ),
748
- unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
1249
+ unhandledErrors: extractFailureEntries(unhandledErrors).map(
1250
+ (entry) => firstLine(entry.message) ?? entry.message
1251
+ )
749
1252
  });
750
- const output = formatReactPlaythroughReport(report);
751
- if (report.failsRun) {
1253
+ const alignment = assessProductTestAlignment(this.projectRoot);
1254
+ const output = formatReactPlaythroughReport(report, alignment);
1255
+ if (report.failsRun || alignment.status === "FAILED") {
752
1256
  console.error(output);
753
1257
  process.exitCode = 1;
754
1258
  } else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
@@ -756,10 +1260,15 @@ var ReactPlaythroughReporter = class {
756
1260
  } else {
757
1261
  console.log(output);
758
1262
  }
1263
+ const pendingOutput = formatPendingProductTestReport(pendingProductTests);
1264
+ if (pendingOutput) {
1265
+ console.error(pendingOutput);
1266
+ process.exitCode = 1;
1267
+ }
759
1268
  const summary = formatReactFailureSummary(
760
1269
  testModules.map((module2) => toModuleResult(module2, this.projectRoot))
761
1270
  );
762
- if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
1271
+ if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
763
1272
  summary[0] = "TEST_RESULT: FAIL";
764
1273
  }
765
1274
  console.log(`
@@ -775,16 +1284,16 @@ function getJSDOMWorkerExecArgv() {
775
1284
 
776
1285
  // src/react-vitest-config.ts
777
1286
  function resolvePhaser3BrowserEntry(projectRoot) {
778
- const manifestPath = (0, import_node_path2.resolve)(projectRoot, "node_modules/phaser/package.json");
779
- if (!(0, import_node_fs2.existsSync)(manifestPath)) return void 0;
1287
+ const manifestPath = (0, import_node_path3.resolve)(projectRoot, "node_modules/phaser/package.json");
1288
+ if (!(0, import_node_fs3.existsSync)(manifestPath)) return void 0;
780
1289
  try {
781
- const manifest = JSON.parse((0, import_node_fs2.readFileSync)(manifestPath, "utf8"));
1290
+ const manifest = JSON.parse((0, import_node_fs3.readFileSync)(manifestPath, "utf8"));
782
1291
  if (!manifest.version?.startsWith("3.")) return void 0;
783
- const browserEntry = (0, import_node_path2.resolve)(
784
- (0, import_node_path2.dirname)(manifestPath),
1292
+ const browserEntry = (0, import_node_path3.resolve)(
1293
+ (0, import_node_path3.dirname)(manifestPath),
785
1294
  manifest.browser ?? "dist/phaser.js"
786
1295
  );
787
- return (0, import_node_fs2.existsSync)(browserEntry) ? browserEntry : void 0;
1296
+ return (0, import_node_fs3.existsSync)(browserEntry) ? browserEntry : void 0;
788
1297
  } catch {
789
1298
  return void 0;
790
1299
  }
@@ -799,7 +1308,7 @@ function defineReactGameVitestConfig(options) {
799
1308
  alias: {
800
1309
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
801
1310
  ...options.aliases,
802
- "@": (0, import_node_path2.resolve)(options.projectRoot, "src")
1311
+ "@": (0, import_node_path3.resolve)(options.projectRoot, "src")
803
1312
  }
804
1313
  },
805
1314
  test: {