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