miaoda-game-devkit 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/phaser-lint.js +62 -105
- package/dist/cli/react-lint.js +62 -105
- package/dist/react/index.d.mts +3 -0
- package/dist/react/index.d.ts +3 -0
- package/dist/react/testing.d.mts +22 -0
- package/dist/react/testing.d.ts +22 -0
- package/dist/react/testing.js +114 -16
- package/dist/react/testing.mjs +114 -16
- package/dist/react/vitest-config.d.mts +5 -0
- package/dist/react/vitest-config.d.ts +5 -0
- package/dist/react/vitest-config.js +522 -365
- package/dist/react/vitest-config.mjs +515 -358
- package/dist/react/vitest-setup.js +112 -14
- package/dist/react/vitest-setup.mjs +112 -14
- package/package.json +2 -1
|
@@ -5,246 +5,7 @@ import { defineConfig } from "vitest/config";
|
|
|
5
5
|
|
|
6
6
|
// src/react/react-playthrough-reporter.ts
|
|
7
7
|
import { existsSync as existsSync2 } from "fs";
|
|
8
|
-
import {
|
|
9
|
-
import { stripVTControlCharacters } from "util";
|
|
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. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
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(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
return {
|
|
234
|
-
ok: issues.length === 0,
|
|
235
|
-
issues,
|
|
236
|
-
clockFiles,
|
|
237
|
-
controllerFiles,
|
|
238
|
-
productionEntryExists,
|
|
239
|
-
productionUsesExample,
|
|
240
|
-
staleExampleTestFiles
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// src/react/react-playthrough.ts
|
|
245
|
-
import { render } from "@testing-library/react";
|
|
246
|
-
import userEvent from "@testing-library/user-event";
|
|
247
|
-
import { test } from "vitest";
|
|
8
|
+
import { resolve as resolve2 } from "path";
|
|
248
9
|
|
|
249
10
|
// src/react/react-error-diagnostics.ts
|
|
250
11
|
var MAX_DIAGNOSTIC_LENGTH = 1e3;
|
|
@@ -272,47 +33,145 @@ function safeJson(value) {
|
|
|
272
33
|
return void 0;
|
|
273
34
|
}
|
|
274
35
|
}
|
|
275
|
-
function
|
|
36
|
+
function diagnosticValue(value) {
|
|
37
|
+
if (value === void 0) return void 0;
|
|
276
38
|
if (typeof value === "string") {
|
|
277
|
-
return value.
|
|
39
|
+
return truncate(value.replace(/\s+/g, " ").trim());
|
|
40
|
+
}
|
|
41
|
+
if (value === null || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
42
|
+
return String(value);
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "symbol") return value.toString();
|
|
45
|
+
if (typeof value === "function") {
|
|
46
|
+
return `Function<${value.name || "anonymous"}>`;
|
|
47
|
+
}
|
|
48
|
+
const json = safeJson(value);
|
|
49
|
+
return truncate(json ?? String(value));
|
|
50
|
+
}
|
|
51
|
+
function normalizeFile(value) {
|
|
52
|
+
return value.replaceAll("\\", "/").replace(/^file:\/\//, "");
|
|
53
|
+
}
|
|
54
|
+
function failureLayer(file) {
|
|
55
|
+
const normalized = normalizeFile(file);
|
|
56
|
+
if (normalized.includes("/miaoda-game-devkit/") || normalized.includes("/packages/game-devkit/")) {
|
|
57
|
+
return "harness";
|
|
58
|
+
}
|
|
59
|
+
if (normalized.includes("/node_modules/")) return "dependency";
|
|
60
|
+
if (/(?:^|\/)tests?\//.test(normalized) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalized)) {
|
|
61
|
+
return "test";
|
|
62
|
+
}
|
|
63
|
+
if (/(?:^|\/)src\//.test(normalized)) return "product";
|
|
64
|
+
return "unknown";
|
|
65
|
+
}
|
|
66
|
+
function parsedOrigin(value) {
|
|
67
|
+
if (!value || typeof value !== "object") return void 0;
|
|
68
|
+
const frame = value;
|
|
69
|
+
if (typeof frame.file !== "string" || typeof frame.line !== "number" || typeof frame.column !== "number") {
|
|
70
|
+
return void 0;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
file: normalizeFile(frame.file),
|
|
74
|
+
line: frame.line,
|
|
75
|
+
column: frame.column
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function stackOrigins(record) {
|
|
79
|
+
if (Array.isArray(record.stacks)) {
|
|
80
|
+
const parsed = record.stacks.map(parsedOrigin).filter((origin) => Boolean(origin));
|
|
81
|
+
if (parsed.length > 0) return parsed;
|
|
82
|
+
}
|
|
83
|
+
if (typeof record.stack !== "string") return [];
|
|
84
|
+
const origins = [];
|
|
85
|
+
const pattern = /(?:at\s+.*?\()?((?:file:\/\/)?[^()\s]+):(\d+):(\d+)\)?/g;
|
|
86
|
+
for (const match of record.stack.matchAll(pattern)) {
|
|
87
|
+
origins.push({
|
|
88
|
+
file: normalizeFile(match[1]),
|
|
89
|
+
line: Number(match[2]),
|
|
90
|
+
column: Number(match[3])
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return origins;
|
|
94
|
+
}
|
|
95
|
+
function selectOrigin(record) {
|
|
96
|
+
const origins = stackOrigins(record);
|
|
97
|
+
return origins.find((origin) => {
|
|
98
|
+
const layer = failureLayer(origin.file);
|
|
99
|
+
return layer === "product" || layer === "test";
|
|
100
|
+
}) ?? origins.find((origin) => failureLayer(origin.file) !== "dependency");
|
|
101
|
+
}
|
|
102
|
+
function fallbackCode(record, origin, defaultCode) {
|
|
103
|
+
if (typeof record.code === "string" && record.code.trim()) {
|
|
104
|
+
return record.code.trim();
|
|
105
|
+
}
|
|
106
|
+
if (record.name === "AssertionError" && record.actual !== void 0 && record.expected !== void 0) {
|
|
107
|
+
return "EXPECTATION_MISMATCH";
|
|
108
|
+
}
|
|
109
|
+
if (record.name === "TypeError" && origin) {
|
|
110
|
+
const layer = failureLayer(origin.file);
|
|
111
|
+
if (layer === "product") return "PRODUCT_RUNTIME_TYPE_ERROR";
|
|
112
|
+
if (layer === "test") return "TEST_API_MISMATCH";
|
|
113
|
+
}
|
|
114
|
+
return defaultCode;
|
|
115
|
+
}
|
|
116
|
+
function structuredEntry(record, message, defaultCode) {
|
|
117
|
+
const origin = selectOrigin(record);
|
|
118
|
+
return {
|
|
119
|
+
code: fallbackCode(record, origin, defaultCode),
|
|
120
|
+
message: truncate(message),
|
|
121
|
+
errorName: typeof record.name === "string" && record.name.trim() ? record.name.trim() : void 0,
|
|
122
|
+
actual: diagnosticValue(record.actual),
|
|
123
|
+
expected: diagnosticValue(record.expected),
|
|
124
|
+
origin,
|
|
125
|
+
layer: origin ? failureLayer(origin.file) : void 0
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function collectEntries(value, fallbackCode2, seen) {
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
return value.trim() ? [{ code: fallbackCode2, message: truncate(value) }] : [];
|
|
278
131
|
}
|
|
279
132
|
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
|
|
280
|
-
return [{ code:
|
|
133
|
+
return [{ code: fallbackCode2, message: String(value) }];
|
|
281
134
|
}
|
|
282
135
|
if (typeof value === "function") {
|
|
283
136
|
return [
|
|
284
|
-
{ code:
|
|
137
|
+
{ code: fallbackCode2, message: `Function<${value.name || "anonymous"}>` }
|
|
285
138
|
];
|
|
286
139
|
}
|
|
287
140
|
if (seen.has(value)) return [];
|
|
288
141
|
seen.add(value);
|
|
289
142
|
if (Array.isArray(value)) {
|
|
290
|
-
return value.flatMap((item) => collectEntries(item,
|
|
143
|
+
return value.flatMap((item) => collectEntries(item, fallbackCode2, seen));
|
|
291
144
|
}
|
|
292
145
|
const record = value;
|
|
293
|
-
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
|
|
294
146
|
const entries = [];
|
|
295
147
|
if (typeof record.message === "string" && record.message.trim()) {
|
|
296
|
-
entries.push(
|
|
148
|
+
entries.push(structuredEntry(record, record.message, fallbackCode2));
|
|
297
149
|
}
|
|
298
150
|
if (record.cause !== void 0) {
|
|
299
|
-
entries.push(...collectEntries(record.cause,
|
|
151
|
+
entries.push(...collectEntries(record.cause, fallbackCode2, seen));
|
|
300
152
|
}
|
|
301
153
|
if (Array.isArray(record.errors)) {
|
|
302
|
-
entries.push(...collectEntries(record.errors,
|
|
154
|
+
entries.push(...collectEntries(record.errors, fallbackCode2, seen));
|
|
303
155
|
}
|
|
304
156
|
if (entries.length > 0) return entries;
|
|
305
157
|
if (typeof record.stack === "string" && record.stack.trim()) {
|
|
306
|
-
return [
|
|
158
|
+
return [structuredEntry(record, record.stack, fallbackCode2)];
|
|
307
159
|
}
|
|
308
160
|
const json = safeJson(value);
|
|
309
|
-
return json && json !== "{}" ? [
|
|
161
|
+
return json && json !== "{}" ? [structuredEntry(record, json, fallbackCode2)] : [];
|
|
310
162
|
}
|
|
311
|
-
function extractFailureEntries(value,
|
|
312
|
-
const entries = collectEntries(value,
|
|
163
|
+
function extractFailureEntries(value, fallbackCode2 = "TEST_FAILURE") {
|
|
164
|
+
const entries = collectEntries(value, fallbackCode2, /* @__PURE__ */ new WeakSet());
|
|
313
165
|
const keys = /* @__PURE__ */ new Set();
|
|
314
166
|
return entries.filter((entry) => {
|
|
315
|
-
const key =
|
|
167
|
+
const key = [
|
|
168
|
+
entry.code,
|
|
169
|
+
entry.message,
|
|
170
|
+
entry.errorName,
|
|
171
|
+
entry.actual,
|
|
172
|
+
entry.expected,
|
|
173
|
+
entry.origin ? `${entry.origin.file}:${entry.origin.line}:${entry.origin.column}` : void 0
|
|
174
|
+
].join("\0");
|
|
316
175
|
if (keys.has(key)) return false;
|
|
317
176
|
keys.add(key);
|
|
318
177
|
return true;
|
|
@@ -343,6 +202,15 @@ function codedError(code, message) {
|
|
|
343
202
|
return error;
|
|
344
203
|
}
|
|
345
204
|
|
|
205
|
+
// src/react/react-playthrough-results.ts
|
|
206
|
+
import { relative } from "path";
|
|
207
|
+
import { stripVTControlCharacters } from "util";
|
|
208
|
+
|
|
209
|
+
// src/react/react-playthrough.ts
|
|
210
|
+
import { render } from "@testing-library/react";
|
|
211
|
+
import userEvent from "@testing-library/user-event";
|
|
212
|
+
import { test } from "vitest";
|
|
213
|
+
|
|
346
214
|
// src/react/react-playthrough-core.ts
|
|
347
215
|
import { act } from "@testing-library/react";
|
|
348
216
|
function throwIfAborted(signal) {
|
|
@@ -393,10 +261,10 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
393
261
|
}
|
|
394
262
|
}
|
|
395
263
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
396
|
-
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided,
|
|
264
|
+
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, and the condition did not become true after the stage action. This does not identify a clock problem; inspect the condition, production input wiring, and observed state boundary.";
|
|
397
265
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
398
266
|
throw codedError(
|
|
399
|
-
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "
|
|
267
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_OUTCOME_NOT_REACHED",
|
|
400
268
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
401
269
|
);
|
|
402
270
|
}
|
|
@@ -824,28 +692,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
824
692
|
return { passed: false, waived: false, issues };
|
|
825
693
|
}
|
|
826
694
|
|
|
827
|
-
// src/react/react-playthrough-
|
|
828
|
-
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
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
|
-
}
|
|
695
|
+
// src/react/react-playthrough-results.ts
|
|
849
696
|
function isMetadata(value) {
|
|
850
697
|
if (!value || typeof value !== "object") return false;
|
|
851
698
|
const metadata = value;
|
|
@@ -863,17 +710,22 @@ function isMetadata(value) {
|
|
|
863
710
|
(stage) => Boolean(stage) && typeof stage === "object" && typeof stage.name === "string" && typeof stage.kind === "string" && typeof stage.domInputEvents === "number" && typeof stage.advancedSteps === "number" && typeof stage.assertions === "number" && typeof stage.stateChanged === "boolean" && typeof stage.before === "string" && typeof stage.after === "string"
|
|
864
711
|
);
|
|
865
712
|
}
|
|
713
|
+
function firstFailureLine(value) {
|
|
714
|
+
if (typeof value !== "string") return void 0;
|
|
715
|
+
return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
716
|
+
}
|
|
866
717
|
function toAuditInput(test2) {
|
|
867
718
|
const metadata = test2.meta().reactPlaythrough;
|
|
868
719
|
return {
|
|
869
720
|
name: test2.fullName,
|
|
870
721
|
state: test2.result().state,
|
|
722
|
+
mode: test2.options.mode,
|
|
871
723
|
metadata: isMetadata(metadata) ? metadata : void 0
|
|
872
724
|
};
|
|
873
725
|
}
|
|
874
726
|
function findPendingProductTests(modules, projectRoot) {
|
|
875
727
|
return modules.flatMap((module) => {
|
|
876
|
-
const file =
|
|
728
|
+
const file = relative(projectRoot, module.moduleId).replaceAll("\\", "/");
|
|
877
729
|
if (file.split("/").includes("examples")) return [];
|
|
878
730
|
const tests = [...module.children.allTests()];
|
|
879
731
|
const pending = tests.filter((test2) => {
|
|
@@ -892,36 +744,6 @@ function findPendingProductTests(modules, projectRoot) {
|
|
|
892
744
|
}));
|
|
893
745
|
});
|
|
894
746
|
}
|
|
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
|
-
}
|
|
921
|
-
function firstLine(value) {
|
|
922
|
-
if (typeof value !== "string") return void 0;
|
|
923
|
-
return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
924
|
-
}
|
|
925
747
|
function selectReactFailure(values, errorRecordCount = values.length) {
|
|
926
748
|
const entries = extractFailureEntries(values);
|
|
927
749
|
if (entries.length === 0) {
|
|
@@ -934,8 +756,17 @@ function selectReactFailure(values, errorRecordCount = values.length) {
|
|
|
934
756
|
}
|
|
935
757
|
const primary = entries[0];
|
|
936
758
|
const rawCause = primary.message;
|
|
937
|
-
|
|
938
|
-
|
|
759
|
+
return {
|
|
760
|
+
code: primary.code,
|
|
761
|
+
cause: firstFailureLine(rawCause) ?? rawCause,
|
|
762
|
+
rawCause,
|
|
763
|
+
errorName: primary.errorName,
|
|
764
|
+
actual: primary.actual,
|
|
765
|
+
expected: primary.expected,
|
|
766
|
+
origin: primary.origin,
|
|
767
|
+
layer: primary.layer,
|
|
768
|
+
related: entries.slice(1)
|
|
769
|
+
};
|
|
939
770
|
}
|
|
940
771
|
function failureHint(value) {
|
|
941
772
|
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
@@ -950,6 +781,11 @@ function errorLocation(value) {
|
|
|
950
781
|
);
|
|
951
782
|
return match?.[1];
|
|
952
783
|
}
|
|
784
|
+
function truncateReporterLine(value, limit) {
|
|
785
|
+
const compact = stripVTControlCharacters(value).replace(/\s+/g, " ").trim();
|
|
786
|
+
if (compact.length <= limit) return compact;
|
|
787
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
788
|
+
}
|
|
953
789
|
function failureTrace(test2) {
|
|
954
790
|
const annotations = test2.annotations();
|
|
955
791
|
let annotationTrace;
|
|
@@ -963,25 +799,20 @@ function failureTrace(test2) {
|
|
|
963
799
|
const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
|
|
964
800
|
return trace ? truncateReporterLine(trace, 720) : void 0;
|
|
965
801
|
}
|
|
966
|
-
function
|
|
967
|
-
const compact = stripVTControlCharacters(value).replace(/\s+/g, " ").trim();
|
|
968
|
-
if (compact.length <= limit) return compact;
|
|
969
|
-
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
970
|
-
}
|
|
971
|
-
function toModuleResult(module, projectRoot) {
|
|
802
|
+
function toReactPlaythroughModuleResult(module, projectRoot) {
|
|
972
803
|
const tests = [...module.children.allTests()];
|
|
973
804
|
const moduleFailure = selectReactFailure(
|
|
974
805
|
module.errors(),
|
|
975
806
|
module.errors().length
|
|
976
807
|
);
|
|
977
808
|
const moduleErrors = extractFailureEntries(module.errors()).map(
|
|
978
|
-
(entry) =>
|
|
809
|
+
(entry) => firstFailureLine(entry.message) ?? entry.message
|
|
979
810
|
);
|
|
980
811
|
const errors = [
|
|
981
812
|
...moduleErrors,
|
|
982
813
|
...tests.flatMap(
|
|
983
814
|
(test2) => extractFailureEntries(test2.result().errors ?? []).map(
|
|
984
|
-
(entry) =>
|
|
815
|
+
(entry) => firstFailureLine(entry.message) ?? entry.message
|
|
985
816
|
)
|
|
986
817
|
)
|
|
987
818
|
];
|
|
@@ -998,8 +829,13 @@ function toModuleResult(module, projectRoot) {
|
|
|
998
829
|
test: test2.fullName,
|
|
999
830
|
causeCode: selected.code,
|
|
1000
831
|
cause: selected.cause,
|
|
832
|
+
errorName: selected.errorName,
|
|
833
|
+
actual: selected.actual,
|
|
834
|
+
expected: selected.expected,
|
|
835
|
+
origin: selected.origin,
|
|
836
|
+
layer: selected.layer,
|
|
1001
837
|
related: selected.related,
|
|
1002
|
-
location: test2.location ? `${
|
|
838
|
+
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
|
|
1003
839
|
hint: failureHint(selected.rawCause),
|
|
1004
840
|
trace: failureTrace(test2)
|
|
1005
841
|
};
|
|
@@ -1009,13 +845,18 @@ function toModuleResult(module, projectRoot) {
|
|
|
1009
845
|
test: "<collection>",
|
|
1010
846
|
causeCode: moduleFailure.code,
|
|
1011
847
|
cause: moduleFailure.cause,
|
|
848
|
+
errorName: moduleFailure.errorName,
|
|
849
|
+
actual: moduleFailure.actual,
|
|
850
|
+
expected: moduleFailure.expected,
|
|
851
|
+
origin: moduleFailure.origin,
|
|
852
|
+
layer: moduleFailure.layer,
|
|
1012
853
|
related: moduleFailure.related,
|
|
1013
854
|
location: errorLocation(moduleFailure.rawCause),
|
|
1014
855
|
hint: failureHint(moduleFailure.rawCause)
|
|
1015
856
|
});
|
|
1016
857
|
}
|
|
1017
858
|
return {
|
|
1018
|
-
file:
|
|
859
|
+
file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
1019
860
|
state: module.state(),
|
|
1020
861
|
errors,
|
|
1021
862
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
@@ -1025,6 +866,47 @@ function toModuleResult(module, projectRoot) {
|
|
|
1025
866
|
failures
|
|
1026
867
|
};
|
|
1027
868
|
}
|
|
869
|
+
|
|
870
|
+
// src/react/react-playthrough-report-format.ts
|
|
871
|
+
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.";
|
|
872
|
+
function formatPendingProductTestReport(pending) {
|
|
873
|
+
if (pending.length === 0) return void 0;
|
|
874
|
+
const lines = [
|
|
875
|
+
"REACT_FOCUSED_TESTS: FAILED",
|
|
876
|
+
"CAUSE_CODE: TODO_OR_SKIP_TESTS",
|
|
877
|
+
"CAUSE: Product tests still contain explicit todo/skip cases."
|
|
878
|
+
];
|
|
879
|
+
const reportedIncompleteFiles = /* @__PURE__ */ new Set();
|
|
880
|
+
for (const item of pending) {
|
|
881
|
+
if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
|
|
882
|
+
lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
|
|
883
|
+
lines.push(
|
|
884
|
+
`FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
|
|
885
|
+
);
|
|
886
|
+
reportedIncompleteFiles.add(item.file);
|
|
887
|
+
}
|
|
888
|
+
lines.push(`FILE: ${item.file}`);
|
|
889
|
+
lines.push(`TEST: ${item.test}`);
|
|
890
|
+
lines.push(`MODE: ${item.mode}`);
|
|
891
|
+
}
|
|
892
|
+
lines.push(
|
|
893
|
+
"NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
|
|
894
|
+
);
|
|
895
|
+
return `
|
|
896
|
+
${lines.join("\n")}`;
|
|
897
|
+
}
|
|
898
|
+
function formatOrigin(origin) {
|
|
899
|
+
return `${origin.file}:${origin.line}:${origin.column}`;
|
|
900
|
+
}
|
|
901
|
+
function appendFailureDetails(lines, failure) {
|
|
902
|
+
if (failure.errorName) lines.push(`ERROR_NAME: ${failure.errorName}`);
|
|
903
|
+
if (failure.layer) lines.push(`CAUSE_LAYER: ${failure.layer}`);
|
|
904
|
+
if (failure.origin) lines.push(`ORIGIN: ${formatOrigin(failure.origin)}`);
|
|
905
|
+
if (failure.expected !== void 0) {
|
|
906
|
+
lines.push(`EXPECTED: ${failure.expected}`);
|
|
907
|
+
}
|
|
908
|
+
if (failure.actual !== void 0) lines.push(`ACTUAL: ${failure.actual}`);
|
|
909
|
+
}
|
|
1028
910
|
function formatReactFailureSummary(modules) {
|
|
1029
911
|
const failures = modules.flatMap(
|
|
1030
912
|
(module) => (module.failures ?? []).map((failure) => ({
|
|
@@ -1039,11 +921,20 @@ function formatReactFailureSummary(modules) {
|
|
|
1039
921
|
lines.push(`TEST: ${failure.test}`);
|
|
1040
922
|
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
1041
923
|
lines.push(`CAUSE: ${failure.cause}`);
|
|
924
|
+
appendFailureDetails(lines, failure);
|
|
1042
925
|
for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
|
|
1043
926
|
lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
|
|
1044
927
|
lines.push(
|
|
1045
|
-
`RELATED_${relatedIndex + 1}: ${
|
|
928
|
+
`RELATED_${relatedIndex + 1}: ${firstFailureLine(related.message) ?? related.message}`
|
|
1046
929
|
);
|
|
930
|
+
if (related.layer) {
|
|
931
|
+
lines.push(`RELATED_${relatedIndex + 1}_LAYER: ${related.layer}`);
|
|
932
|
+
}
|
|
933
|
+
if (related.origin) {
|
|
934
|
+
lines.push(
|
|
935
|
+
`RELATED_${relatedIndex + 1}_ORIGIN: ${formatOrigin(related.origin)}`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
1047
938
|
}
|
|
1048
939
|
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
1049
940
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
@@ -1052,6 +943,282 @@ function formatReactFailureSummary(modules) {
|
|
|
1052
943
|
lines.push("TEST_RESULT: FAIL");
|
|
1053
944
|
return lines;
|
|
1054
945
|
}
|
|
946
|
+
function formatReactTestCountSummary(modules) {
|
|
947
|
+
const files = {
|
|
948
|
+
total: modules.length,
|
|
949
|
+
passed: 0,
|
|
950
|
+
failed: 0,
|
|
951
|
+
skipped: 0,
|
|
952
|
+
pending: 0,
|
|
953
|
+
queued: 0
|
|
954
|
+
};
|
|
955
|
+
const tests = {
|
|
956
|
+
total: 0,
|
|
957
|
+
passed: 0,
|
|
958
|
+
failed: 0,
|
|
959
|
+
skipped: 0,
|
|
960
|
+
todo: 0,
|
|
961
|
+
pending: 0
|
|
962
|
+
};
|
|
963
|
+
let declaredPlaythroughs = 0;
|
|
964
|
+
let verifiedPlaythroughs = 0;
|
|
965
|
+
let waivedPlaythroughs = 0;
|
|
966
|
+
for (const module of modules) {
|
|
967
|
+
if (module.state in files && module.state !== "total") {
|
|
968
|
+
files[module.state] += 1;
|
|
969
|
+
}
|
|
970
|
+
for (const test2 of module.tests) {
|
|
971
|
+
tests.total += 1;
|
|
972
|
+
if (test2.mode === "todo") tests.todo += 1;
|
|
973
|
+
else tests[test2.state] += 1;
|
|
974
|
+
if (test2.metadata) {
|
|
975
|
+
declaredPlaythroughs += 1;
|
|
976
|
+
if (test2.metadata.evidence.verified) verifiedPlaythroughs += 1;
|
|
977
|
+
if (test2.metadata.waiverReason) waivedPlaythroughs += 1;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return [
|
|
982
|
+
`TEST_FILES: total=${files.total} passed=${files.passed} failed=${files.failed} skipped=${files.skipped} pending=${files.pending} queued=${files.queued}`,
|
|
983
|
+
`TESTS: total=${tests.total} passed=${tests.passed} failed=${tests.failed} skipped=${tests.skipped} todo=${tests.todo} pending=${tests.pending}`,
|
|
984
|
+
`PLAYTHROUGHS: declared=${declaredPlaythroughs} verified=${verifiedPlaythroughs} waived=${waivedPlaythroughs}`
|
|
985
|
+
];
|
|
986
|
+
}
|
|
987
|
+
function formatReactPlaythroughReport(report, alignment) {
|
|
988
|
+
const lines = [
|
|
989
|
+
`REACT_PLAYTHROUGH_STRUCTURE: ${report.status}`,
|
|
990
|
+
`PRODUCT_TEST_ALIGNMENT: ${alignment?.status ?? "NOT_VERIFIED"}`,
|
|
991
|
+
`FILE: ${report.file}`
|
|
992
|
+
];
|
|
993
|
+
if (alignment?.cause) lines.push(`ALIGNMENT_CAUSE: ${alignment.cause}`);
|
|
994
|
+
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
995
|
+
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
996
|
+
for (const [index, related] of (report.related ?? []).entries()) {
|
|
997
|
+
lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
|
|
998
|
+
lines.push(
|
|
999
|
+
`RELATED_${index + 1}: ${firstFailureLine(related.message) ?? related.message}`
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
if (report.waiverReasons?.length) {
|
|
1003
|
+
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
1006
|
+
if (report.status === "FAILED") {
|
|
1007
|
+
lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
|
|
1008
|
+
}
|
|
1009
|
+
return `
|
|
1010
|
+
${lines.join("\n")}`;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// src/cli/react-authoritative-playthrough.ts
|
|
1014
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
1015
|
+
import { dirname, join, relative as relative2, resolve, sep } from "path";
|
|
1016
|
+
import { parseSync } from "oxc-parser";
|
|
1017
|
+
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
1018
|
+
var PRODUCTION_APP = "src/App.tsx";
|
|
1019
|
+
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
1020
|
+
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
1021
|
+
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
1022
|
+
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
1023
|
+
var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
1024
|
+
"useGameController",
|
|
1025
|
+
"useOwnedGameController"
|
|
1026
|
+
]);
|
|
1027
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
1028
|
+
function runnableTestFiles(root, directory = join(root, "tests")) {
|
|
1029
|
+
if (!existsSync(directory)) return [];
|
|
1030
|
+
const files = [];
|
|
1031
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1032
|
+
const path = join(directory, entry.name);
|
|
1033
|
+
if (entry.isDirectory()) {
|
|
1034
|
+
files.push(...runnableTestFiles(root, path));
|
|
1035
|
+
} else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
1036
|
+
files.push(path);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
return files;
|
|
1040
|
+
}
|
|
1041
|
+
function extension(path) {
|
|
1042
|
+
const index = path.lastIndexOf(".");
|
|
1043
|
+
return index < 0 ? "" : path.slice(index);
|
|
1044
|
+
}
|
|
1045
|
+
function sourceFiles(root, directory = join(root, "src")) {
|
|
1046
|
+
if (!existsSync(directory)) return [];
|
|
1047
|
+
const files = [];
|
|
1048
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1049
|
+
const path = join(directory, entry.name);
|
|
1050
|
+
const projectPath = relative2(root, path).replaceAll("\\", "/");
|
|
1051
|
+
if (entry.isDirectory()) {
|
|
1052
|
+
if (projectPath === "src/game/example") continue;
|
|
1053
|
+
files.push(...sourceFiles(root, path));
|
|
1054
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
|
|
1055
|
+
files.push(path);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return files;
|
|
1059
|
+
}
|
|
1060
|
+
function parseSource(file, source) {
|
|
1061
|
+
return parseSync(file, source, { sourceType: "module" });
|
|
1062
|
+
}
|
|
1063
|
+
function staticImports(file, source) {
|
|
1064
|
+
return parseSource(file, source).module.staticImports;
|
|
1065
|
+
}
|
|
1066
|
+
function collectDynamicImportSpecifiers(value, modules) {
|
|
1067
|
+
if (Array.isArray(value)) {
|
|
1068
|
+
for (const item of value) collectDynamicImportSpecifiers(item, modules);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (!isRecord(value)) return;
|
|
1072
|
+
if (value.type === "ImportExpression" && isRecord(value.source) && value.source.type === "Literal" && typeof value.source.value === "string") {
|
|
1073
|
+
modules.push(value.source.value);
|
|
1074
|
+
}
|
|
1075
|
+
for (const child of Object.values(value)) {
|
|
1076
|
+
collectDynamicImportSpecifiers(child, modules);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
function importedModuleSpecifiers(file, source) {
|
|
1080
|
+
const parsed = parseSource(file, source);
|
|
1081
|
+
const modules = parsed.module.staticImports.map(
|
|
1082
|
+
({ moduleRequest }) => moduleRequest.value
|
|
1083
|
+
);
|
|
1084
|
+
collectDynamicImportSpecifiers(parsed.program, modules);
|
|
1085
|
+
return modules;
|
|
1086
|
+
}
|
|
1087
|
+
function productionFileImportsExample(file, projectRoot) {
|
|
1088
|
+
const exampleRoot = join(projectRoot, "src/game/example");
|
|
1089
|
+
return importedModuleSpecifiers(file, readFileSync(file, "utf8")).some(
|
|
1090
|
+
(moduleName) => {
|
|
1091
|
+
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
1092
|
+
if (!moduleName.startsWith(".")) return false;
|
|
1093
|
+
const target = resolve(dirname(file), moduleName);
|
|
1094
|
+
return target === exampleRoot || target.startsWith(`${exampleRoot}${sep}`);
|
|
1095
|
+
}
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
function importsExampleAlias(file, source) {
|
|
1099
|
+
return importedModuleSpecifiers(file, source).some(
|
|
1100
|
+
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
1101
|
+
);
|
|
1102
|
+
}
|
|
1103
|
+
function namedImports(file, source, moduleName) {
|
|
1104
|
+
const names = /* @__PURE__ */ new Set();
|
|
1105
|
+
for (const declaration of staticImports(file, source)) {
|
|
1106
|
+
if (declaration.moduleRequest.value !== moduleName) continue;
|
|
1107
|
+
for (const entry of declaration.entries) {
|
|
1108
|
+
if (entry.importName.kind === "Name" && entry.importName.name) {
|
|
1109
|
+
names.add(entry.importName.name);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
return names;
|
|
1114
|
+
}
|
|
1115
|
+
function containsAny(values, expected) {
|
|
1116
|
+
return [...values].some((value) => expected.has(value));
|
|
1117
|
+
}
|
|
1118
|
+
function isRecord(value) {
|
|
1119
|
+
return typeof value === "object" && value !== null;
|
|
1120
|
+
}
|
|
1121
|
+
function containsObserveProperty(value) {
|
|
1122
|
+
if (Array.isArray(value)) return value.some(containsObserveProperty);
|
|
1123
|
+
if (!isRecord(value)) return false;
|
|
1124
|
+
if (value.type === "Property" && isRecord(value.key)) {
|
|
1125
|
+
if (value.key.type === "Identifier" && value.key.name === "observe") return true;
|
|
1126
|
+
if (value.key.type === "Literal" && value.key.value === "observe") return true;
|
|
1127
|
+
}
|
|
1128
|
+
return Object.values(value).some(containsObserveProperty);
|
|
1129
|
+
}
|
|
1130
|
+
function declaresObserve(file, source) {
|
|
1131
|
+
return containsObserveProperty(parseSource(file, source).program);
|
|
1132
|
+
}
|
|
1133
|
+
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
1134
|
+
const clockFiles = [];
|
|
1135
|
+
const controllerFiles = [];
|
|
1136
|
+
const productionFiles = sourceFiles(projectRoot);
|
|
1137
|
+
for (const file of productionFiles) {
|
|
1138
|
+
const imports = namedImports(
|
|
1139
|
+
file,
|
|
1140
|
+
readFileSync(file, "utf8"),
|
|
1141
|
+
REACT_RUNTIME_ENTRY
|
|
1142
|
+
);
|
|
1143
|
+
const projectPath = relative2(projectRoot, file).replaceAll("\\", "/");
|
|
1144
|
+
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
1145
|
+
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
1146
|
+
controllerFiles.push(projectPath);
|
|
1147
|
+
}
|
|
1148
|
+
const appPath = join(projectRoot, PRODUCTION_APP);
|
|
1149
|
+
const productionEntryExists = existsSync(appPath);
|
|
1150
|
+
const productionUsesExample = productionFiles.some(
|
|
1151
|
+
(file) => productionFileImportsExample(file, projectRoot)
|
|
1152
|
+
);
|
|
1153
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter(
|
|
1154
|
+
(file) => importsExampleAlias(file, readFileSync(file, "utf8"))
|
|
1155
|
+
).map((file) => relative2(projectRoot, file).replaceAll("\\", "/"));
|
|
1156
|
+
const issues = [];
|
|
1157
|
+
if (staleExampleTestFiles.length > 0) {
|
|
1158
|
+
issues.push(
|
|
1159
|
+
`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.`
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
1163
|
+
return {
|
|
1164
|
+
ok: issues.length === 0,
|
|
1165
|
+
issues,
|
|
1166
|
+
clockFiles,
|
|
1167
|
+
controllerFiles,
|
|
1168
|
+
productionEntryExists,
|
|
1169
|
+
productionUsesExample,
|
|
1170
|
+
staleExampleTestFiles
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
1174
|
+
const testSource = existsSync(testPath) ? readFileSync(testPath, "utf8") : "";
|
|
1175
|
+
const testingImports = namedImports(
|
|
1176
|
+
testPath,
|
|
1177
|
+
testSource,
|
|
1178
|
+
REACT_TESTING_ENTRY
|
|
1179
|
+
);
|
|
1180
|
+
const hasObserve = declaresObserve(testPath, testSource);
|
|
1181
|
+
if (!hasObserve) {
|
|
1182
|
+
issues.push(
|
|
1183
|
+
`${PRODUCTION_PLAYTHROUGH} must declare observe because production uses the devkit clock or Controller ownership hook. Pass { observe: () => telemetry.read.session() } to playthroughTest, using Telemetry backed by the same production Controller rendered by <App />; DOM labels are not an authoritative gameplay boundary.`
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
1187
|
+
issues.push(
|
|
1188
|
+
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Add this import to the test: import { ManualGameClock } from "miaoda-game-devkit/react/testing"; Then create one, inject it through the production <App /> factory, and advance it with step: () => clock.stepFrame().`
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
return {
|
|
1192
|
+
ok: issues.length === 0,
|
|
1193
|
+
issues,
|
|
1194
|
+
clockFiles,
|
|
1195
|
+
controllerFiles,
|
|
1196
|
+
productionEntryExists,
|
|
1197
|
+
productionUsesExample,
|
|
1198
|
+
staleExampleTestFiles
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/react/react-playthrough-policy.ts
|
|
1203
|
+
function assessProductTestAlignment(projectRoot) {
|
|
1204
|
+
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
1205
|
+
if (audit.staleExampleTestFiles.length > 0) {
|
|
1206
|
+
return { status: "FAILED", cause: audit.issues[0] };
|
|
1207
|
+
}
|
|
1208
|
+
if (!audit.productionEntryExists) {
|
|
1209
|
+
return {
|
|
1210
|
+
status: "NOT_VERIFIED",
|
|
1211
|
+
cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
if (audit.productionUsesExample) {
|
|
1215
|
+
return {
|
|
1216
|
+
status: "NOT_VERIFIED",
|
|
1217
|
+
cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
return { status: "PASS" };
|
|
1221
|
+
}
|
|
1055
1222
|
function repairGuidance(code) {
|
|
1056
1223
|
switch (code) {
|
|
1057
1224
|
case "GAME_SNAPSHOT_REFERENCE_REUSED":
|
|
@@ -1066,8 +1233,16 @@ function repairGuidance(code) {
|
|
|
1066
1233
|
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
1234
|
case "PLAYTHROUGH_BOUND_EXHAUSTED":
|
|
1068
1235
|
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.";
|
|
1236
|
+
case "PLAYTHROUGH_OUTCOME_NOT_REACHED":
|
|
1237
|
+
return "The stage action completed, but its until condition never became true. Inspect TRACE and Last diagnostics, then confirm that the production DOM input reaches the rendered App, until describes the result caused by this stage, and observe reads the matching authoritative state when used. Add a deterministic step only if the gameplay is actually driven by time or frames; do not add a no-op step or weaken the intended outcome.";
|
|
1069
1238
|
case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
|
|
1070
1239
|
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.";
|
|
1240
|
+
case "EXPECTATION_MISMATCH":
|
|
1241
|
+
return "The test assertion observed a different value than it expected. Compare EXPECTED and ACTUAL with the game-owned state type and inspect ORIGIN before editing. Fix production only when the observed value violates the product contract; otherwise correct the test expectation without weakening the intended outcome.";
|
|
1242
|
+
case "PRODUCT_RUNTIME_TYPE_ERROR":
|
|
1243
|
+
return "A TypeError originated in production source. Open ORIGIN, validate the referenced dependency or state before mutation, and fix the owning initialization or lifecycle boundary. Do not bypass the crashing path in the test.";
|
|
1244
|
+
case "TEST_API_MISMATCH":
|
|
1245
|
+
return "A TypeError originated in test code. Open ORIGIN and align the call with the installed public API or the game-owned typed Session. Do not add a production shim solely for an invented test helper.";
|
|
1071
1246
|
case "INVALID_STAGE_ORDER":
|
|
1072
1247
|
case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
|
|
1073
1248
|
case "INVALID_STAGE_NAME":
|
|
@@ -1153,31 +1328,9 @@ function assessReactPlaythroughReport(input) {
|
|
|
1153
1328
|
}
|
|
1154
1329
|
return { ...base, status: "PASS", failsRun: false };
|
|
1155
1330
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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}`);
|
|
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
|
-
}
|
|
1171
|
-
if (report.waiverReasons?.length) {
|
|
1172
|
-
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
1173
|
-
}
|
|
1174
|
-
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
1175
|
-
if (report.status === "FAILED") {
|
|
1176
|
-
lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
|
|
1177
|
-
}
|
|
1178
|
-
return `
|
|
1179
|
-
${lines.join("\n")}`;
|
|
1180
|
-
}
|
|
1331
|
+
|
|
1332
|
+
// src/react/react-playthrough-reporter.ts
|
|
1333
|
+
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
1181
1334
|
var ReactPlaythroughReporter = class {
|
|
1182
1335
|
/** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
|
|
1183
1336
|
constructor(projectRoot) {
|
|
@@ -1204,16 +1357,17 @@ var ReactPlaythroughReporter = class {
|
|
|
1204
1357
|
testModules,
|
|
1205
1358
|
this.projectRoot
|
|
1206
1359
|
);
|
|
1360
|
+
const moduleResults = testModules.map(
|
|
1361
|
+
(module) => toReactPlaythroughModuleResult(module, this.projectRoot)
|
|
1362
|
+
);
|
|
1207
1363
|
const report = assessReactPlaythroughReport({
|
|
1208
1364
|
expectedFile: this.expectedFile,
|
|
1209
1365
|
expectedFileExists: existsSync2(this.expectedModuleId),
|
|
1210
1366
|
expectedFileScheduled: this.expectedFileScheduled,
|
|
1211
1367
|
focusedSelection: this.focusedSelection,
|
|
1212
|
-
modules:
|
|
1213
|
-
(module) => toModuleResult(module, this.projectRoot)
|
|
1214
|
-
),
|
|
1368
|
+
modules: moduleResults,
|
|
1215
1369
|
unhandledErrors: extractFailureEntries(unhandledErrors).map(
|
|
1216
|
-
(entry) =>
|
|
1370
|
+
(entry) => firstFailureLine(entry.message) ?? entry.message
|
|
1217
1371
|
)
|
|
1218
1372
|
});
|
|
1219
1373
|
const alignment = assessProductTestAlignment(this.projectRoot);
|
|
@@ -1231,14 +1385,14 @@ var ReactPlaythroughReporter = class {
|
|
|
1231
1385
|
console.error(pendingOutput);
|
|
1232
1386
|
process.exitCode = 1;
|
|
1233
1387
|
}
|
|
1234
|
-
const summary = formatReactFailureSummary(
|
|
1235
|
-
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
1236
|
-
);
|
|
1388
|
+
const summary = formatReactFailureSummary(moduleResults);
|
|
1237
1389
|
if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
1238
1390
|
summary[0] = "TEST_RESULT: FAIL";
|
|
1239
1391
|
}
|
|
1240
|
-
console.log(
|
|
1241
|
-
|
|
1392
|
+
console.log(
|
|
1393
|
+
`
|
|
1394
|
+
${[...formatReactTestCountSummary(moduleResults), ...summary].join("\n")}`
|
|
1395
|
+
);
|
|
1242
1396
|
}
|
|
1243
1397
|
};
|
|
1244
1398
|
|
|
@@ -1298,8 +1452,11 @@ function defineReactGameVitestConfig(options) {
|
|
|
1298
1452
|
sequence: {
|
|
1299
1453
|
setupFiles: "list"
|
|
1300
1454
|
},
|
|
1301
|
-
|
|
1302
|
-
|
|
1455
|
+
...options.enablePlaythroughReporter ? {
|
|
1456
|
+
// The structured reporter remains available to existing Devkit consumers,
|
|
1457
|
+
// but is no longer a default requirement for generated React games.
|
|
1458
|
+
reporters: [new ReactPlaythroughReporter(options.projectRoot)]
|
|
1459
|
+
} : {},
|
|
1303
1460
|
restoreMocks: true,
|
|
1304
1461
|
clearMocks: true,
|
|
1305
1462
|
testTimeout: options.testTimeout,
|