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.
@@ -1,26 +1,357 @@
1
1
  // src/react-vitest-config.ts
2
- import { existsSync as existsSync2, readFileSync } from "fs";
3
- import { dirname, resolve as resolve2 } from "path";
2
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
3
+ import { dirname as dirname2, resolve as resolve3 } from "path";
4
4
  import { defineConfig } from "vitest/config";
5
5
 
6
6
  // src/react/react-playthrough-reporter.ts
7
- import { existsSync } from "fs";
8
- import { relative, resolve } from "path";
7
+ import { existsSync as existsSync2 } from "fs";
8
+ import { relative as relative2, resolve as resolve2 } from "path";
9
9
  import { stripVTControlCharacters } from "util";
10
10
 
11
+ // src/cli/react-authoritative-playthrough.ts
12
+ import { existsSync, readdirSync, readFileSync } from "fs";
13
+ import { dirname, join, relative, resolve, sep } from "path";
14
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
15
+ var PRODUCTION_APP = "src/App.tsx";
16
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
17
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
18
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
19
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
20
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
21
+ "useGameController",
22
+ "useOwnedGameController"
23
+ ]);
24
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
25
+ function runnableTestFiles(root, directory = join(root, "tests")) {
26
+ if (!existsSync(directory)) return [];
27
+ const files = [];
28
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
29
+ const path = join(directory, entry.name);
30
+ if (entry.isDirectory()) {
31
+ files.push(...runnableTestFiles(root, path));
32
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
33
+ files.push(path);
34
+ }
35
+ }
36
+ return files;
37
+ }
38
+ function extension(path) {
39
+ const index = path.lastIndexOf(".");
40
+ return index < 0 ? "" : path.slice(index);
41
+ }
42
+ function sourceFiles(root, directory = join(root, "src")) {
43
+ if (!existsSync(directory)) return [];
44
+ const files = [];
45
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
46
+ const path = join(directory, entry.name);
47
+ const projectPath = relative(root, path).replaceAll("\\", "/");
48
+ if (entry.isDirectory()) {
49
+ if (projectPath === "src/game/example") continue;
50
+ files.push(...sourceFiles(root, path));
51
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
52
+ files.push(path);
53
+ }
54
+ }
55
+ return files;
56
+ }
57
+ function withoutComments(source) {
58
+ let output = "";
59
+ let state = "code";
60
+ for (let index = 0; index < source.length; index += 1) {
61
+ const char = source[index];
62
+ const next = source[index + 1];
63
+ if (state === "line") {
64
+ if (char === "\n") {
65
+ state = "code";
66
+ output += char;
67
+ } else {
68
+ output += " ";
69
+ }
70
+ continue;
71
+ }
72
+ if (state === "block") {
73
+ if (char === "*" && next === "/") {
74
+ output += " ";
75
+ index += 1;
76
+ state = "code";
77
+ } else {
78
+ output += char === "\n" ? "\n" : " ";
79
+ }
80
+ continue;
81
+ }
82
+ if (state === "code" && char === "/" && next === "/") {
83
+ output += " ";
84
+ index += 1;
85
+ state = "line";
86
+ continue;
87
+ }
88
+ if (state === "code" && char === "/" && next === "*") {
89
+ output += " ";
90
+ index += 1;
91
+ state = "block";
92
+ continue;
93
+ }
94
+ if (state === "code" && char === "'") state = "single";
95
+ else if (state === "code" && char === '"') state = "double";
96
+ else if (state === "code" && char === "`") state = "template";
97
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
98
+ state = "code";
99
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
100
+ state = "code";
101
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
102
+ state = "code";
103
+ }
104
+ output += char;
105
+ }
106
+ return output;
107
+ }
108
+ function codePositions(source) {
109
+ const positions = Array.from({ length: source.length }, () => false);
110
+ let state = "code";
111
+ for (let index = 0; index < source.length; index += 1) {
112
+ const char = source[index];
113
+ if (state === "code") positions[index] = true;
114
+ if (state === "code" && char === "'") state = "single";
115
+ else if (state === "code" && char === '"') state = "double";
116
+ else if (state === "code" && char === "`") state = "template";
117
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
118
+ state = "code";
119
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
120
+ state = "code";
121
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
122
+ state = "code";
123
+ }
124
+ }
125
+ return positions;
126
+ }
127
+ function importedModuleSpecifiers(source) {
128
+ const clean = withoutComments(source);
129
+ const positions = codePositions(clean);
130
+ const modules = [];
131
+ const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
132
+ for (const match of clean.matchAll(pattern)) {
133
+ if (positions[match.index]) modules.push(match[1]);
134
+ }
135
+ return modules;
136
+ }
137
+ function productionFileImportsExample(file, projectRoot) {
138
+ const exampleRoot = join(projectRoot, "src/game/example");
139
+ return importedModuleSpecifiers(readFileSync(file, "utf8")).some(
140
+ (moduleName) => {
141
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
142
+ if (!moduleName.startsWith(".")) return false;
143
+ const target = resolve(dirname(file), moduleName);
144
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${sep}`);
145
+ }
146
+ );
147
+ }
148
+ function importsExampleAlias(source) {
149
+ return importedModuleSpecifiers(source).some(
150
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
151
+ );
152
+ }
153
+ function namedImports(source, moduleName) {
154
+ const names = /* @__PURE__ */ new Set();
155
+ const clean = withoutComments(source);
156
+ const positions = codePositions(clean);
157
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
158
+ const pattern = new RegExp(
159
+ `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
160
+ "gm"
161
+ );
162
+ for (const match of clean.matchAll(pattern)) {
163
+ if (!positions[match.index]) continue;
164
+ for (const specifier of match[1].split(",")) {
165
+ const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
166
+ if (imported) names.add(imported);
167
+ }
168
+ }
169
+ return names;
170
+ }
171
+ function containsAny(values, expected) {
172
+ return [...values].some((value) => expected.has(value));
173
+ }
174
+ function declaresObserve(source) {
175
+ const clean = withoutComments(source);
176
+ const positions = codePositions(clean);
177
+ for (const match of clean.matchAll(/\bobserve\s*:/g)) {
178
+ if (positions[match.index]) return true;
179
+ }
180
+ return false;
181
+ }
182
+ function auditReactAuthoritativePlaythrough(projectRoot) {
183
+ const clockFiles = [];
184
+ const controllerFiles = [];
185
+ const productionFiles = sourceFiles(projectRoot);
186
+ for (const file of productionFiles) {
187
+ const imports = namedImports(
188
+ readFileSync(file, "utf8"),
189
+ REACT_RUNTIME_ENTRY
190
+ );
191
+ const projectPath = relative(projectRoot, file).replaceAll("\\", "/");
192
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
193
+ if (containsAny(imports, CONTROLLER_IMPORTS))
194
+ controllerFiles.push(projectPath);
195
+ }
196
+ const appPath = join(projectRoot, PRODUCTION_APP);
197
+ const productionEntryExists = existsSync(appPath);
198
+ const productionUsesExample = productionFiles.some(
199
+ (file) => productionFileImportsExample(file, projectRoot)
200
+ );
201
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias(readFileSync(file, "utf8"))).map((file) => relative(projectRoot, file).replaceAll("\\", "/"));
202
+ const issues = [];
203
+ if (staleExampleTestFiles.length > 0) {
204
+ issues.push(
205
+ `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.`
206
+ );
207
+ }
208
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
209
+ return {
210
+ ok: issues.length === 0,
211
+ issues,
212
+ clockFiles,
213
+ controllerFiles,
214
+ productionEntryExists,
215
+ productionUsesExample,
216
+ staleExampleTestFiles
217
+ };
218
+ }
219
+ const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
220
+ const testSource = existsSync(testPath) ? readFileSync(testPath, "utf8") : "";
221
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
222
+ const hasObserve = declaresObserve(testSource);
223
+ if (!hasObserve) {
224
+ issues.push(
225
+ `${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.`
226
+ );
227
+ }
228
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
229
+ issues.push(
230
+ `${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.`
231
+ );
232
+ }
233
+ return {
234
+ ok: issues.length === 0,
235
+ issues,
236
+ clockFiles,
237
+ controllerFiles,
238
+ productionEntryExists,
239
+ productionUsesExample,
240
+ staleExampleTestFiles
241
+ };
242
+ }
243
+
11
244
  // src/react/react-playthrough.ts
