miaoda-game-devkit 0.6.4 → 0.6.6

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. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
260
- );
261
- }
262
- if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
263
- issues.push(
264
- `${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
265
- );
266
- }
267
- return {
268
- ok: issues.length === 0,
269
- issues,
270
- clockFiles,
271
- controllerFiles,
272
- productionEntryExists,
273
- productionUsesExample,
274
- staleExampleTestFiles
275
- };
276
- }
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) {
@@ -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,6 +778,131 @@ function findPendingProductTests(modules, projectRoot) {
926
778
  }));
927
779
  });
928
780
  }
781
+ function selectReactFailure(values, errorRecordCount = values.length) {
782
+ const entries = extractFailureEntries(values);
783
+ if (entries.length === 0) {
784
+ return {
785
+ code: "MISSING_FAILURE_DETAILS",
786
+ cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
787
+ rawCause: "",
788
+ related: []
789
+ };
790
+ }
791
+ const primary = entries[0];
792
+ const rawCause = primary.message;
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
+ };
804
+ }
805
+ function failureHint(value) {
806
+ const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
807
+ (match) => match[1]
808
+ );
809
+ if (names.length === 0) return void 0;
810
+ return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
811
+ }
812
+ function errorLocation(value) {
813
+ const match = value.match(
814
+ /(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
815
+ );
816
+ return match?.[1];
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
+ }
823
+ function failureTrace(test2) {
824
+ const annotations = test2.annotations();
825
+ let annotationTrace;
826
+ for (let index = annotations.length - 1; index >= 0; index -= 1) {
827
+ if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
828
+ annotationTrace = annotations[index].message;
829
+ break;
830
+ }
831
+ }
832
+ const metadata = test2.meta().reactPlaythrough;
833
+ const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
834
+ return trace ? truncateReporterLine(trace, 720) : void 0;
835
+ }
836
+ function toReactPlaythroughModuleResult(module2, projectRoot) {
837
+ const tests = [...module2.children.allTests()];
838
+ const moduleFailure = selectReactFailure(
839
+ module2.errors(),
840
+ module2.errors().length
841
+ );
842
+ const moduleErrors = extractFailureEntries(module2.errors()).map(
843
+ (entry) => firstFailureLine(entry.message) ?? entry.message
844
+ );
845
+ const errors = [
846
+ ...moduleErrors,
847
+ ...tests.flatMap(
848
+ (test2) => extractFailureEntries(test2.result().errors ?? []).map(
849
+ (entry) => firstFailureLine(entry.message) ?? entry.message
850
+ )
851
+ )
852
+ ];
853
+ const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
854
+ const metadata = test2.meta().reactPlaythrough;
855
+ const testErrors = test2.result().errors ?? [];
856
+ const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
857
+ const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
858
+ const selected = selectReactFailure(
859
+ [...attemptErrors, ...module2.errors()],
860
+ testErrors.length + module2.errors().length
861
+ );
862
+ return {
863
+ test: test2.fullName,
864
+ causeCode: selected.code,
865
+ cause: selected.cause,
866
+ errorName: selected.errorName,
867
+ actual: selected.actual,
868
+ expected: selected.expected,
869
+ origin: selected.origin,
870
+ layer: selected.layer,
871
+ related: selected.related,
872
+ location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
873
+ hint: failureHint(selected.rawCause),
874
+ trace: failureTrace(test2)
875
+ };
876
+ });
877
+ if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
878
+ failures.push({
879
+ test: "<collection>",
880
+ causeCode: moduleFailure.code,
881
+ cause: moduleFailure.cause,
882
+ errorName: moduleFailure.errorName,
883
+ actual: moduleFailure.actual,
884
+ expected: moduleFailure.expected,
885
+ origin: moduleFailure.origin,
886
+ layer: moduleFailure.layer,
887
+ related: moduleFailure.related,
888
+ location: errorLocation(moduleFailure.rawCause),
889
+ hint: failureHint(moduleFailure.rawCause)
890
+ });
891
+ }
892
+ return {
893
+ file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
894
+ state: module2.state(),
895
+ errors,
896
+ primaryError: moduleErrors[0] ?? failures[0]?.cause,
897
+ primaryCauseCode: failures[0]?.causeCode,
898
+ relatedErrors: failures[0]?.related,
899
+ tests: tests.map(toAuditInput),
900
+ failures
901
+ };
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.";
929
906
  function formatPendingProductTestReport(pending) {
930
907
  if (pending.length === 0) return void 0;
931
908
  const lines = [
@@ -942,149 +919,383 @@ function formatPendingProductTestReport(pending) {
942
919
  );
943
920
  reportedIncompleteFiles.add(item.file);
944
921
  }
945
- lines.push(`FILE: ${item.file}`);
946
- lines.push(`TEST: ${item.test}`);
947
- lines.push(`MODE: ${item.mode}`);
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
+ }
944
+ function formatReactFailureSummary(modules) {
945
+ const failures = modules.flatMap(
946
+ (module2) => (module2.failures ?? []).map((failure) => ({
947
+ ...failure,
948
+ file: module2.file
949
+ }))
950
+ );
951
+ if (failures.length === 0) return ["TEST_RESULT: PASS"];
952
+ const lines = [`FAILED_TESTS: ${failures.length}`];
953
+ for (const [index, failure] of failures.entries()) {
954
+ lines.push(`FAILURE_${index + 1}: ${failure.file}`);
955
+ lines.push(`TEST: ${failure.test}`);
956
+ lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
957
+ lines.push(`CAUSE: ${failure.cause}`);
958
+ appendFailureDetails(lines, failure);
959
+ for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
960
+ lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
961
+ lines.push(
962
+ `RELATED_${relatedIndex + 1}: ${firstFailureLine(related.message) ?? related.message}`
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
+ }
972
+ }
973
+ if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
974
+ if (failure.location) lines.push(`AT: ${failure.location}`);
975
+ if (failure.hint) lines.push(`HINT: ${failure.hint}`);
976
+ }
977
+ lines.push("TEST_RESULT: FAIL");
978
+ return lines;
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 PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
1051
+ var PRODUCTION_APP = "src/App.tsx";
1052
+ var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
1053
+ var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
1054
+ var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
1055
+ var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
1056
+ var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
1057
+ "useGameController",
1058
+ "useOwnedGameController"
1059
+ ]);
1060
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
1061
+ function runnableTestFiles(root, directory = (0, import_node_path2.join)(root, "tests")) {
1062
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
1063
+ const files = [];
1064
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
1065
+ const path = (0, import_node_path2.join)(directory, entry.name);
1066
+ if (entry.isDirectory()) {
1067
+ files.push(...runnableTestFiles(root, path));
1068
+ } else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
1069
+ files.push(path);
1070
+ }
1071
+ }
1072
+ return files;
1073
+ }
1074
+ function extension(path) {
1075
+ const index = path.lastIndexOf(".");
1076
+ return index < 0 ? "" : path.slice(index);
1077
+ }
1078
+ function sourceFiles(root, directory = (0, import_node_path2.join)(root, "src")) {
1079
+ if (!(0, import_node_fs.existsSync)(directory)) return [];
1080
+ const files = [];
1081
+ for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
1082
+ const path = (0, import_node_path2.join)(directory, entry.name);
1083
+ const projectPath = (0, import_node_path2.relative)(root, path).replaceAll("\\", "/");
1084
+ if (entry.isDirectory()) {
1085
+ if (projectPath === "src/game/example") continue;
1086
+ files.push(...sourceFiles(root, path));
1087
+ } else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
1088
+ files.push(path);
1089
+ }
1090
+ }
1091
+ return files;
1092
+ }
1093
+ function withoutComments(source) {
1094
+ let output = "";
1095
+ let state = "code";
1096
+ for (let index = 0; index < source.length; index += 1) {
1097
+ const char = source[index];
1098
+ const next = source[index + 1];
1099
+ if (state === "line") {
1100
+ if (char === "\n") {
1101
+ state = "code";
1102
+ output += char;
1103
+ } else {
1104
+ output += " ";
1105
+ }
1106
+ continue;
1107
+ }
1108
+ if (state === "block") {
1109
+ if (char === "*" && next === "/") {
1110
+ output += " ";
1111
+ index += 1;
1112
+ state = "code";
1113
+ } else {
1114
+ output += char === "\n" ? "\n" : " ";
1115
+ }
1116
+ continue;
1117
+ }
1118
+ if (state === "code" && char === "/" && next === "/") {
1119
+ output += " ";
1120
+ index += 1;
1121
+ state = "line";
1122
+ continue;
1123
+ }
1124
+ if (state === "code" && char === "/" && next === "*") {
1125
+ output += " ";
1126
+ index += 1;
1127
+ state = "block";
1128
+ continue;
1129
+ }
1130
+ if (state === "code" && char === "'") state = "single";
1131
+ else if (state === "code" && char === '"') state = "double";
1132
+ else if (state === "code" && char === "`") state = "template";
1133
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
1134
+ state = "code";
1135
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
1136
+ state = "code";
1137
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
1138
+ state = "code";
1139
+ }
1140
+ output += char;
1141
+ }
1142
+ return output;
1143
+ }
1144
+ function codePositions(source) {
1145
+ const positions = Array.from({ length: source.length }, () => false);
1146
+ let state = "code";
1147
+ for (let index = 0; index < source.length; index += 1) {
1148
+ const char = source[index];
1149
+ if (state === "code") positions[index] = true;
1150
+ if (state === "code" && char === "'") state = "single";
1151
+ else if (state === "code" && char === '"') state = "double";
1152
+ else if (state === "code" && char === "`") state = "template";
1153
+ else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
1154
+ state = "code";
1155
+ } else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
1156
+ state = "code";
1157
+ } else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
1158
+ state = "code";
1159
+ }
948
1160
  }
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);
1161
+ return positions;
958
1162
  }
