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