miaoda-game-devkit 0.6.5 → 0.7.0

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.
@@ -34,251 +34,12 @@ __export(react_vitest_config_exports, {
34
34
  });
35
35
  module.exports = __toCommonJS(react_vitest_config_exports);
36
36
  var import_node_fs3 = require("fs");
37
- var import_node_path3 = require("path");
37
+ var import_node_path4 = require("path");
38
38
  var import_config = require("vitest/config");
39
39
 
40
40
  // src/react/react-playthrough-reporter.ts
41
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
46
- var import_node_fs = require("fs");
47
- var import_node_path = require("path");
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. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; 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(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
265
- );
266
- }
267
- return {
268
- ok: issues.length === 0,
269
- issues,
270
- clockFiles,
271
- controllerFiles,
272
- productionEntryExists,
273
- productionUsesExample,
274
- staleExampleTestFiles
275
- };
276
- }
277
-
278
- // src/react/react-playthrough.ts
279
- var import_react2 = require("@testing-library/react");
280
- var import_user_event = __toESM(require("@testing-library/user-event"));
281
- var import_vitest = require("vitest");
42
+ var import_node_path3 = require("path");
282
43
 
283
44
  // src/react/react-error-diagnostics.ts
284
45
  var MAX_DIAGNOSTIC_LENGTH = 1e3;
@@ -306,47 +67,145 @@ function safeJson(value) {
306
67
  return void 0;
307
68
  }
308
69
  }
309
- function collectEntries(value, fallbackCode, seen) {
70
+ function diagnosticValue(value) {
71
+ if (value === void 0) return void 0;
72
+ if (typeof value === "string") {
73
+ return truncate(value.replace(/\s+/g, " ").trim());
74
+ }
75
+ if (value === null || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
76
+ return String(value);
77
+ }
78
+ if (typeof value === "symbol") return value.toString();
79
+ if (typeof value === "function") {
80
+ return `Function<${value.name || "anonymous"}>`;
81
+ }
82
+ const json = safeJson(value);
83
+ return truncate(json ?? String(value));
84
+ }
85
+ function normalizeFile(value) {
86
+ return value.replaceAll("\\", "/").replace(/^file:\/\//, "");
87
+ }
88
+ function failureLayer(file) {
89
+ const normalized = normalizeFile(file);
90
+ if (normalized.includes("/miaoda-game-devkit/") || normalized.includes("/packages/game-devkit/")) {
91
+ return "harness";
92
+ }
93
+ if (normalized.includes("/node_modules/")) return "dependency";
94
+ if (/(?:^|\/)tests?\//.test(normalized) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalized)) {
95
+ return "test";
96
+ }
97
+ if (/(?:^|\/)src\//.test(normalized)) return "product";
98
+ return "unknown";
99
+ }
100
+ function parsedOrigin(value) {
101
+ if (!value || typeof value !== "object") return void 0;
102
+ const frame = value;
103
+ if (typeof frame.file !== "string" || typeof frame.line !== "number" || typeof frame.column !== "number") {
104
+ return void 0;
105
+ }
106
+ return {
107
+ file: normalizeFile(frame.file),
108
+ line: frame.line,
109
+ column: frame.column
110
+ };
111
+ }
112
+ function stackOrigins(record) {
113
+ if (Array.isArray(record.stacks)) {
114
+ const parsed = record.stacks.map(parsedOrigin).filter((origin) => Boolean(origin));
115
+ if (parsed.length > 0) return parsed;
116
+ }
117
+ if (typeof record.stack !== "string") return [];
118
+ const origins = [];
119
+ const pattern = /(?:at\s+.*?\()?((?:file:\/\/)?[^()\s]+):(\d+):(\d+)\)?/g;
120
+ for (const match of record.stack.matchAll(pattern)) {
121
+ origins.push({
122
+ file: normalizeFile(match[1]),
123
+ line: Number(match[2]),
124
+ column: Number(match[3])
125
+ });
126
+ }
127
+ return origins;
128
+ }
129
+ function selectOrigin(record) {
130
+ const origins = stackOrigins(record);
131
+ return origins.find((origin) => {
132
+ const layer = failureLayer(origin.file);
133
+ return layer === "product" || layer === "test";
134
+ }) ?? origins.find((origin) => failureLayer(origin.file) !== "dependency");
135
+ }
136
+ function fallbackCode(record, origin, defaultCode) {
137
+ if (typeof record.code === "string" && record.code.trim()) {
138
+ return record.code.trim();
139
+ }
140
+ if (record.name === "AssertionError" && record.actual !== void 0 && record.expected !== void 0) {
141
+ return "EXPECTATION_MISMATCH";
142
+ }
143
+ if (record.name === "TypeError" && origin) {
144
+ const layer = failureLayer(origin.file);
145
+ if (layer === "product") return "PRODUCT_RUNTIME_TYPE_ERROR";
146
+ if (layer === "test") return "TEST_API_MISMATCH";
147
+ }
148
+ return defaultCode;
149
+ }
150
+ function structuredEntry(record, message, defaultCode) {
151
+ const origin = selectOrigin(record);
152
+ return {
153
+ code: fallbackCode(record, origin, defaultCode),
154
+ message: truncate(message),
155
+ errorName: typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0,
156
+ actual: diagnosticValue(record.actual),
157
+ expected: diagnosticValue(record.expected),
158
+ origin,
159
+ layer: origin ? failureLayer(origin.file) : void 0
160
+ };
161
+ }
162
+ function collectEntries(value, fallbackCode2, seen) {
310
163
  if (typeof value === "string") {
311
- return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
164
+ return value.trim() ? [{ code: fallbackCode2, message: truncate(value) }] : [];
312
165
  }
313
166
  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) }];
167
+ return [{ code: fallbackCode2, message: String(value) }];
315
168
  }