959
- function selectReactFailure(values, errorRecordCount = values.length) {
960
- const entries = extractFailureEntries(values);
961
- if (entries.length === 0) {
962
- return {
963
- code: "MISSING_FAILURE_DETAILS",
964
- cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
965
- rawCause: "",
966
- related: []
967
- };
1163
+ function importedModuleSpecifiers(source) {
1164
+ const clean = withoutComments(source);
1165
+ const positions = codePositions(clean);
1166
+ const modules = [];
1167
+ const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
1168
+ for (const match of clean.matchAll(pattern)) {
1169
+ if (positions[match.index]) modules.push(match[1]);
968
1170
  }
969
- const primary = entries[0];
970
- const rawCause = primary.message;
971
- const cause = firstLine(rawCause) ?? rawCause;
972
- return { code: primary.code, cause, rawCause, related: entries.slice(1) };
1171
+ return modules;
973
1172
  }
974
- function failureHint(value) {
975
- const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
976
- (match) => match[1]
1173
+ function productionFileImportsExample(file, projectRoot) {
1174
+ const exampleRoot = (0, import_node_path2.join)(projectRoot, "src/game/example");
1175
+ return importedModuleSpecifiers((0, import_node_fs.readFileSync)(file, "utf8")).some(
1176
+ (moduleName) => {
1177
+ if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
1178
+ if (!moduleName.startsWith(".")) return false;
1179
+ const target = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(file), moduleName);
1180
+ return target === exampleRoot || target.startsWith(`${exampleRoot}${import_node_path2.sep}`);
1181
+ }
977
1182
  );
978
- if (names.length === 0) return void 0;
979
- return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
980
1183
  }
981
- function errorLocation(value) {
982
- const match = value.match(
983
- /(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
1184
+ function importsExampleAlias(source) {
1185
+ return importedModuleSpecifiers(source).some(
1186
+ (moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
984
1187
  );
985
- return match?.[1];
986
1188
  }
987
- function failureTrace(test2) {
988
- const annotations = test2.annotations();
989
- let annotationTrace;
990
- for (let index = annotations.length - 1; index >= 0; index -= 1) {
991
- if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
992
- annotationTrace = annotations[index].message;
993
- break;
1189
+ function namedImports(source, moduleName) {
1190
+ const names = /* @__PURE__ */ new Set();
1191
+ const clean = withoutComments(source);
1192
+ const positions = codePositions(clean);
1193
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1194
+ const pattern = new RegExp(
1195
+ `^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
1196
+ "gm"
1197
+ );
1198
+ for (const match of clean.matchAll(pattern)) {
1199
+ if (!positions[match.index]) continue;
1200
+ for (const specifier of match[1].split(",")) {
1201
+ const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
1202
+ if (imported) names.add(imported);
994
1203
  }
995
1204
  }
996
- const metadata = test2.meta().reactPlaythrough;
997
- const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
998
- return trace ? truncateReporterLine(trace, 720) : void 0;
1205
+ return names;
999
1206
  }
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`;
1207
+ function containsAny(values, expected) {
1208
+ return [...values].some((value) => expected.has(value));
1004
1209
  }
1005
- function toModuleResult(module2, projectRoot) {
1006
- const tests = [...module2.children.allTests()];
1007
- const moduleFailure = selectReactFailure(
1008
- module2.errors(),
1009
- module2.errors().length
1010
- );
1011
- const moduleErrors = extractFailureEntries(module2.errors()).map(
1012
- (entry) => firstLine(entry.message) ?? entry.message
1210
+ function declaresObserve(source) {
1211
+ const clean = withoutComments(source);
1212
+ const positions = codePositions(clean);
1213
+ for (const match of clean.matchAll(/\bobserve\s*:/g)) {
1214
+ if (positions[match.index]) return true;
1215
+ }
1216
+ return false;
1217
+ }
1218
+ function auditReactAuthoritativePlaythrough(projectRoot) {
1219
+ const clockFiles = [];
1220
+ const controllerFiles = [];
1221
+ const productionFiles = sourceFiles(projectRoot);
1222
+ for (const file of productionFiles) {
1223
+ const imports = namedImports(
1224
+ (0, import_node_fs.readFileSync)(file, "utf8"),
1225
+ REACT_RUNTIME_ENTRY
1226
+ );
1227
+ const projectPath = (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/");
1228
+ if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
1229
+ if (containsAny(imports, CONTROLLER_IMPORTS))
1230
+ controllerFiles.push(projectPath);
1231
+ }
1232
+ const appPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_APP);
1233
+ const productionEntryExists = (0, import_node_fs.existsSync)(appPath);
1234
+ const productionUsesExample = productionFiles.some(
1235
+ (file) => productionFileImportsExample(file, projectRoot)
1013
1236
  );
1014
- const errors = [
1015
- ...moduleErrors,
1016
- ...tests.flatMap(
1017
- (test2) => extractFailureEntries(test2.result().errors ?? []).map(
1018
- (entry) => firstLine(entry.message) ?? entry.message
1019
- )
1020
- )
1021
- ];
1022
- const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
1023
- const metadata = test2.meta().reactPlaythrough;
1024
- const testErrors = test2.result().errors ?? [];
1025
- const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
1026
- const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
1027
- const selected = selectReactFailure(
1028
- [...attemptErrors, ...module2.errors()],
1029
- testErrors.length + module2.errors().length
1237
+ const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias((0, import_node_fs.readFileSync)(file, "utf8"))).map((file) => (0, import_node_path2.relative)(projectRoot, file).replaceAll("\\", "/"));
1238
+ const issues = [];
1239
+ if (staleExampleTestFiles.length > 0) {
1240
+ issues.push(
1241
+ `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.`
1030
1242
  );
1243
+ }
1244
+ if (clockFiles.length === 0 && controllerFiles.length === 0) {
1031
1245
  return {
1032
- test: test2.fullName,
1033
- causeCode: selected.code,
1034
- cause: selected.cause,
1035
- related: selected.related,
1036
- location: test2.location ? `${(0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
1037
- hint: failureHint(selected.rawCause),
1038
- trace: failureTrace(test2)
1246
+ ok: issues.length === 0,
1247
+ issues,
1248
+ clockFiles,
1249
+ controllerFiles,
1250
+ productionEntryExists,
1251
+ productionUsesExample,
1252
+ staleExampleTestFiles
1039
1253
  };
1040
- });
1041
- if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
1042
- failures.push({
1043
- test: "<collection>",
1044
- causeCode: moduleFailure.code,
1045
- cause: moduleFailure.cause,
1046
- related: moduleFailure.related,
1047
- location: errorLocation(moduleFailure.rawCause),
1048
- hint: failureHint(moduleFailure.rawCause)
1049
- });
1254
+ }
1255
+ const testPath = (0, import_node_path2.join)(projectRoot, PRODUCTION_PLAYTHROUGH);
1256
+ const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
1257
+ const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
1258
+ const hasObserve = declaresObserve(testSource);
1259
+ if (!hasObserve) {
1260
+ issues.push(
1261
+ `${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.`
1262
+ );
1263
+ }
1264
+ if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
1265
+ issues.push(
1266
+ `${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().`
1267
+ );
1050
1268
  }
1051
1269
  return {
1052
- file: (0, import_node_path2.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
1053
- state: module2.state(),
1054
- errors,
1055
- primaryError: moduleErrors[0] ?? failures[0]?.cause,
1056
- primaryCauseCode: failures[0]?.causeCode,
1057
- relatedErrors: failures[0]?.related,
1058
- tests: tests.map(toAuditInput),
1059
- failures
1270
+ ok: issues.length === 0,
1271
+ issues,
1272
+ clockFiles,
1273
+ controllerFiles,
1274
+ productionEntryExists,
1275
+ productionUsesExample,
1276
+ staleExampleTestFiles
1060
1277
  };
1061
1278
  }
1062
- function formatReactFailureSummary(modules) {
1063
- const failures = modules.flatMap(
1064
- (module2) => (module2.failures ?? []).map((failure) => ({
1065
- ...failure,
1066
- file: module2.file
1067
- }))
1068
- );
1069
- if (failures.length === 0) return ["TEST_RESULT: PASS"];
1070
- const lines = [`FAILED_TESTS: ${failures.length}`];
1071
- for (const [index, failure] of failures.entries()) {
1072
- lines.push(`FAILURE_${index + 1}: ${failure.file}`);
1073
- lines.push(`TEST: ${failure.test}`);
1074
- lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
1075
- lines.push(`CAUSE: ${failure.cause}`);
1076
- for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
1077
- lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
1078
- lines.push(
1079
- `RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
1080
- );
1081
- }
1082
- if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
1083
- if (failure.location) lines.push(`AT: ${failure.location}`);
1084
- if (failure.hint) lines.push(`HINT: ${failure.hint}`);
1279
+
1280
+ // src/react/react-playthrough-policy.ts
1281
+ function assessProductTestAlignment(projectRoot) {
1282
+ const audit = auditReactAuthoritativePlaythrough(projectRoot);
1283
+ if (audit.staleExampleTestFiles.length > 0) {
1284
+ return { status: "FAILED", cause: audit.issues[0] };
1085
1285
  }
1086
- lines.push("TEST_RESULT: FAIL");
1087
- return lines;
1286
+ if (!audit.productionEntryExists) {
1287
+ return {
1288
+ status: "NOT_VERIFIED",
1289
+ cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
1290
+ };
1291
+ }
1292
+ if (audit.productionUsesExample) {
1293
+ return {
1294
+ status: "NOT_VERIFIED",
1295
+ cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
1296
+ };
1297
+ }
1298
+ return { status: "PASS" };
1088
1299
  }
1089
1300
  function repairGuidance(code) {
1090
1301
  switch (code) {
@@ -1102,6 +1313,12 @@ function repairGuidance(code) {
1102
1313
  return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
1103
1314
  case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
1104
1315
  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.";
1316
+ case "EXPECTATION_MISMATCH":
1317
+ 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.";
1318
+ case "PRODUCT_RUNTIME_TYPE_ERROR":
1319
+ 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.";
1320
+ case "TEST_API_MISMATCH":
1321
+ 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
1322
  case "INVALID_STAGE_ORDER":
1106
1323
  case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
1107
1324
  case "INVALID_STAGE_NAME":
@@ -1187,36 +1404,14 @@ function assessReactPlaythroughReport(input) {
1187
1404
  }
1188
1405
  return { ...base, status: "PASS", failsRun: false };
1189
1406
  }
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
- }
1407
+
1408
+ // src/react/react-playthrough-reporter.ts
1409
+ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
1215
1410
  var ReactPlaythroughReporter = class {
1216
1411
  /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
1217
1412
  constructor(projectRoot) {
1218
1413
  this.projectRoot = projectRoot;
1219
- this.expectedModuleId = (0, import_node_path2.resolve)(projectRoot, this.expectedFile);
1414
+ this.expectedModuleId = (0, import_node_path3.resolve)(projectRoot, this.expectedFile);
1220
1415
  }
1221
1416
  projectRoot;
1222
1417
  expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
@@ -1226,7 +1421,7 @@ var ReactPlaythroughReporter = class {
1226
1421
  /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
1227
1422
  onTestRunStart(specifications) {
1228
1423
  this.expectedFileScheduled = specifications.some(
1229
- (specification) => (0, import_node_path2.resolve)(specification.moduleId) === this.expectedModuleId
1424
+ (specification) => (0, import_node_path3.resolve)(specification.moduleId) === this.expectedModuleId
1230
1425
  );
1231
1426
  this.focusedSelection = specifications.some(
1232
1427
  (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
@@ -1238,16 +1433,17 @@ var ReactPlaythroughReporter = class {
1238
1433
  testModules,
1239
1434
  this.projectRoot
1240
1435
  );
1436
+ const moduleResults = testModules.map(
1437
+ (module2) => toReactPlaythroughModuleResult(module2, this.projectRoot)
1438
+ );
1241
1439
  const report = assessReactPlaythroughReport({
1242
1440
  expectedFile: this.expectedFile,
1243
1441
  expectedFileExists: (0, import_node_fs2.existsSync)(this.expectedModuleId),
1244
1442
  expectedFileScheduled: this.expectedFileScheduled,
1245
1443
  focusedSelection: this.focusedSelection,
1246
- modules: testModules.map(
1247
- (module2) => toModuleResult(module2, this.projectRoot)
1248
- ),
1444
+ modules: moduleResults,
1249
1445
  unhandledErrors: extractFailureEntries(unhandledErrors).map(
1250
- (entry) => firstLine(entry.message) ?? entry.message
1446
+ (entry) => firstFailureLine(entry.message) ?? entry.message
1251
1447
  )
1252
1448
  });
1253
1449
  const alignment = assessProductTestAlignment(this.projectRoot);
@@ -1265,14 +1461,14 @@ var ReactPlaythroughReporter = class {
1265
1461
  console.error(pendingOutput);
1266
1462
  process.exitCode = 1;
1267
1463
  }
1268
- const summary = formatReactFailureSummary(
1269
- testModules.map((module2) => toModuleResult(module2, this.projectRoot))
1270
- );
1464
+ const summary = formatReactFailureSummary(moduleResults);
1271
1465
  if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
1272
1466
  summary[0] = "TEST_RESULT: FAIL";
1273
1467
  }
1274
- console.log(`
1275
- ${summary.join("\n")}`);
1468
+ console.log(
1469
+ `
1470
+ ${[...formatReactTestCountSummary(moduleResults), ...summary].join("\n")}`
1471
+ );
1276
1472
  }
1277
1473
  };
1278
1474
 
@@ -1284,13 +1480,13 @@ function getJSDOMWorkerExecArgv() {
1284
1480
 
1285
1481
  // src/react-vitest-config.ts
1286
1482
  function resolvePhaser3BrowserEntry(projectRoot) {
1287
- const manifestPath = (0, import_node_path3.resolve)(projectRoot, "node_modules/phaser/package.json");
1483
+ const manifestPath = (0, import_node_path4.resolve)(projectRoot, "node_modules/phaser/package.json");
1288
1484
  if (!(0, import_node_fs3.existsSync)(manifestPath)) return void 0;
1289
1485
  try {
1290
1486
  const manifest = JSON.parse((0, import_node_fs3.readFileSync)(manifestPath, "utf8"));
1291
1487
  if (!manifest.version?.startsWith("3.")) return void 0;
1292
- const browserEntry = (0, import_node_path3.resolve)(
1293
- (0, import_node_path3.dirname)(manifestPath),
1488
+ const browserEntry = (0, import_node_path4.resolve)(
1489
+ (0, import_node_path4.dirname)(manifestPath),
1294
1490
  manifest.browser ?? "dist/phaser.js"
1295
1491
  );
1296
1492
  return (0, import_node_fs3.existsSync)(browserEntry) ? browserEntry : void 0;
@@ -1308,7 +1504,7 @@ function defineReactGameVitestConfig(options) {
1308
1504
  alias: {
1309
1505
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
1310
1506
  ...options.aliases,
1311
- "@": (0, import_node_path3.resolve)(options.projectRoot, "src")
1507
+ "@": (0, import_node_path4.resolve)(options.projectRoot, "src")
1312
1508
  }
1313
1509
  },
1314
1510
  test: {