12
245
  import { render } from "@testing-library/react";
13
246
  import userEvent from "@testing-library/user-event";
14
247
  import { test } from "vitest";
15
248
 
249
+ // src/react/react-error-diagnostics.ts
250
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
251
+ function truncate(value) {
252
+ const trimmed = value.trim();
253
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
254
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
255
+ }
256
+ function safeJson(value) {
257
+ const seen = /* @__PURE__ */ new WeakSet();
258
+ try {
259
+ return JSON.stringify(value, (_key, nested) => {
260
+ if (typeof nested === "bigint") return `${nested}n`;
261
+ if (typeof nested === "function") {
262
+ return `Function<${nested.name || "anonymous"}>`;
263
+ }
264
+ if (typeof nested === "symbol") return nested.toString();
265
+ if (nested && typeof nested === "object") {
266
+ if (seen.has(nested)) return "[Circular]";
267
+ seen.add(nested);
268
+ }
269
+ return nested;
270
+ });
271
+ } catch {
272
+ return void 0;
273
+ }
274
+ }
275
+ function collectEntries(value, fallbackCode, seen) {
276
+ if (typeof value === "string") {
277
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
278
+ }
279
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
280
+ return [{ code: fallbackCode, message: String(value) }];
281
+ }
282
+ if (typeof value === "function") {
283
+ return [
284
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
285
+ ];
286
+ }
287
+ if (seen.has(value)) return [];
288
+ seen.add(value);
289
+ if (Array.isArray(value)) {
290
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
291
+ }
292
+ const record = value;
293
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
294
+ const entries = [];
295
+ if (typeof record.message === "string" && record.message.trim()) {
296
+ entries.push({ code, message: truncate(record.message) });
297
+ }
298
+ if (record.cause !== void 0) {
299
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
300
+ }
301
+ if (Array.isArray(record.errors)) {
302
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
303
+ }
304
+ if (entries.length > 0) return entries;
305
+ if (typeof record.stack === "string" && record.stack.trim()) {
306
+ return [{ code, message: truncate(record.stack) }];
307
+ }
308
+ const json = safeJson(value);
309
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
310
+ }
311
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
312
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
313
+ const keys = /* @__PURE__ */ new Set();
314
+ return entries.filter((entry) => {
315
+ const key = `${entry.code}\0${entry.message}`;
316
+ if (keys.has(key)) return false;
317
+ keys.add(key);
318
+ return true;
319
+ });
320
+ }
321
+ function createFailureDiagnostic(source, value) {
322
+ return { source, entries: extractFailureEntries(value) };
323
+ }
324
+ function appendCurrentAttemptFailures(current, runnerValue) {
325
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
326
+ if (!current || current.entries.length === 0) return runner;
327
+ const primary = current.entries[0];
328
+ const currentStart = runner.entries.findIndex(
329
+ (entry) => entry.code === primary.code && entry.message === primary.message
330
+ );
331
+ if (currentStart < 0) return current;
332
+ return {
333
+ source: current.source,
334
+ entries: extractFailureEntries([
335
+ ...current.entries,
336
+ ...runner.entries.slice(currentStart + 1)
337
+ ])
338
+ };
339
+ }
340
+ function codedError(code, message) {
341
+ const error = new Error(message);
342
+ error.code = code;
343
+ return error;
344
+ }
345
+
16
346
  // src/react/react-playthrough-core.ts