316
169
  if (typeof value === "function") {
317
170
  return [
318
- { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
171
+ { code: fallbackCode2, message: `Function<${value.name || "anonymous"}>` }
319
172
  ];
320
173
  }
321
174
  if (seen.has(value)) return [];
322
175
  seen.add(value);
323
176
  if (Array.isArray(value)) {
324
- return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
177
+ return value.flatMap((item) => collectEntries(item, fallbackCode2, seen));
325
178
  }
326
179
  const record = value;
327
- const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
328
180
  const entries = [];
329
181
  if (typeof record.message === "string" && record.message.trim()) {
330
- entries.push({ code, message: truncate(record.message) });
182
+ entries.push(structuredEntry(record, record.message, fallbackCode2));
331
183
  }
332
184
  if (record.cause !== void 0) {
333
- entries.push(...collectEntries(record.cause, fallbackCode, seen));
185
+ entries.push(...collectEntries(record.cause, fallbackCode2, seen));
334
186
  }
335
187
  if (Array.isArray(record.errors)) {
336
- entries.push(...collectEntries(record.errors, fallbackCode, seen));
188
+ entries.push(...collectEntries(record.errors, fallbackCode2, seen));
337
189
  }
338
190
  if (entries.length > 0) return entries;
339
191
  if (typeof record.stack === "string" && record.stack.trim()) {
340
- return [{ code, message: truncate(record.stack) }];
192
+ return [structuredEntry(record, record.stack, fallbackCode2)];
341
193
  }
342
194
  const json = safeJson(value);
343
- return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
195
+ return json && json !== "{}" ? [structuredEntry(record, json, fallbackCode2)] : [];
344
196
  }
345
- function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
346
- const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
197
+ function extractFailureEntries(value, fallbackCode2 = "TEST_FAILURE") {
198
+ const entries = collectEntries(value, fallbackCode2, /* @__PURE__ */ new WeakSet());
347
199
  const keys = /* @__PURE__ */ new Set();
348
200
  return entries.filter((entry) => {
349
- const key = `${entry.code}\0${entry.message}`;
201
+ const key = [
202
+ entry.code,
203
+ entry.message,
204
+ entry.errorName,
205
+ entry.actual,
206
+ entry.expected,
207
+ entry.origin ? `${entry.origin.file}:${entry.origin.line}:${entry.origin.column}` : void 0
208
+ ].join("\0");
350
209
  if (keys.has(key)) return false;
351
210
  keys.add(key);
352
211
  return true;
@@ -377,6 +236,15 @@ function codedError(code, message) {
377
236
  return error;
378
237
  }
379
238
 
239
+ // src/react/react-playthrough-results.ts
240
+ var import_node_path = require("path");
241
+ var import_node_util = require("util");
242
+
243
+ // src/react/react-playthrough.ts
244
+ var import_react2 = require("@testing-library/react");
245
+ var import_user_event = __toESM(require("@testing-library/user-event"));
246
+ var import_vitest = require("vitest");
247
+
380
248
  // src/react/react-playthrough-core.ts
381
249
  var import_react = require("@testing-library/react");
382
250
  function throwIfAborted(signal) {
@@ -427,10 +295,10 @@ async function runBoundedUntil(condition, options = {}) {
427
295
  }
428
296
  }
429
297
  const diagnostics = formatDiagnostics(options.diagnostics);
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().";
298
+ const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
431
299
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
432
300
  throw codedError(
433
- options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
301
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
434
302
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
435
303
  );
436
304
  }
@@ -858,28 +726,7 @@ function auditReactPlaythroughRun(tests) {
858
726
  return { passed: false, waived: false, issues };
859
727
  }
860
728
 
861
- // src/react/react-playthrough-reporter.ts
862
- var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
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
- }
729
+ // src/react/react-playthrough-results.ts
883
730
  function isMetadata(value) {
884
731
  if (!value || typeof value !== "object") return false;
885
732
  const metadata = value;
@@ -897,17 +744,22 @@ function isMetadata(value) {
897
744
  (stage) => Boolean(stage) && typeof stage === "object" && typeof stage.name === "string" && typeof stage.kind === "string" && typeof stage.domInputEvents === "number" && typeof stage.advancedSteps === "number" && typeof stage.assertions === "number" && typeof stage.stateChanged === "boolean" && typeof stage.before === "string" && typeof stage.after === "string"
898
745
  );
899
746
  }
747
+ function firstFailureLine(value) {
748
+ if (typeof value !== "string") return void 0;
749
+ return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
750
+ }
900
751
  function toAuditInput(test2) {
901
752
  const metadata = test2.meta().reactPlaythrough;
902
753
  return {
903
754
  name: test2.fullName,
904
755
  state: test2.result().state,
756
+ mode: test2.options.mode,
905
757
  metadata: isMetadata(metadata) ? metadata : void 0
906
758
  };
907
759
  }
908
760
  function findPendingProductTests(modules, projectRoot) {
909
761
  return modules.flatMap((module2) => {
910
- const file = (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
762
+ const file = (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
911
763
  if (file.split("/").includes("examples")) return [];
912
764
  const tests = [...module2.children.allTests()];
913
765
  const pending = tests.filter((test2) => {
@@ -926,36 +778,6 @@ function findPendingProductTests(modules, projectRoot) {
926
778
  }));
927
779
  });
928
780
  }
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
- }
955
- function firstLine(value) {
956
- if (typeof value !== "string") return void 0;
957
- return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
958
- }
959
781
  function selectReactFailure(values, errorRecordCount = values.length) {
960
782
  const entries = extractFailureEntries(values);
961
783
  if (entries.length === 0) {
@@ -968,8 +790,17 @@ function selectReactFailure(values, errorRecordCount = values.length) {
968
790
  }
969
791
  const primary = entries[0];
970
792
  const rawCause = primary.message;
971
- const cause = firstLine(rawCause) ?? rawCause;
972
- return { code: primary.code, cause, rawCause, related: entries.slice(1) };
793
+ return {
794
+ code: primary.code,
795
+ cause: firstFailureLine(rawCause) ?? rawCause,
796
+ rawCause,
797
+ errorName: primary.errorName,
798
+ actual: primary.actual,
799
+ expected: primary.expected,
800
+ origin: primary.origin,
801
+ layer: primary.layer,
802
+ related: entries.slice(1)
803
+ };
973
804
  }
974
805
  function failureHint(value) {
975
806
  const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
@@ -984,6 +815,11 @@ function errorLocation(value) {
984
815
  );
985
816
  return match?.[1];
986
817
  }
818
+ function truncateReporterLine(value, limit) {
819
+ const compact = (0, import_node_util.stripVTControlCharacters)(value).replace(/\s+/g, " ").trim();
820
+ if (compact.length <= limit) return compact;
821
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
822
+ }
987
823
  function failureTrace(test2) {
988
824
  const annotations = test2.annotations();
989
825
  let annotationTrace;
@@ -997,25 +833,20 @@ function failureTrace(test2) {
997
833
  const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
998
834
  return trace ? truncateReporterLine(trace, 720) : void 0;
999
835
  }
1000
- function truncateReporterLine(value, limit) {
1001
- const compact = (0, import_node_util.stripVTControlCharacters)(value).replace(/\s+/g, " ").trim();
1002
- if (compact.length <= limit) return compact;
1003
- return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
1004
- }
1005
- function toModuleResult(module2, projectRoot) {
836
+ function toReactPlaythroughModuleResult(module2, projectRoot) {
1006
837
  const tests = [...module2.children.allTests()];
1007
838
  const moduleFailure = selectReactFailure(
1008
839
  module2.errors(),
1009
840
  module2.errors().length
1010
841
  );
1011
842
  const moduleErrors = extractFailureEntries(module2.errors()).map(
1012
- (entry) => firstLine(entry.message) ?? entry.message
843
+ (entry) => firstFailureLine(entry.message) ?? entry.message
1013
844
  );
1014
845
  const errors = [
1015
846
  ...moduleErrors,
1016
847
  ...tests.flatMap(
1017
848
  (test2) => extractFailureEntries(test2.result().errors ?? []).map(
1018
- (entry) => firstLine(entry.message) ?? entry.message
849
+ (entry) => firstFailureLine(entry.message) ?? entry.message
1019
850
  )
1020
851
  )
1021
852
  ];
@@ -1032,8 +863,13 @@ function toModuleResult(module2, projectRoot) {
1032
863
  test: test2.fullName,
1033
864
  causeCode: selected.code,
1034
865
  cause: selected.cause,
866
+ errorName: selected.errorName,
867
+ actual: selected.actual,
868
+ expected: selected.expected,
869
+ origin: selected.origin,
870
+ layer: selected.layer,
1035
871
  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),
872
+ location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
1037
873
  hint: failureHint(selected.rawCause),
1038
874
  trace: failureTrace(test2)
1039
875
  };
@@ -1043,13 +879,18 @@ function toModuleResult(module2, projectRoot) {
1043
879
  test: "<collection>",
1044
880
  causeCode: moduleFailure.code,
1045
881
  cause: moduleFailure.cause,
882
+ errorName: moduleFailure.errorName,
883
+ actual: moduleFailure.actual,
884
+ expected: moduleFailure.expected,
885
+ origin: moduleFailure.origin,
886
+ layer: moduleFailure.layer,
1046
887
  related: moduleFailure.related,
1047
888
  location: errorLocation(moduleFailure.rawCause),
1048
889
  hint: failureHint(moduleFailure.rawCause)
1049
890
  });
1050
891
  }
1051
892
  return {
1052
- file: (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
893
+ file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
1053
894
  state: module2.state(),
1054
895
  errors,
1055
896
  primaryError: moduleErrors[0] ?? failures[0]?.cause,
@@ -1059,6 +900,47 @@ function toModuleResult(module2, projectRoot) {
1059
900
  failures
1060
901
  };
1061
902
  }
903
+
904
+ // src/react/react-playthrough-report-format.ts
905
+ 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.";
906
+ function formatPendingProductTestReport(pending) {
907
+ if (pending.length === 0) return void 0;
908
+ const lines = [
909
+ "REACT_FOCUSED_TESTS: FAILED",
910
+ "CAUSE_CODE: TODO_OR_SKIP_TESTS",
911
+ "CAUSE: Product tests still contain explicit todo/skip cases."
912
+ ];
913
+ const reportedIncompleteFiles = /* @__PURE__ */ new Set();
914
+ for (const item of pending) {
915
+ if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
916
+ lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
917
+ lines.push(
918
+ `FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
919
+ );
920
+ reportedIncompleteFiles.add(item.file);
921
+ }
922
+ lines.push(`FILE: ${item.file}`);
923
+ lines.push(`TEST: ${item.test}`);
924
+ lines.push(`MODE: ${item.mode}`);
925
+ }
926
+ lines.push(
927
+ "NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
928
+ );
929
+ return `
930
+ ${lines.join("\n")}`;
931
+ }
932
+ function formatOrigin(origin) {
933
+ return `${origin.file}:${origin.line}:${origin.column}`;
934
+ }
935
+ function appendFailureDetails(lines, failure) {
936
+ if (failure.errorName) lines.push(`ERROR_NAME: ${failure.errorName}`);
937
+ if (failure.layer) lines.push(`CAUSE_LAYER: ${failure.layer}`);
938
+ if (failure.origin) lines.push(`ORIGIN: ${formatOrigin(failure.origin)}`);
939
+ if (failure.expected !== void 0) {
940
+ lines.push(`EXPECTED: ${failure.expected}`);
941
+ }
942
+ if (failure.actual !== void 0) lines.push(`ACTUAL: ${failure.actual}`);
943
+ }
1062
944
  function formatReactFailureSummary(modules) {
1063
945
  const failures = modules.flatMap(
1064
946
  (module2) => (module2.failures ?? []).map((failure) => ({
@@ -1073,11 +955,20 @@ function formatReactFailureSummary(modules) {
1073
955
  lines.push(`TEST: ${failure.test}`);
1074
956
  lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
1075
957
  lines.push(`CAUSE: ${failure.cause}`);
958
+ appendFailureDetails(lines, failure);
1076
959
  for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
1077
960
  lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
1078
961
  lines.push(
1079
- `RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
962
+ `RELATED_${relatedIndex + 1}: ${firstFailureLine(related.message) ?? related.message}`
1080
963
  );
964
+ if (related.layer) {
965
+ lines.push(`RELATED_${relatedIndex + 1}_LAYER: ${related.layer}`);
966
+ }
967
+ if (related.origin) {
968
+ lines.push(
969
+ `RELATED_${relatedIndex + 1}_ORIGIN: ${formatOrigin(related.origin)}`
970
+ );
971
+ }
1081
972
  }
1082
973
  if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
1083
974
  if (failure.location) lines.push(`AT: ${failure.location}`);
@@ -1086,6 +977,282 @@ function formatReactFailureSummary(modules) {
1086
977
  lines.push("TEST_RESULT: FAIL");
1087
978
  return lines;
1088
979
  }
980
+ function formatReactTestCountSummary(modules) {
981
+ const files = {
982
+ total: modules.length,
983
+ passed: 0,
984
+ failed: 0,
985
+ skipped: 0,
986
+ pending: 0,
987
+ queued: 0
988
+ };
989
+ const tests = {
990
+ total: 0,
991
+ passed: 0,
992
+ failed: 0,
993
+ skipped: 0,
994
+ todo: 0,
995
+ pending: 0
996
+ };
997
+ let declaredPlaythroughs = 0;
998
+ let verifiedPlaythroughs = 0;
999
+ let waivedPlaythroughs = 0;
1000
+ for (const module2 of modules) {
1001
+ if (module2.state in files && module2.state !== "total") {
1002
+ files[module2.state] += 1;
1003
+ }
1004
+ for (const test2 of module2.tests) {
1005
+ tests.total += 1;
1006
+ if (test2.mode === "todo") tests.todo += 1;
1007
+ else tests[test2.state] += 1;
1008
+ if (test2.metadata) {
1009
+ declaredPlaythroughs += 1;
1010
+ if (test2.metadata.evidence.verified) verifiedPlaythroughs += 1;
1011
+ if (test2.metadata.waiverReason) waivedPlaythroughs += 1;
1012
+ }
1013
+ }
1014
+ }
1015
+ return [
1016
+ `TEST_FILES: total=${files.total} passed=${files.passed} failed=${files.failed} skipped=${files.skipped} pending=${files.pending} queued=${files.queued}`,
1017
+ `TESTS: total=${tests.total} passed=${tests.passed} failed=${tests.failed} skipped=${tests.skipped} todo=${tests.todo} pending=${tests.pending}`,
1018
+ `PLAYTHROUGHS: declared=${declaredPlaythroughs} verified=${verifiedPlaythroughs} waived=${waivedPlaythroughs}`
1019
+ ];
1020
+ }
1021
+ function formatReactPlaythroughReport(report, alignment) {
1022
+ const lines = [
1023
+ `REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
1024
+ `PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
1025
+ `FILE: ${report.file}`
1026
+ ];
1027
+ if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
1028
+ if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
1029
+ if (report.cause) lines.push(`CAUSE: ${report.cause}`);
1030
+ for (const [index, related] of (report.related ?? []).entries()) {
1031
+ lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
1032
+ lines.push(
1033
+ `RELATED_${index + 1}: ${firstFailureLine(related.message) ?? related.message}`
1034
+ );
1035
+ }
1036
+ if (report.waiverReasons?.length) {
1037
+ lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
1038
+ }
1039
+ if (report.next) lines.push(`NEXT: ${report.next}`);
1040
+ if (report.status === "FAILED") {
1041
+ lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
1042
+ }
1043
+ return `
1044
+ ${lines.join("\n")}`;
1045
+ }
1046
+
1047
+ // src/cli/react-authoritative-playthrough.ts
1048
+ var import_node_fs = require("fs");
1049
+ var import_node_path2 = require("path");
1050
+ var import_oxc_parser = require("oxc-parser");
1051
+ var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
1052
+ var PRODUCTION_APP = "src/App.tsx";
1053
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
1054
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
1055
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
1056
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
1057
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
1058
+ "useGameController",
1059
+ "useOwnedGameController"
1060
+ ]);
1061
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
1062
+ function runnableTestFiles(root, directory = (0, import_node_path2.join)(root, "tests")) {
1063
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
1064
+ const files = [];
1065
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
1066
+ const path = (0, import_node_path2.join)(directory, entry.name);
1067
+ if (entry.isDirectory()) {
1068
+ files.push(...runnableTestFiles(root, path));
1069
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
1070
+ files.push(path);
1071
+ }
1072
+ }
1073
+ return files;
1074
+ }
1075
+ function extension(path) {
1076
+ const index = path.lastIndexOf(".");
1077
+ return index < 0 ? "" : path.slice(index);
1078
+ }
1079
+ function sourceFiles(root, directory = (0, import_node_path2.join)(root, "src")) {
1080
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
1081
+ const files = [];
1082
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
1083
+ const path = (0, import_node_path2.join)(directory, entry.name);
1084
+ const projectPath = (0, import_node_path2.relative)(root, path).replaceAll("\\", "/");
1085
+ if (entry.isDirectory()) {
1086
+ if (projectPath === "src/game/example") continue;
1087
+ files.push(...sourceFiles(root, path));
1088
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
1089
+ files.push(path);
1090
+ }
1091
+ }
1092
+ return files;
1093
+ }
1094
+ function parseSource(file, source) {
1095
+ return (0, import_oxc_parser.parseSync)(file, source, { sourceType: "module" });
1096
+ }
1097
+ function staticImports(file, source) {
1098
+ return parseSource(file, source).module.staticImports;
1099
+ }
1100
+ function collectDynamicImportSpecifiers(value, modules) {
1101
+ if (Array.isArray(value)) {
1102
+ for (const item of value) collectDynamicImportSpecifiers(item, modules);
1103
+ return;
1104
+ }
1105
+ if (!isRecord(value)) return;
1106
+ if (value.type === "ImportExpression" && isRecord(value.source) && value.source.type === "Literal" && typeof value.source.value === "string") {
1107
+ modules.push(value.source.value);
1108
+ }
1109
+ for (const child of Object.values(value)) {
1110
+ collectDynamicImportSpecifiers(child, modules);
1111
+ }
1112
+ }
1113
+ function importedModuleSpecifiers(file, source) {
1114
+ const parsed = parseSource(file, source);
1115
+ const modules = parsed.module.staticImports.map(
1116
+ ({ moduleRequest }) => moduleRequest.value
1117
+ );
1118
+ collectDynamicImportSpecifiers(parsed.program, modules);
1119
+ return modules;
1120
+ }
1121
+ function productionFileImportsExample(file, projectRoot) {
1122
+ const exampleRoot = (0, import_node_path2.join)(projectRoot, "src/game/example");
1123
+ return importedModuleSpecifiers(file, (0, import_node_fs.readFileSync)(file, "utf8")).some(
1124
+ (moduleName) => {
1125
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
1126
+ if (!moduleName.startsWith(".")) return false;
1127
+ const target = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(file), moduleName);
1128
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path2.sep}`);
1129
+ }
1130
+ );
1131
+ }
1132
+ function importsExampleAlias(file, source) {
1133
+ return importedModuleSpecifiers(file, source).some(
1134
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
1135
+ );
1136
+ }
1137
+ function namedImports(file, source, moduleName) {
1138
+ const names = /* @__PURE__ */ new Set();
1139
+ for (const declaration of staticImports(file, source)) {
1140
+ if (declaration.moduleRequest.value !== moduleName) continue;
1141
+ for (const entry of declaration.entries) {
1142
+ if (entry.importName.kind === "Name" && entry.importName.name) {
1143
+ names.add(entry.importName.name);
1144
+ }
1145
+ }
1146
+ }
1147
+ return names;
1148
+ }
1149
+ function containsAny(values, expected) {
1150
+ return [...values].some((value) => expected.has(value));
1151
+ }
1152
+ function isRecord(value) {
1153
+ return typeof value === "object" && value !== null;
1154
+ }
1155
+ function containsObserveProperty(value) {
1156
+ if (Array.isArray(value)) return value.some(containsObserveProperty);
1157
+ if (!isRecord(value)) return false;
1158
+ if (value.type === "Property" && isRecord(value.key)) {
1159
+ if (value.key.type === "Identifier" && value.key.name === "observe") return true;
1160
+ if (value.key.type === "Literal" && value.key.value === "observe") return true;
1161
+ }
1162
+ return Object.values(value).some(containsObserveProperty);
1163
+ }
1164
+ function declaresObserve(file, source) {
1165
+ return containsObserveProperty(parseSource(file, source).program);
1166
+ }
1167
+ function auditReactAuthoritativePlaythrough(projectRoot) {
1168
+ const clockFiles = [];
1169
+ const controllerFiles = [];
1170
+ const productionFiles = sourceFiles(projectRoot);
1171
+ for (const file of productionFiles) {
1172
+ const imports = namedImports(
1173
+ file,
1174
+ (0, import_node_fs.readFileSync)(file, "utf8"),
1175
+ REACT_RUNTIME_ENTRY
1176
+ );
1177
+ const projectPath = (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/");
1178
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
1179
+ if (containsAny(imports, CONTROLLER_IMPORTS))
1180
+ controllerFiles.push(projectPath);
1181
+ }
1182
+ const appPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_APP);
1183
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
1184
+ const productionUsesExample = productionFiles.some(
1185
+ (file) => productionFileImportsExample(file, projectRoot)
1186
+ );
1187
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter(
1188
+ (file) => importsExampleAlias(file, (0, import_node_fs.readFileSync)(file, "utf8"))
1189
+ ).map((file) => (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/"));
1190
+ const issues = [];
1191
+ if (staleExampleTestFiles.length > 0) {
1192
+ issues.push(
1193
+ `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.`
1194
+ );
1195
+ }
1196
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
1197
+ return {
1198
+ ok: issues.length === 0,
1199
+ issues,
1200
+ clockFiles,
1201
+ controllerFiles,
1202
+ productionEntryExists,
1203
+ productionUsesExample,
1204
+ staleExampleTestFiles
1205
+ };
1206
+ }
1207
+ const testPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
1208
+ const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
1209
+ const testingImports = namedImports(
1210
+ testPath,
1211
+ testSource,
1212
+ REACT_TESTING_ENTRY
1213
+ );
1214
+ const hasObserve = declaresObserve(testPath, testSource);
1215
+ if (!hasObserve) {
1216
+ issues.push(
1217
+ `${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
1218
+ );
1219
+ }
1220
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
1221
+ issues.push(
1222
+ `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
1223
+ );
1224
+ }
1225
+ return {
1226
+ ok: issues.length === 0,
1227
+ issues,
1228
+ clockFiles,
1229
+ controllerFiles,
1230
+ productionEntryExists,
1231
+ productionUsesExample,
1232
+ staleExampleTestFiles
1233
+ };
1234
+ }
1235
+
1236
+ // src/react/react-playthrough-policy.ts
1237
+ function assessProductTestAlignment(projectRoot) {
1238
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
1239
+ if (audit.staleExampleTestFiles.length > 0) {
1240
+ return { status: "FAILED", cause: audit.issues[0] };
1241
+ }
1242
+ if (!audit.productionEntryExists) {
1243
+ return {
1244
+ status: "NOT_VERIFIED",
1245
+ cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
1246
+ };
1247
+ }
1248
+ if (audit.productionUsesExample) {
1249
+ return {
1250
+ status: "NOT_VERIFIED",
1251
+ cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
1252
+ };
1253
+ }
1254
+ return { status: "PASS" };
1255
+ }
1089
1256
  function repairGuidance(code) {
1090
1257
  switch (code) {
1091
1258
  case "GAME_SNAPSHOT_REFERENCE_REUSED":
@@ -1100,8 +1267,16 @@ function repairGuidance(code) {
1100
1267
  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
1268
  case "PLAYTHROUGH_BOUND_EXHAUSTED":
1102
1269
  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.";
1270
+ case "PLAYTHROUGH_OUTCOME_NOT_REACHED":
1271
+ return "The stage action completed, but its until condition never became true. Inspect TRACE and Last diagnostics, then confirm that the production DOM input reaches the rendered App, until describes the result caused by this stage, and observe reads the matching authoritative state when used. Add a deterministic step only if the gameplay is actually driven by time or frames; do not add a no-op step or weaken the intended outcome.";
1103
1272
  case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
1104
1273
  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.";
1274
+ case "EXPECTATION_MISMATCH":
1275
+ return "The test assertion observed a different value than it expected. Compare EXPECTED and ACTUAL with the game-owned state type and inspect ORIGIN before editing. Fix production only when the observed value violates the product contract; otherwise correct the test expectation without weakening the intended outcome.";
1276
+ case "PRODUCT_RUNTIME_TYPE_ERROR":
1277
+ return "A TypeError originated in production source. Open ORIGIN, validate the referenced dependency or state before mutation, and fix the owning initialization or lifecycle boundary. Do not bypass the crashing path in the test.";
1278
+ case "TEST_API_MISMATCH":
1279
+ return "A TypeError originated in test code. Open ORIGIN and align the call with the installed public API or the game-owned typed Session. Do not add a production shim solely for an invented test helper.";
1105
1280
  case "INVALID_STAGE_ORDER":
1106
1281
  case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
1107
1282
  case "INVALID_STAGE_NAME":
@@ -1187,36 +1362,14 @@ function assessReactPlaythroughReport(input) {
1187
1362
  }
1188
1363
  return { ...base, status: "PASS", failsRun: false };
1189
1364
  }
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}`);
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
- }
1205
- if (report.waiverReasons?.length) {
1206
- lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
1207
- }
1208
- if (report.next) lines.push(`NEXT: ${report.next}`);
1209
- if (report.status === "FAILED") {
1210
- lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
1211
- }
1212
- return `
1213
- ${lines.join("\n")}`;
1214
- }
1365
+
1366
+ // src/react/react-playthrough-reporter.ts
1367
+ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
1215
1368
  var ReactPlaythroughReporter = class {
1216
1369
  /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
1217
1370
  constructor(projectRoot) {
1218
1371
  this.projectRoot = projectRoot;
1219
- this.expectedModuleId = (0, import_node_path2.resolve)(projectRoot, this.expectedFile);
1372
+ this.expectedModuleId = (0, import_node_path3.resolve)(projectRoot, this.expectedFile);
1220
1373
  }
1221
1374
  projectRoot;
1222
1375
  expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
@@ -1226,7 +1379,7 @@ var ReactPlaythroughReporter = class {
1226
1379
  /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
1227
1380
  onTestRunStart(specifications) {
1228
1381
  this.expectedFileScheduled = specifications.some(
1229
- (specification) => (0, import_node_path2.resolve)(specification.moduleId) === this.expectedModuleId
1382
+ (specification) => (0, import_node_path3.resolve)(specification.moduleId) === this.expectedModuleId
1230
1383
  );
1231
1384
  this.focusedSelection = specifications.some(
1232
1385
  (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
@@ -1238,16 +1391,17 @@ var ReactPlaythroughReporter = class {
1238
1391
  testModules,
1239
1392
  this.projectRoot
1240
1393
  );
1394
+ const moduleResults = testModules.map(
1395
+ (module2) => toReactPlaythroughModuleResult(module2, this.projectRoot)
1396
+ );
1241
1397
  const report = assessReactPlaythroughReport({
1242
1398
  expectedFile: this.expectedFile,
1243
1399
  expectedFileExists: (0, import_node_fs2.existsSync)(this.expectedModuleId),
1244
1400
  expectedFileScheduled: this.expectedFileScheduled,
1245
1401
  focusedSelection: this.focusedSelection,
1246
- modules: testModules.map(
1247
- (module2) => toModuleResult(module2, this.projectRoot)
1248
- ),
1402
+ modules: moduleResults,
1249
1403
  unhandledErrors: extractFailureEntries(unhandledErrors).map(
1250
- (entry) => firstLine(entry.message) ?? entry.message
1404
+ (entry) => firstFailureLine(entry.message) ?? entry.message
1251
1405
  )
1252
1406
  });
1253
1407
  const alignment = assessProductTestAlignment(this.projectRoot);
@@ -1265,14 +1419,14 @@ var ReactPlaythroughReporter = class {
1265
1419
  console.error(pendingOutput);
1266
1420
  process.exitCode = 1;
1267
1421
  }
1268
- const summary = formatReactFailureSummary(
1269
- testModules.map((module2) => toModuleResult(module2, this.projectRoot))
1270
- );
1422
+ const summary = formatReactFailureSummary(moduleResults);
1271
1423
  if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
1272
1424
  summary[0] = "TEST_RESULT: FAIL";
1273
1425
  }
1274
- console.log(`
1275
- ${summary.join("\n")}`);
1426
+ console.log(
1427
+ `
1428
+ ${[...formatReactTestCountSummary(moduleResults), ...summary].join("\n")}`
1429
+ );
1276
1430
  }
1277
1431
  };
1278
1432
 
@@ -1284,13 +1438,13 @@ function getJSDOMWorkerExecArgv() {
1284
1438
 
1285
1439
  // src/react-vitest-config.ts
1286
1440
  function resolvePhaser3BrowserEntry(projectRoot) {
1287
- const manifestPath = (0, import_node_path3.resolve)(projectRoot, "node_modules/phaser/package.json");
1441
+ const manifestPath = (0, import_node_path4.resolve)(projectRoot, "node_modules/phaser/package.json");
1288
1442
  if (!(0, import_node_fs3.existsSync)(manifestPath)) return void 0;
1289
1443
  try {
1290
1444
  const manifest = JSON.parse((0, import_node_fs3.readFileSync)(manifestPath, "utf8"));
1291
1445
  if (!manifest.version?.startsWith("3.")) return void 0;
1292
- const browserEntry = (0, import_node_path3.resolve)(
1293
- (0, import_node_path3.dirname)(manifestPath),
1446
+ const browserEntry = (0, import_node_path4.resolve)(
1447
+ (0, import_node_path4.dirname)(manifestPath),
1294
1448
  manifest.browser ?? "dist/phaser.js"
1295
1449
  );
1296
1450
  return (0, import_node_fs3.existsSync)(browserEntry) ? browserEntry : void 0;
@@ -1308,7 +1462,7 @@ function defineReactGameVitestConfig(options) {
1308
1462
  alias: {
1309
1463
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
1310
1464
  ...options.aliases,
1311
- "@": (0, import_node_path3.resolve)(options.projectRoot, "src")
1465
+ "@": (0, import_node_path4.resolve)(options.projectRoot, "src")
1312
1466
  }
1313
1467
  },
1314
1468
  test: {
@@ -1332,8 +1486,11 @@ function defineReactGameVitestConfig(options) {
1332
1486
  sequence: {
1333
1487
  setupFiles: "list"
1334
1488
  },
1335
- // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
1336
- reporters: [new ReactPlaythroughReporter(options.projectRoot)],
1489
+ ...options.enablePlaythroughReporter ? {
1490
+ // The structured reporter remains available to existing Devkit consumers,
1491
+ // but is no longer a default requirement for generated React games.
1492
+ reporters: [new ReactPlaythroughReporter(options.projectRoot)]
1493
+ } : {},
1337
1494
  restoreMocks: true,
1338
1495
  clearMocks: true,
1339
1496
  testTimeout: options.testTimeout,