17
347
  import { act } from "@testing-library/react";
18
348
  function throwIfAborted(signal) {
19
349
  if (!signal?.aborted) return;
20
350
  if (signal.reason instanceof Error) throw signal.reason;
21
- throw new Error("Playthrough advancement was cancelled.", {
22
- cause: signal.reason
23
- });
351
+ throw codedError(
352
+ "PLAYTHROUGH_CANCELLED",
353
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
354
+ );
24
355
  }
25
356
  function formatDiagnostics(read) {
26
357
  if (!read) return void 0;
@@ -36,7 +367,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
36
367
  if (waiverReason === void 0) return void 0;
37
368
  const reason = waiverReason.trim();
38
369
  if (reason.length < 20) {
39
- throw new Error(
370
+ throw codedError(
371
+ "INVALID_PLAYTHROUGH_WAIVER",
40
372
  "playthroughTest.skip reason must contain at least 20 characters."
41
373
  );
42
374
  }
@@ -45,7 +377,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
45
377
  async function runBoundedUntil(condition, options = {}) {
46
378
  const maxSteps = options.maxSteps ?? 120;
47
379
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
48
- throw new RangeError(
380
+ throw codedError(
381
+ "INVALID_STEP_BOUND",
49
382
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
50
383
  );
51
384
  }
@@ -62,7 +395,8 @@ async function runBoundedUntil(condition, options = {}) {
62
395
  const diagnostics = formatDiagnostics(options.diagnostics);
63
396
  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
397
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
65
- throw new Error(
398
+ throw codedError(
399
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
66
400
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
67
401
  );
68
402
  }
@@ -115,14 +449,18 @@ function sampleObservedState(observe, stage) {
115
449
  try {
116
450
  value = observe();
117
451
  } catch (error) {
118
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
452
+ throw codedError(
453
+ "OBSERVE_FAILED",
454
+ `observe() threw at ${stage}: ${String(error)}`
455
+ );
119
456
  }
120
457
  try {
121
458
  const fingerprint = JSON.stringify(value);
122
459
  if (fingerprint === void 0) throw new Error("unsupported value");
123
460
  return { fingerprint, formatted: formatState(fingerprint) };
124
461
  } catch {
125
- throw new Error(
462
+ throw codedError(
463
+ "OBSERVE_NOT_SERIALIZABLE",
126
464
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
127
465
  );
128
466
  }
@@ -135,7 +473,7 @@ function createEvidence() {
135
473
  return { domInputEvents: 0, stages: [], verified: false };
136
474
  }
137
475
  function createMetadata(waiverReason) {
138
- return { version: 4, waiverReason, evidence: createEvidence() };
476
+ return { version: 5, waiverReason, evidence: createEvidence() };
139
477
  }
140
478
  function stageLabel(kind, name) {
141
479
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -171,6 +509,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
171
509
  async ({ annotate, expect, onTestFailed, signal }) => {
172
510
  metadata.evidence = createEvidence();
173
511
  metadata.trace = void 0;
512
+ metadata.failure = void 0;
174
513
  const evidence = metadata.evidence;
175
514
  let entered = false;
176
515
  let finished = false;
@@ -201,8 +540,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
201
540
  return "stages=none";
202
541
  }
203
542
  };
204
- onTestFailed(() => {
543
+ onTestFailed(({ task }) => {
205
544
  metadata.trace ??= captureFailureTrace();
545
+ metadata.failure = appendCurrentAttemptFailures(
546
+ metadata.failure,
547
+ task.result?.errors ?? []
548
+ );
206
549
  });
207
550
  for (const event of INPUT_EVENTS) {
208
551
  document.addEventListener(event, recordInput, true);
@@ -212,7 +555,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
212
555
  try {
213
556
  const view = render(element);
214
557
  if (view.container.childNodes.length === 0) {
215
- throw new Error(
558
+ throw codedError(
559
+ "PRODUCTION_ENTRY_NOT_RENDERED",
216
560
  "playthroughTest must render the production game entry."
217
561
  );
218
562
  }
@@ -241,17 +585,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
241
585
  const executeStage = async (name, kind, stage) => {
242
586
  const normalizedName = name.trim();
243
587
  if (normalizedName.length === 0) {
244
- throw new Error("playthrough stage names must be non-empty strings.");
588
+ throw codedError(
589
+ "INVALID_STAGE_NAME",
590
+ "playthrough stage names must be non-empty strings."
591
+ );
245
592
  }
246
593
  if (evidence.stages.some(
247
594
  (completed) => completed.name === normalizedName
248
595
  )) {
249
- throw new Error(
596
+ throw codedError(
597
+ "DUPLICATE_STAGE_NAME",
250
598
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
251
599
  );
252
600
  }
253
601
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
254
- throw new Error(
602
+ throw codedError(
603
+ "RESERVED_STAGE_NAME",
255
604
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
256
605
  );
257
606
  }
@@ -259,12 +608,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
259
608
  activeStage = { name: normalizedName, kind, before };
260
609
  stepTrace = { bound: stage.maxSteps ?? 120 };
261
610
  if (stage.step && !playthroughOptions?.observe) {
262
- throw new Error(
611
+ throw codedError(
612
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
263
613
  `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
264
614
  );
265
615
  }
266
616
  if (stage.until()) {
267
- throw new Error(
617
+ throw codedError(
618
+ "STAGE_OUTCOME_ALREADY_REACHED",
268
619
  `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
269
620
  );
270
621
  }
@@ -278,12 +629,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
278
629
  acceptingStageInput = false;
279
630
  }
280
631
  if (evidence.domInputEvents === inputsBefore) {
281
- throw new Error(
632
+ throw codedError(
633
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
282
634
  `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
283
635
  );
284
636
  }
285
637
  if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
286
- throw new Error(
638
+ throw codedError(
639
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
287
640
  `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
288
641
  );
289
642
  }
@@ -302,7 +655,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
302
655
  });
303
656
  stepTrace = { bound: stepBound, completed: steps };
304
657
  if (!stage.act && advancedSteps === 0) {
305
- throw new Error(
658
+ throw codedError(
659
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
306
660
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
307
661
  );
308
662
  }
@@ -310,14 +664,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
310
664
  await stage.assert({ expect, user, view });
311
665
  const assertions = expect.getState().assertionCalls - assertionsBefore;
312
666
  if (assertions === 0) {
313
- throw new Error(
667
+ throw codedError(
668
+ "STAGE_ASSERTION_MISSING",
314
669
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
315
670
  );
316
671
  }
317
672
  const after = sampleState(`after ${normalizedName}`);
318
673
  if (after.fingerprint === before.fingerprint) {
319
674
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
320
- throw new Error(
675
+ throw codedError(
676
+ "STAGE_STATE_UNCHANGED",
321
677
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
322
678
  );
323
679
  }
@@ -339,27 +695,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
339
695
  user,
340
696
  async enter(stage) {
341
697
  if (entered) {
342
- throw new Error("enter may only be called once.");
698
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
343
699
  }
344
700
  if (evidence.stages.length > 0) {
345
- throw new Error("enter must be the first playthrough stage.");
701
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
346
702
  }
347
703
  await executeStage("entered", "entered", stage);
348
704
  entered = true;
349
705
  },
350
706
  async milestone(name, stage) {
351
707
  if (!entered) {
352
- throw new Error("milestone must follow enter.");
708
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
353
709
  }
354
710
  if (finished) {
355
- throw new Error("milestone cannot run after finish.");
711
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
356
712
  }
357
713
  await executeStage(name, "milestone", stage);
358
714
  },
359
715
  async finish(name, stage) {
360
- if (!entered) throw new Error("finish must follow enter.");
716
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
361
717
  if (finished) {
362
- throw new Error("finish may only be called once.");
718
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
363
719
  }
364
720
  await executeStage(name, stage.kind, stage);
365
721
  finished = true;
@@ -368,19 +724,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
368
724
  const milestones = evidence.stages.filter(
369
725
  (stage) => stage.kind === "milestone"
370
726
  );
371
- if (!entered) throw new Error("playthroughTest must call enter once.");
727
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
372
728
  if (milestones.length < MIN_MILESTONES) {
373
- throw new Error(
729
+ throw codedError(
730
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
374
731
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
375
732
  );
376
733
  }
377
734
  if (!finished) {
378
- throw new Error(
735
+ throw codedError(
736
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
379
737
  'playthroughTest must call finish with kind "progress" or "terminal".'
380
738
  );
381
739
  }
382
740
  if (evidence.stages.length < MIN_STAGES) {
383
- throw new Error(
741
+ throw codedError(
742
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
384
743
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
385
744
  );
386
745
  }
@@ -388,6 +747,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
388
747
  } catch (error) {
389
748
  const trace = captureFailureTrace();
390
749
  metadata.trace = trace;
750
+ metadata.failure = createFailureDiagnostic("playthrough", error);
391
751
  try {
392
752
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
393
753
  } catch {
@@ -467,10 +827,36 @@ function auditReactPlaythroughRun(tests) {
467
827
  // src/react/react-playthrough-reporter.ts
468
828
  var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
469
829
  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.";
830
+ function assessProductTestAlignment(projectRoot) {
831
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
832
+ if (audit.staleExampleTestFiles.length > 0) {
833
+ return { status: "FAILED", cause: audit.issues[0] };
834
+ }
835
+ if (!audit.productionEntryExists) {
836
+ return {
837
+ status: "NOT_VERIFIED",
838
+ cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
839
+ };
840
+ }
841
+ if (audit.productionUsesExample) {
842
+ return {
843
+ status: "NOT_VERIFIED",
844
+ cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
845
+ };
846
+ }
847
+ return { status: "PASS" };
848
+ }
470
849
  function isMetadata(value) {
471
850
  if (!value || typeof value !== "object") return false;
472
851
  const metadata = value;
473
- if (metadata.version !== 4) return false;
852
+ if (metadata.version !== 5) return false;
853
+ if (metadata.failure !== void 0) {
854
+ if (!metadata.failure || typeof metadata.failure !== "object" || typeof metadata.failure.source !== "string" || !Array.isArray(metadata.failure.entries) || !metadata.failure.entries.every(
855
+ (entry) => Boolean(entry) && typeof entry === "object" && typeof entry.code === "string" && typeof entry.message === "string"
856
+ )) {
857
+ return false;
858
+ }
859
+ }
474
860
  const evidence = metadata.evidence;
475
861
  if (!evidence || typeof evidence !== "object") return false;
476
862
  return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
@@ -485,10 +871,72 @@ function toAuditInput(test2) {
485
871
  metadata: isMetadata(metadata) ? metadata : void 0
486
872
  };
487
873
  }
874
+ function findPendingProductTests(modules, projectRoot) {
875
+ return modules.flatMap((module) => {
876
+ const file = relative2(projectRoot, module.moduleId).replaceAll("\\", "/");
877
+ if (file.split("/").includes("examples")) return [];
878
+ const tests = [...module.children.allTests()];
879
+ const pending = tests.filter((test2) => {
880
+ const mode = test2.options.mode;
881
+ if (mode !== "todo" && mode !== "skip") return false;
882
+ const metadata = test2.meta().reactPlaythrough;
883
+ const approvedWaiver = mode === "skip" && isMetadata(metadata) && Boolean(metadata.waiverReason);
884
+ return !approvedWaiver;
885
+ });
886
+ const fileOnlyContainsPendingTests = tests.length > 0 && pending.length === tests.length;
887
+ return pending.map((test2) => ({
888
+ file,
889
+ test: test2.fullName,
890
+ mode: test2.options.mode,
891
+ fileOnlyContainsPendingTests
892
+ }));
893
+ });
894
+ }
895
+ function formatPendingProductTestReport(pending) {
896
+ if (pending.length === 0) return void 0;
897
+ const lines = [
898
+ "REACT_FOCUSED_TESTS: FAILED",
899
+ "CAUSE_CODE: TODO_OR_SKIP_TESTS",
900
+ "CAUSE: Product tests still contain explicit todo/skip cases."
901
+ ];
902
+ const reportedIncompleteFiles = /* @__PURE__ */ new Set();
903
+ for (const item of pending) {
904
+ if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
905
+ lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
906
+ lines.push(
907
+ `FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
908
+ );
909
+ reportedIncompleteFiles.add(item.file);
910
+ }
911
+ lines.push(`FILE: ${item.file}`);
912
+ lines.push(`TEST: ${item.test}`);
913
+ lines.push(`MODE: ${item.mode}`);
914
+ }
915
+ lines.push(
916
+ "NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
917
+ );
918
+ return `
919
+ ${lines.join("\n")}`;
920
+ }
488
921
  function firstLine(value) {
489
922
  if (typeof value !== "string") return void 0;
490
923
  return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
491
924
  }
925
+ function selectReactFailure(values, errorRecordCount = values.length) {
926
+ const entries = extractFailureEntries(values);
927
+ if (entries.length === 0) {
928
+ return {
929
+ code: "MISSING_FAILURE_DETAILS",
930
+ cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
931
+ rawCause: "",
932
+ related: []
933
+ };
934
+ }
935
+ const primary = entries[0];
936
+ const rawCause = primary.message;
937
+ const cause = firstLine(rawCause) ?? rawCause;
938
+ return { code: primary.code, cause, rawCause, related: entries.slice(1) };
939
+ }
492
940
  function failureHint(value) {
493
941
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
494
942
  (match) => match[1]
@@ -522,37 +970,57 @@ function truncateReporterLine(value, limit) {
522
970
  }
523
971
  function toModuleResult(module, projectRoot) {
524
972
  const tests = [...module.children.allTests()];
525
- const moduleErrors = module.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
973
+ const moduleFailure = selectReactFailure(
974
+ module.errors(),
975
+ module.errors().length
976
+ );
977
+ const moduleErrors = extractFailureEntries(module.errors()).map(
978
+ (entry) => firstLine(entry.message) ?? entry.message
979
+ );
526
980
  const errors = [
527
981
  ...moduleErrors,
528
982
  ...tests.flatMap(
529
- (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
983
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
984
+ (entry) => firstLine(entry.message) ?? entry.message
985
+ )
530
986
  )
531
- ].filter((message) => Boolean(message));
987
+ ];
532
988
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
533
- const raw = test2.result().errors?.at(-1)?.message ?? module.errors()[0]?.message ?? "Unknown failure";
989
+ const metadata = test2.meta().reactPlaythrough;
990
+ const testErrors = test2.result().errors ?? [];
991
+ const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
992
+ const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
993
+ const selected = selectReactFailure(
994
+ [...attemptErrors, ...module.errors()],
995
+ testErrors.length + module.errors().length
996
+ );
534
997
  return {
535
998
  test: test2.fullName,
536
- cause: firstLine(raw) ?? "Unknown failure",
537
- location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
538
- hint: failureHint(raw),
999
+ causeCode: selected.code,
1000
+ cause: selected.cause,
1001
+ related: selected.related,
1002
+ location: test2.location ? `${relative2(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
1003
+ hint: failureHint(selected.rawCause),
539
1004
  trace: failureTrace(test2)
540
1005
  };
541
1006
  });
542
1007
  if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
543
- const raw = module.errors()[0]?.message ?? "Module failed to load";
544
1008
  failures.push({
545
1009
  test: "<collection>",
546
- cause: firstLine(raw) ?? "Module failed to load",
547
- location: errorLocation(raw),
548
- hint: failureHint(raw)
1010
+ causeCode: moduleFailure.code,
1011
+ cause: moduleFailure.cause,
1012
+ related: moduleFailure.related,
1013
+ location: errorLocation(moduleFailure.rawCause),
1014
+ hint: failureHint(moduleFailure.rawCause)
549
1015
  });
550
1016
  }
551
1017
  return {
552
- file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
1018
+ file: relative2(projectRoot, module.moduleId).replaceAll("\\", "/"),
553
1019
  state: module.state(),
554
1020
  errors,
555
1021
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
1022
+ primaryCauseCode: failures[0]?.causeCode,
1023
+ relatedErrors: failures[0]?.related,
556
1024
  tests: tests.map(toAuditInput),
557
1025
  failures
558
1026
  };
@@ -569,7 +1037,14 @@ function formatReactFailureSummary(modules) {
569
1037
  for (const [index, failure] of failures.entries()) {
570
1038
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
571
1039
  lines.push(`TEST: ${failure.test}`);
1040
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
572
1041
  lines.push(`CAUSE: ${failure.cause}`);
1042
+ for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
1043
+ lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
1044
+ lines.push(
1045
+ `RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
1046
+ );
1047
+ }
573
1048
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
574
1049
  if (failure.location) lines.push(`AT: ${failure.location}`);
575
1050
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
@@ -577,32 +1052,36 @@ function formatReactFailureSummary(modules) {
577
1052
  lines.push("TEST_RESULT: FAIL");
578
1053
  return lines;
579
1054
  }
580
- function repairGuidance(cause) {
581
- if (/snapshot\(\) returned the same reference/i.test(cause)) {
582
- return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
583
- }
584
- if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
585
- return "This stage advances production time or frames, so DOM text is not a sufficient state boundary. Keep the intended outcome, inject ManualGameClock through the production <App /> factory, and make observe read the same production Controller through Telemetry. Do not remove step or replace the outcome with an immediate UI transition.";
586
- }
587
- if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
588
- return "The player input reached the production Canvas, but JSDOM cannot verify its pixels. Keep the real Canvas input and make observe read the authoritative state from the same production Controller rendered by <App /> through Telemetry. Do not replace Canvas input with a test-only Controller command or button-label assertion.";
589
- }
590
- if (/until condition must be false before its driver runs/i.test(cause)) {
591
- return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
592
- }
593
- if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
594
- return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe that result directly. Canvas or Controller games should make observe read the same production Controller that React renders. Do not substitute arbitrary labels, button visibility, navigation, or another weaker state change merely to produce a different fingerprint.";
595
- }
596
- if (/No step callback was provided/i.test(cause)) {
597
- return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
1055
+ function repairGuidance(code) {
1056
+ switch (code) {
1057
+ case "GAME_SNAPSHOT_REFERENCE_REUSED":
1058
+ 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.";
1059
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP":
1060
+ 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.";
1061
+ case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS":
1062
+ 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.";
1063
+ case "STAGE_OUTCOME_ALREADY_REACHED":
1064
+ 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.";
1065
+ case "STAGE_STATE_UNCHANGED":
1066
+ 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.";
1067
+ case "PLAYTHROUGH_BOUND_EXHAUSTED":
1068
+ 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.";
1069
+ case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
1070
+ 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.";
1071
+ case "INVALID_STAGE_ORDER":
1072
+ case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
1073
+ case "INVALID_STAGE_NAME":
1074
+ case "DUPLICATE_STAGE_NAME":
1075
+ case "RESERVED_STAGE_NAME":
1076
+ case "PRODUCTION_INPUT_NOT_DISPATCHED":
1077
+ case "AUTONOMOUS_STAGE_NOT_ADVANCED":
1078
+ case "STAGE_ASSERTION_MISSING":
1079
+ 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.";
1080
+ case "MISSING_FAILURE_DETAILS":
1081
+ 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.";
1082
+ default:
1083
+ return "Fix the first reported CAUSE, then rerun the same test. RELATED entries preserve the remaining Vitest errors in their original order.";
598
1084
  }
599
- if (/outcome was not reached within \d+ steps/i.test(cause)) {
600
- return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
601
- }
602
- if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
603
- return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
604
- }
605
- return "Start from the production entry and describe the real game as enter, named milestones, and finish. Enter must act through production input; later stages may act or step. Every stage waits for and asserts a new player-visible or authoritative result. Do not jump to an internal level or mutate gameplay state.";
606
1085
  }
607
1086
  function assessReactPlaythroughReport(input) {
608
1087
  const base = { file: input.expectedFile };
@@ -618,6 +1097,7 @@ function assessReactPlaythroughReport(input) {
618
1097
  return {
619
1098
  ...base,
620
1099
  status: "FAILED",
1100
+ causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
621
1101
  cause: "The required production playthrough test file does not exist.",
622
1102
  next: "Create the file and render <App />. Compose one real-input enter stage, at least three named gameplay milestones, and one finish stage. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result.",
623
1103
  failsRun: true
@@ -641,6 +1121,7 @@ function assessReactPlaythroughReport(input) {
641
1121
  return {
642
1122
  ...base,
643
1123
  status: "NOT_RUN",
1124
+ causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
644
1125
  cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
645
1126
  next: "Fix the first Vitest syntax, import, environment, or collection error shown above, then run pnpm test again. Do not use skip to hide a load failure.",
646
1127
  failsRun: true
@@ -653,8 +1134,12 @@ function assessReactPlaythroughReport(input) {
653
1134
  return {
654
1135
  ...base,
655
1136
  status: "FAILED",
1137
+ causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
656
1138
  cause,
657
- next: repairGuidance(cause),
1139
+ related: productionModule.relatedErrors,
1140
+ next: repairGuidance(
1141
+ productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
1142
+ ),
658
1143
  failsRun: true
659
1144
  };
660
1145
  }
@@ -668,9 +1153,21 @@ function assessReactPlaythroughReport(input) {
668
1153
  }
669
1154
  return { ...base, status: "PASS", failsRun: false };
670
1155
  }
671
- function formatReactPlaythroughReport(report) {
672
- const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
1156
+ function formatReactPlaythroughReport(report, alignment) {
1157
+ const lines = [
1158
+ `REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
1159
+ `PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
1160
+ `FILE: ${report.file}`
1161
+ ];
1162
+ if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
1163
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
673
1164
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
1165
+ for (const [index, related] of (report.related ?? []).entries()) {
1166
+ lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
1167
+ lines.push(
1168
+ `RELATED_${index + 1}: ${firstLine(related.message) ?? related.message}`
1169
+ );
1170
+ }
674
1171
  if (report.waiverReasons?.length) {
675
1172
  lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
676
1173
  }
@@ -685,7 +1182,7 @@ var ReactPlaythroughReporter = class {
685
1182
  /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
686
1183
  constructor(projectRoot) {
687
1184
  this.projectRoot = projectRoot;
688
- this.expectedModuleId = resolve(projectRoot, this.expectedFile);
1185
+ this.expectedModuleId = resolve2(projectRoot, this.expectedFile);
689
1186
  }
690
1187
  projectRoot;
691
1188
  expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
@@ -695,7 +1192,7 @@ var ReactPlaythroughReporter = class {
695
1192
  /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
696
1193
  onTestRunStart(specifications) {
697
1194
  this.expectedFileScheduled = specifications.some(
698
- (specification) => resolve(specification.moduleId) === this.expectedModuleId
1195
+ (specification) => resolve2(specification.moduleId) === this.expectedModuleId
699
1196
  );
700
1197
  this.focusedSelection = specifications.some(
701
1198
  (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
@@ -703,18 +1200,25 @@ var ReactPlaythroughReporter = class {
703
1200
  }
704
1201
  /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
705
1202
  onTestRunEnd(testModules, unhandledErrors) {
1203
+ const pendingProductTests = findPendingProductTests(
1204
+ testModules,
1205
+ this.projectRoot
1206
+ );
706
1207
  const report = assessReactPlaythroughReport({
707
1208
  expectedFile: this.expectedFile,
708
- expectedFileExists: existsSync(this.expectedModuleId),
1209
+ expectedFileExists: existsSync2(this.expectedModuleId),
709
1210
  expectedFileScheduled: this.expectedFileScheduled,
710
1211
  focusedSelection: this.focusedSelection,
711
1212
  modules: testModules.map(
712
1213
  (module) => toModuleResult(module, this.projectRoot)
713
1214
  ),
714
- unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
1215
+ unhandledErrors: extractFailureEntries(unhandledErrors).map(
1216
+ (entry) => firstLine(entry.message) ?? entry.message
1217
+ )
715
1218
  });
716
- const output = formatReactPlaythroughReport(report);
717
- if (report.failsRun) {
1219
+ const alignment = assessProductTestAlignment(this.projectRoot);
1220
+ const output = formatReactPlaythroughReport(report, alignment);
1221
+ if (report.failsRun || alignment.status === "FAILED") {
718
1222
  console.error(output);
719
1223
  process.exitCode = 1;
720
1224
  } else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
@@ -722,10 +1226,15 @@ var ReactPlaythroughReporter = class {
722
1226
  } else {
723
1227
  console.log(output);
724
1228
  }
1229
+ const pendingOutput = formatPendingProductTestReport(pendingProductTests);
1230
+ if (pendingOutput) {
1231
+ console.error(pendingOutput);
1232
+ process.exitCode = 1;
1233
+ }
725
1234
  const summary = formatReactFailureSummary(
726
1235
  testModules.map((module) => toModuleResult(module, this.projectRoot))
727
1236
  );
728
- if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
1237
+ if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
729
1238
  summary[0] = "TEST_RESULT: FAIL";
730
1239
  }
731
1240
  console.log(`
@@ -741,16 +1250,16 @@ function getJSDOMWorkerExecArgv() {
741
1250
 
742
1251
  // src/react-vitest-config.ts
743
1252
  function resolvePhaser3BrowserEntry(projectRoot) {
744
- const manifestPath = resolve2(projectRoot, "node_modules/phaser/package.json");
745
- if (!existsSync2(manifestPath)) return void 0;
1253
+ const manifestPath = resolve3(projectRoot, "node_modules/phaser/package.json");
1254
+ if (!existsSync3(manifestPath)) return void 0;
746
1255
  try {
747
- const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1256
+ const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
748
1257
  if (!manifest.version?.startsWith("3.")) return void 0;
749
- const browserEntry = resolve2(
750
- dirname(manifestPath),
1258
+ const browserEntry = resolve3(
1259
+ dirname2(manifestPath),
751
1260
  manifest.browser ?? "dist/phaser.js"
752
1261
  );
753
- return existsSync2(browserEntry) ? browserEntry : void 0;
1262
+ return existsSync3(browserEntry) ? browserEntry : void 0;
754
1263
  } catch {
755
1264
  return void 0;
756
1265
  }
@@ -765,7 +1274,7 @@ function defineReactGameVitestConfig(options) {
765
1274
  alias: {
766
1275
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
767
1276
  ...options.aliases,
768
- "@": resolve2(options.projectRoot, "src")
1277
+ "@": resolve3(options.projectRoot, "src")
769
1278
  }
770
1279
  },
771
1280
  test: {