miaoda-game-devkit 0.6.1 → 0.6.2
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 +187 -19
- package/dist/cli/react-lint.js +187 -19
- package/dist/react/testing.d.mts +8 -1
- package/dist/react/testing.d.ts +8 -1
- package/dist/react/testing.js +21 -2
- package/dist/react/testing.mjs +21 -2
- package/dist/react/vitest-config.js +33 -4
- package/dist/react/vitest-config.mjs +33 -4
- package/dist/rules/react-test-boundary-plugin.js +14 -1
- package/package.json +1 -1
package/dist/cli/phaser-lint.js
CHANGED
|
@@ -3,23 +3,185 @@
|
|
|
3
3
|
|
|
4
4
|
// src/cli/lint.ts
|
|
5
5
|
var import_node_child_process = require("child_process");
|
|
6
|
-
var
|
|
6
|
+
var import_node_fs2 = require("fs");
|
|
7
7
|
var import_node_module = require("module");
|
|
8
8
|
var import_node_os = require("os");
|
|
9
|
+
var import_node_path2 = require("path");
|
|
10
|
+
|
|
11
|
+
// src/cli/react-authoritative-playthrough.ts
|
|
12
|
+
var import_node_fs = require("fs");
|
|
9
13
|
var import_node_path = require("path");
|
|
10
|
-
var
|
|
14
|
+
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
|
+
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
16
|
+
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
17
|
+
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
18
|
+
var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
19
|
+
"useGameController",
|
|
20
|
+
"useOwnedGameController"
|
|
21
|
+
]);
|
|
22
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
23
|
+
function extension(path) {
|
|
24
|
+
const index = path.lastIndexOf(".");
|
|
25
|
+
return index < 0 ? "" : path.slice(index);
|
|
26
|
+
}
|
|
27
|
+
function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
|
|
28
|
+
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
29
|
+
const files = [];
|
|
30
|
+
for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
|
|
31
|
+
const path = (0, import_node_path.join)(directory, entry.name);
|
|
32
|
+
const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (projectPath === "src/game/example") continue;
|
|
35
|
+
files.push(...sourceFiles(root, path));
|
|
36
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
|
|
37
|
+
files.push(path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
42
|
+
function withoutComments(source) {
|
|
43
|
+
let output = "";
|
|
44
|
+
let state = "code";
|
|
45
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
46
|
+
const char = source[index];
|
|
47
|
+
const next = source[index + 1];
|
|
48
|
+
if (state === "line") {
|
|
49
|
+
if (char === "\n") {
|
|
50
|
+
state = "code";
|
|
51
|
+
output += char;
|
|
52
|
+
} else {
|
|
53
|
+
output += " ";
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (state === "block") {
|
|
58
|
+
if (char === "*" && next === "/") {
|
|
59
|
+
output += " ";
|
|
60
|
+
index += 1;
|
|
61
|
+
state = "code";
|
|
62
|
+
} else {
|
|
63
|
+
output += char === "\n" ? "\n" : " ";
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (state === "code" && char === "/" && next === "/") {
|
|
68
|
+
output += " ";
|
|
69
|
+
index += 1;
|
|
70
|
+
state = "line";
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (state === "code" && char === "/" && next === "*") {
|
|
74
|
+
output += " ";
|
|
75
|
+
index += 1;
|
|
76
|
+
state = "block";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (state === "code" && char === "'") state = "single";
|
|
80
|
+
else if (state === "code" && char === '"') state = "double";
|
|
81
|
+
else if (state === "code" && char === "`") state = "template";
|
|
82
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
83
|
+
state = "code";
|
|
84
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
85
|
+
state = "code";
|
|
86
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
87
|
+
state = "code";
|
|
88
|
+
}
|
|
89
|
+
output += char;
|
|
90
|
+
}
|
|
91
|
+
return output;
|
|
92
|
+
}
|
|
93
|
+
function codePositions(source) {
|
|
94
|
+
const positions = Array.from({ length: source.length }, () => false);
|
|
95
|
+
let state = "code";
|
|
96
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
97
|
+
const char = source[index];
|
|
98
|
+
if (state === "code") positions[index] = true;
|
|
99
|
+
if (state === "code" && char === "'") state = "single";
|
|
100
|
+
else if (state === "code" && char === '"') state = "double";
|
|
101
|
+
else if (state === "code" && char === "`") state = "template";
|
|
102
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
103
|
+
state = "code";
|
|
104
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
105
|
+
state = "code";
|
|
106
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
107
|
+
state = "code";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return positions;
|
|
111
|
+
}
|
|
112
|
+
function namedImports(source, moduleName) {
|
|
113
|
+
const names = /* @__PURE__ */ new Set();
|
|
114
|
+
const clean = withoutComments(source);
|
|
115
|
+
const positions = codePositions(clean);
|
|
116
|
+
const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
117
|
+
const pattern = new RegExp(
|
|
118
|
+
`^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
|
|
119
|
+
"gm"
|
|
120
|
+
);
|
|
121
|
+
for (const match of clean.matchAll(pattern)) {
|
|
122
|
+
if (!positions[match.index]) continue;
|
|
123
|
+
for (const specifier of match[1].split(",")) {
|
|
124
|
+
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
125
|
+
if (imported) names.add(imported);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return names;
|
|
129
|
+
}
|
|
130
|
+
function containsAny(values, expected) {
|
|
131
|
+
return [...values].some((value) => expected.has(value));
|
|
132
|
+
}
|
|
133
|
+
function declaresObserve(source) {
|
|
134
|
+
const clean = withoutComments(source);
|
|
135
|
+
const positions = codePositions(clean);
|
|
136
|
+
for (const match of clean.matchAll(/\bobserve\s*:/g)) {
|
|
137
|
+
if (positions[match.index]) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
142
|
+
const clockFiles = [];
|
|
143
|
+
const controllerFiles = [];
|
|
144
|
+
for (const file of sourceFiles(projectRoot2)) {
|
|
145
|
+
const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
|
|
146
|
+
const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
147
|
+
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
148
|
+
if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
|
|
149
|
+
}
|
|
150
|
+
const issues = [];
|
|
151
|
+
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
152
|
+
return { ok: true, issues, clockFiles, controllerFiles };
|
|
153
|
+
}
|
|
154
|
+
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
155
|
+
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
156
|
+
const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
|
|
157
|
+
const hasObserve = declaresObserve(testSource);
|
|
158
|
+
if (!hasObserve) {
|
|
159
|
+
issues.push(
|
|
160
|
+
`${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.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
164
|
+
issues.push(
|
|
165
|
+
`${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.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/cli/lint.ts
|
|
172
|
+
var devkitRoot = (0, import_node_path2.resolve)(__dirname, "../..");
|
|
11
173
|
var projectRoot = process.cwd();
|
|
12
|
-
var requireFromDevkit = (0, import_node_module.createRequire)((0,
|
|
13
|
-
var projectBiomeConfig = (0,
|
|
14
|
-
var biomeConfig = (0,
|
|
174
|
+
var requireFromDevkit = (0, import_node_module.createRequire)((0, import_node_path2.join)(devkitRoot, "package.json"));
|
|
175
|
+
var projectBiomeConfig = (0, import_node_path2.join)(projectRoot, "biome.json");
|
|
176
|
+
var biomeConfig = (0, import_node_fs2.existsSync)(projectBiomeConfig) ? projectBiomeConfig : (0, import_node_path2.join)(devkitRoot, "biome-config.json");
|
|
15
177
|
function resolveBin(packageName, binName) {
|
|
16
178
|
const packageJsonPath = requireFromDevkit.resolve(`${packageName}/package.json`);
|
|
17
|
-
const packageJson = JSON.parse((0,
|
|
179
|
+
const packageJson = JSON.parse((0, import_node_fs2.readFileSync)(packageJsonPath, "utf8"));
|
|
18
180
|
const relativeBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.[binName];
|
|
19
181
|
if (!relativeBin) {
|
|
20
182
|
throw new Error(`${packageName} does not expose the ${binName} executable`);
|
|
21
183
|
}
|
|
22
|
-
return (0,
|
|
184
|
+
return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(packageJsonPath), relativeBin);
|
|
23
185
|
}
|
|
24
186
|
function run(name, packageName, binName, args) {
|
|
25
187
|
return new Promise((resolveResult) => {
|
|
@@ -41,21 +203,21 @@ function run(name, packageName, binName, args) {
|
|
|
41
203
|
});
|
|
42
204
|
}
|
|
43
205
|
function readProjectSource(path, checkName) {
|
|
44
|
-
const absolutePath = (0,
|
|
45
|
-
if (!(0,
|
|
206
|
+
const absolutePath = (0, import_node_path2.join)(projectRoot, path);
|
|
207
|
+
if (!(0, import_node_fs2.existsSync)(absolutePath)) {
|
|
46
208
|
console.error(`[${checkName}] \u7F3A\u5C11\u6A21\u677F\u5FC5\u9700\u7684 ${path}\u3002\u8BF7\u6062\u590D\u6A21\u677F\u539F\u6587\u4EF6\u540E\u91CD\u65B0\u8FD0\u884C pnpm lint\u3002`);
|
|
47
209
|
return void 0;
|
|
48
210
|
}
|
|
49
|
-
return (0,
|
|
211
|
+
return (0, import_node_fs2.readFileSync)(absolutePath, "utf8");
|
|
50
212
|
}
|
|
51
213
|
function readProjectManifest() {
|
|
52
|
-
const manifestPath = (0,
|
|
53
|
-
if (!(0,
|
|
214
|
+
const manifestPath = (0, import_node_path2.join)(projectRoot, "package.json");
|
|
215
|
+
if (!(0, import_node_fs2.existsSync)(manifestPath)) {
|
|
54
216
|
console.error("[phaser-version] \u7F3A\u5C11\u9879\u76EE package.json\u3002");
|
|
55
217
|
return void 0;
|
|
56
218
|
}
|
|
57
219
|
try {
|
|
58
|
-
return JSON.parse((0,
|
|
220
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(manifestPath, "utf8"));
|
|
59
221
|
} catch (error) {
|
|
60
222
|
console.error(`[phaser-version] \u65E0\u6CD5\u89E3\u6790 package.json\uFF1A${String(error)}`);
|
|
61
223
|
return void 0;
|
|
@@ -113,9 +275,15 @@ function checkVitestConfig(target) {
|
|
|
113
275
|
}
|
|
114
276
|
return { name, ok: true };
|
|
115
277
|
}
|
|
278
|
+
function checkReactAuthoritativePlaythrough() {
|
|
279
|
+
const name = "react-authoritative-playthrough";
|
|
280
|
+
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
281
|
+
for (const issue of audit.issues) console.error(`[${name}] ${issue}`);
|
|
282
|
+
return { name, ok: audit.ok };
|
|
283
|
+
}
|
|
116
284
|
async function runAllChecks(target) {
|
|
117
|
-
const targetChecks = target === "react" ? [checkVitestConfig("react")] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
118
|
-
const lintRoots = target === "react" && (0,
|
|
285
|
+
const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactAuthoritativePlaythrough()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
286
|
+
const lintRoots = target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
|
|
119
287
|
const [tsgo, biome] = await Promise.all([
|
|
120
288
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
|
121
289
|
run("biome", "@biomejs/biome", "biome", [
|
|
@@ -125,17 +293,17 @@ async function runAllChecks(target) {
|
|
|
125
293
|
...lintRoots
|
|
126
294
|
])
|
|
127
295
|
]);
|
|
128
|
-
const tailwindOutput = (0,
|
|
296
|
+
const tailwindOutput = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `miaoda-game-devkit-${process.pid}.css`);
|
|
129
297
|
const tailwind = await run("tailwind", "tailwindcss", "tailwindcss", [
|
|
130
298
|
"-i",
|
|
131
|
-
(0,
|
|
299
|
+
(0, import_node_path2.join)(projectRoot, "src/index.css"),
|
|
132
300
|
"-o",
|
|
133
301
|
tailwindOutput
|
|
134
302
|
]);
|
|
135
|
-
(0,
|
|
303
|
+
(0, import_node_fs2.rmSync)(tailwindOutput, { force: true });
|
|
136
304
|
const oxlint = await run("oxlint", "oxlint", "oxlint", [
|
|
137
305
|
"-c",
|
|
138
|
-
(0,
|
|
306
|
+
(0, import_node_path2.join)(devkitRoot, "oxlint-config.json"),
|
|
139
307
|
...lintRoots
|
|
140
308
|
]);
|
|
141
309
|
return [...targetChecks, tsgo, biome, tailwind, oxlint];
|
package/dist/cli/react-lint.js
CHANGED
|
@@ -3,23 +3,185 @@
|
|
|
3
3
|
|
|
4
4
|
// src/cli/lint.ts
|
|
5
5
|
var import_node_child_process = require("child_process");
|
|
6
|
-
var
|
|
6
|
+
var import_node_fs2 = require("fs");
|
|
7
7
|
var import_node_module = require("module");
|
|
8
8
|
var import_node_os = require("os");
|
|
9
|
+
var import_node_path2 = require("path");
|
|
10
|
+
|
|
11
|
+
// src/cli/react-authoritative-playthrough.ts
|
|
12
|
+
var import_node_fs = require("fs");
|
|
9
13
|
var import_node_path = require("path");
|
|
10
|
-
var
|
|
14
|
+
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
15
|
+
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
16
|
+
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
17
|
+
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
18
|
+
var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
19
|
+
"useGameController",
|
|
20
|
+
"useOwnedGameController"
|
|
21
|
+
]);
|
|
22
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
23
|
+
function extension(path) {
|
|
24
|
+
const index = path.lastIndexOf(".");
|
|
25
|
+
return index < 0 ? "" : path.slice(index);
|
|
26
|
+
}
|
|
27
|
+
function sourceFiles(root, directory = (0, import_node_path.join)(root, "src")) {
|
|
28
|
+
if (!(0, import_node_fs.existsSync)(directory)) return [];
|
|
29
|
+
const files = [];
|
|
30
|
+
for (const entry of (0, import_node_fs.readdirSync)(directory, { withFileTypes: true })) {
|
|
31
|
+
const path = (0, import_node_path.join)(directory, entry.name);
|
|
32
|
+
const projectPath = (0, import_node_path.relative)(root, path).replaceAll("\\", "/");
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (projectPath === "src/game/example") continue;
|
|
35
|
+
files.push(...sourceFiles(root, path));
|
|
36
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
|
|
37
|
+
files.push(path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
42
|
+
function withoutComments(source) {
|
|
43
|
+
let output = "";
|
|
44
|
+
let state = "code";
|
|
45
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
46
|
+
const char = source[index];
|
|
47
|
+
const next = source[index + 1];
|
|
48
|
+
if (state === "line") {
|
|
49
|
+
if (char === "\n") {
|
|
50
|
+
state = "code";
|
|
51
|
+
output += char;
|
|
52
|
+
} else {
|
|
53
|
+
output += " ";
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (state === "block") {
|
|
58
|
+
if (char === "*" && next === "/") {
|
|
59
|
+
output += " ";
|
|
60
|
+
index += 1;
|
|
61
|
+
state = "code";
|
|
62
|
+
} else {
|
|
63
|
+
output += char === "\n" ? "\n" : " ";
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (state === "code" && char === "/" && next === "/") {
|
|
68
|
+
output += " ";
|
|
69
|
+
index += 1;
|
|
70
|
+
state = "line";
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (state === "code" && char === "/" && next === "*") {
|
|
74
|
+
output += " ";
|
|
75
|
+
index += 1;
|
|
76
|
+
state = "block";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (state === "code" && char === "'") state = "single";
|
|
80
|
+
else if (state === "code" && char === '"') state = "double";
|
|
81
|
+
else if (state === "code" && char === "`") state = "template";
|
|
82
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
83
|
+
state = "code";
|
|
84
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
85
|
+
state = "code";
|
|
86
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
87
|
+
state = "code";
|
|
88
|
+
}
|
|
89
|
+
output += char;
|
|
90
|
+
}
|
|
91
|
+
return output;
|
|
92
|
+
}
|
|
93
|
+
function codePositions(source) {
|
|
94
|
+
const positions = Array.from({ length: source.length }, () => false);
|
|
95
|
+
let state = "code";
|
|
96
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
97
|
+
const char = source[index];
|
|
98
|
+
if (state === "code") positions[index] = true;
|
|
99
|
+
if (state === "code" && char === "'") state = "single";
|
|
100
|
+
else if (state === "code" && char === '"') state = "double";
|
|
101
|
+
else if (state === "code" && char === "`") state = "template";
|
|
102
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
103
|
+
state = "code";
|
|
104
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
105
|
+
state = "code";
|
|
106
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
107
|
+
state = "code";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return positions;
|
|
111
|
+
}
|
|
112
|
+
function namedImports(source, moduleName) {
|
|
113
|
+
const names = /* @__PURE__ */ new Set();
|
|
114
|
+
const clean = withoutComments(source);
|
|
115
|
+
const positions = codePositions(clean);
|
|
116
|
+
const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
117
|
+
const pattern = new RegExp(
|
|
118
|
+
`^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
|
|
119
|
+
"gm"
|
|
120
|
+
);
|
|
121
|
+
for (const match of clean.matchAll(pattern)) {
|
|
122
|
+
if (!positions[match.index]) continue;
|
|
123
|
+
for (const specifier of match[1].split(",")) {
|
|
124
|
+
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
125
|
+
if (imported) names.add(imported);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return names;
|
|
129
|
+
}
|
|
130
|
+
function containsAny(values, expected) {
|
|
131
|
+
return [...values].some((value) => expected.has(value));
|
|
132
|
+
}
|
|
133
|
+
function declaresObserve(source) {
|
|
134
|
+
const clean = withoutComments(source);
|
|
135
|
+
const positions = codePositions(clean);
|
|
136
|
+
for (const match of clean.matchAll(/\bobserve\s*:/g)) {
|
|
137
|
+
if (positions[match.index]) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
function auditReactAuthoritativePlaythrough(projectRoot2) {
|
|
142
|
+
const clockFiles = [];
|
|
143
|
+
const controllerFiles = [];
|
|
144
|
+
for (const file of sourceFiles(projectRoot2)) {
|
|
145
|
+
const imports = namedImports((0, import_node_fs.readFileSync)(file, "utf8"), REACT_RUNTIME_ENTRY);
|
|
146
|
+
const projectPath = (0, import_node_path.relative)(projectRoot2, file).replaceAll("\\", "/");
|
|
147
|
+
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
148
|
+
if (containsAny(imports, CONTROLLER_IMPORTS)) controllerFiles.push(projectPath);
|
|
149
|
+
}
|
|
150
|
+
const issues = [];
|
|
151
|
+
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
152
|
+
return { ok: true, issues, clockFiles, controllerFiles };
|
|
153
|
+
}
|
|
154
|
+
const testPath = (0, import_node_path.join)(projectRoot2, PRODUCTION_PLAYTHROUGH);
|
|
155
|
+
const testSource = (0, import_node_fs.existsSync)(testPath) ? (0, import_node_fs.readFileSync)(testPath, "utf8") : "";
|
|
156
|
+
const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
|
|
157
|
+
const hasObserve = declaresObserve(testSource);
|
|
158
|
+
if (!hasObserve) {
|
|
159
|
+
issues.push(
|
|
160
|
+
`${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.`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
164
|
+
issues.push(
|
|
165
|
+
`${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.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return { ok: issues.length === 0, issues, clockFiles, controllerFiles };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/cli/lint.ts
|
|
172
|
+
var devkitRoot = (0, import_node_path2.resolve)(__dirname, "../..");
|
|
11
173
|
var projectRoot = process.cwd();
|
|
12
|
-
var requireFromDevkit = (0, import_node_module.createRequire)((0,
|
|
13
|
-
var projectBiomeConfig = (0,
|
|
14
|
-
var biomeConfig = (0,
|
|
174
|
+
var requireFromDevkit = (0, import_node_module.createRequire)((0, import_node_path2.join)(devkitRoot, "package.json"));
|
|
175
|
+
var projectBiomeConfig = (0, import_node_path2.join)(projectRoot, "biome.json");
|
|
176
|
+
var biomeConfig = (0, import_node_fs2.existsSync)(projectBiomeConfig) ? projectBiomeConfig : (0, import_node_path2.join)(devkitRoot, "biome-config.json");
|
|
15
177
|
function resolveBin(packageName, binName) {
|
|
16
178
|
const packageJsonPath = requireFromDevkit.resolve(`${packageName}/package.json`);
|
|
17
|
-
const packageJson = JSON.parse((0,
|
|
179
|
+
const packageJson = JSON.parse((0, import_node_fs2.readFileSync)(packageJsonPath, "utf8"));
|
|
18
180
|
const relativeBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.[binName];
|
|
19
181
|
if (!relativeBin) {
|
|
20
182
|
throw new Error(`${packageName} does not expose the ${binName} executable`);
|
|
21
183
|
}
|
|
22
|
-
return (0,
|
|
184
|
+
return (0, import_node_path2.resolve)((0, import_node_path2.dirname)(packageJsonPath), relativeBin);
|
|
23
185
|
}
|
|
24
186
|
function run(name, packageName, binName, args) {
|
|
25
187
|
return new Promise((resolveResult) => {
|
|
@@ -41,21 +203,21 @@ function run(name, packageName, binName, args) {
|
|
|
41
203
|
});
|
|
42
204
|
}
|
|
43
205
|
function readProjectSource(path, checkName) {
|
|
44
|
-
const absolutePath = (0,
|
|
45
|
-
if (!(0,
|
|
206
|
+
const absolutePath = (0, import_node_path2.join)(projectRoot, path);
|
|
207
|
+
if (!(0, import_node_fs2.existsSync)(absolutePath)) {
|
|
46
208
|
console.error(`[${checkName}] \u7F3A\u5C11\u6A21\u677F\u5FC5\u9700\u7684 ${path}\u3002\u8BF7\u6062\u590D\u6A21\u677F\u539F\u6587\u4EF6\u540E\u91CD\u65B0\u8FD0\u884C pnpm lint\u3002`);
|
|
47
209
|
return void 0;
|
|
48
210
|
}
|
|
49
|
-
return (0,
|
|
211
|
+
return (0, import_node_fs2.readFileSync)(absolutePath, "utf8");
|
|
50
212
|
}
|
|
51
213
|
function readProjectManifest() {
|
|
52
|
-
const manifestPath = (0,
|
|
53
|
-
if (!(0,
|
|
214
|
+
const manifestPath = (0, import_node_path2.join)(projectRoot, "package.json");
|
|
215
|
+
if (!(0, import_node_fs2.existsSync)(manifestPath)) {
|
|
54
216
|
console.error("[phaser-version] \u7F3A\u5C11\u9879\u76EE package.json\u3002");
|
|
55
217
|
return void 0;
|
|
56
218
|
}
|
|
57
219
|
try {
|
|
58
|
-
return JSON.parse((0,
|
|
220
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(manifestPath, "utf8"));
|
|
59
221
|
} catch (error) {
|
|
60
222
|
console.error(`[phaser-version] \u65E0\u6CD5\u89E3\u6790 package.json\uFF1A${String(error)}`);
|
|
61
223
|
return void 0;
|
|
@@ -113,9 +275,15 @@ function checkVitestConfig(target) {
|
|
|
113
275
|
}
|
|
114
276
|
return { name, ok: true };
|
|
115
277
|
}
|
|
278
|
+
function checkReactAuthoritativePlaythrough() {
|
|
279
|
+
const name = "react-authoritative-playthrough";
|
|
280
|
+
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
281
|
+
for (const issue of audit.issues) console.error(`[${name}] ${issue}`);
|
|
282
|
+
return { name, ok: audit.ok };
|
|
283
|
+
}
|
|
116
284
|
async function runAllChecks(target) {
|
|
117
|
-
const targetChecks = target === "react" ? [checkVitestConfig("react")] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
118
|
-
const lintRoots = target === "react" && (0,
|
|
285
|
+
const targetChecks = target === "react" ? [checkVitestConfig("react"), checkReactAuthoritativePlaythrough()] : [checkPhaser4Dependency(), checkPhaserViteConfig(), checkVitestConfig("phaser")];
|
|
286
|
+
const lintRoots = target === "react" && (0, import_node_fs2.existsSync)((0, import_node_path2.join)(projectRoot, "tests")) ? ["src", "tests"] : ["src"];
|
|
119
287
|
const [tsgo, biome] = await Promise.all([
|
|
120
288
|
run("tsgo", "@typescript/native-preview", "tsgo", ["-p", "tsconfig.json"]),
|
|
121
289
|
run("biome", "@biomejs/biome", "biome", [
|
|
@@ -125,17 +293,17 @@ async function runAllChecks(target) {
|
|
|
125
293
|
...lintRoots
|
|
126
294
|
])
|
|
127
295
|
]);
|
|
128
|
-
const tailwindOutput = (0,
|
|
296
|
+
const tailwindOutput = (0, import_node_path2.join)((0, import_node_os.tmpdir)(), `miaoda-game-devkit-${process.pid}.css`);
|
|
129
297
|
const tailwind = await run("tailwind", "tailwindcss", "tailwindcss", [
|
|
130
298
|
"-i",
|
|
131
|
-
(0,
|
|
299
|
+
(0, import_node_path2.join)(projectRoot, "src/index.css"),
|
|
132
300
|
"-o",
|
|
133
301
|
tailwindOutput
|
|
134
302
|
]);
|
|
135
|
-
(0,
|
|
303
|
+
(0, import_node_fs2.rmSync)(tailwindOutput, { force: true });
|
|
136
304
|
const oxlint = await run("oxlint", "oxlint", "oxlint", [
|
|
137
305
|
"-c",
|
|
138
|
-
(0,
|
|
306
|
+
(0, import_node_path2.join)(devkitRoot, "oxlint-config.json"),
|
|
139
307
|
...lintRoots
|
|
140
308
|
]);
|
|
141
309
|
return [...targetChecks, tsgo, biome, tailwind, oxlint];
|
package/dist/react/testing.d.mts
CHANGED
|
@@ -63,7 +63,14 @@ interface ReactPlaythroughMetadata {
|
|
|
63
63
|
evidence: ReactPlaythroughEvidence;
|
|
64
64
|
}
|
|
65
65
|
interface ReactPlaythroughOptions {
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Read authoritative production gameplay state when the DOM cannot prove the
|
|
68
|
+
* result, such as Canvas, Controller, time, or physics flows. The harness
|
|
69
|
+
* samples this state before and after each stage, not after every step.
|
|
70
|
+
* Individual steps may leave it unchanged, but the completed stage must
|
|
71
|
+
* produce a new gameplay result. Observation supplements real production
|
|
72
|
+
* input; it does not replace it.
|
|
73
|
+
*/
|
|
67
74
|
observe: () => unknown;
|
|
68
75
|
}
|
|
69
76
|
interface ReactPlaythroughAssertContext {
|
package/dist/react/testing.d.ts
CHANGED
|
@@ -63,7 +63,14 @@ interface ReactPlaythroughMetadata {
|
|
|
63
63
|
evidence: ReactPlaythroughEvidence;
|
|
64
64
|
}
|
|
65
65
|
interface ReactPlaythroughOptions {
|
|
66
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Read authoritative production gameplay state when the DOM cannot prove the
|
|
68
|
+
* result, such as Canvas, Controller, time, or physics flows. The harness
|
|
69
|
+
* samples this state before and after each stage, not after every step.
|
|
70
|
+
* Individual steps may leave it unchanged, but the completed stage must
|
|
71
|
+
* produce a new gameplay result. Observation supplements real production
|
|
72
|
+
* input; it does not replace it.
|
|
73
|
+
*/
|
|
67
74
|
observe: () => unknown;
|
|
68
75
|
}
|
|
69
76
|
interface ReactPlaythroughAssertContext {
|
package/dist/react/testing.js
CHANGED
|
@@ -164,6 +164,11 @@ var MIN_STAGES = 5;
|
|
|
164
164
|
var MIN_MILESTONES = 3;
|
|
165
165
|
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
166
166
|
var MAX_TRACE_LENGTH = 720;
|
|
167
|
+
function eventTargetsCanvas(event) {
|
|
168
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
169
|
+
if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
|
|
170
|
+
return event.target instanceof HTMLCanvasElement;
|
|
171
|
+
}
|
|
167
172
|
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
168
173
|
function truncateTraceValue(value, limit) {
|
|
169
174
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
@@ -257,9 +262,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
257
262
|
let stepTrace;
|
|
258
263
|
let failureTraceFactory;
|
|
259
264
|
let acceptingStageInput = false;
|
|
265
|
+
let activeStageTargetedCanvas = false;
|
|
260
266
|
let inputCaptureAttached = false;
|
|
261
|
-
const recordInput = () => {
|
|
262
|
-
if (acceptingStageInput)
|
|
267
|
+
const recordInput = (event) => {
|
|
268
|
+
if (!acceptingStageInput) return;
|
|
269
|
+
evidence.domInputEvents += 1;
|
|
270
|
+
activeStageTargetedCanvas ||= eventTargetsCanvas(event);
|
|
263
271
|
};
|
|
264
272
|
const stopInputCapture = () => {
|
|
265
273
|
acceptingStageInput = false;
|
|
@@ -333,12 +341,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
333
341
|
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
334
342
|
activeStage = { name: normalizedName, kind, before };
|
|
335
343
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
344
|
+
if (stage.step && !playthroughOptions?.observe) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`${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.`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
336
349
|
if (stage.until()) {
|
|
337
350
|
throw new Error(
|
|
338
351
|
`${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.`
|
|
339
352
|
);
|
|
340
353
|
}
|
|
341
354
|
const inputsBefore = evidence.domInputEvents;
|
|
355
|
+
activeStageTargetedCanvas = false;
|
|
342
356
|
if (stage.act) {
|
|
343
357
|
acceptingStageInput = true;
|
|
344
358
|
try {
|
|
@@ -351,6 +365,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
351
365
|
`${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.`
|
|
352
366
|
);
|
|
353
367
|
}
|
|
368
|
+
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`${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.`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
354
373
|
}
|
|
355
374
|
let advancedSteps = 0;
|
|
356
375
|
const stepBound = stage.maxSteps ?? 120;
|
package/dist/react/testing.mjs
CHANGED
|
@@ -126,6 +126,11 @@ var MIN_STAGES = 5;
|
|
|
126
126
|
var MIN_MILESTONES = 3;
|
|
127
127
|
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
128
128
|
var MAX_TRACE_LENGTH = 720;
|
|
129
|
+
function eventTargetsCanvas(event) {
|
|
130
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
131
|
+
if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
|
|
132
|
+
return event.target instanceof HTMLCanvasElement;
|
|
133
|
+
}
|
|
129
134
|
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
130
135
|
function truncateTraceValue(value, limit) {
|
|
131
136
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
@@ -219,9 +224,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
219
224
|
let stepTrace;
|
|
220
225
|
let failureTraceFactory;
|
|
221
226
|
let acceptingStageInput = false;
|
|
227
|
+
let activeStageTargetedCanvas = false;
|
|
222
228
|
let inputCaptureAttached = false;
|
|
223
|
-
const recordInput = () => {
|
|
224
|
-
if (acceptingStageInput)
|
|
229
|
+
const recordInput = (event) => {
|
|
230
|
+
if (!acceptingStageInput) return;
|
|
231
|
+
evidence.domInputEvents += 1;
|
|
232
|
+
activeStageTargetedCanvas ||= eventTargetsCanvas(event);
|
|
225
233
|
};
|
|
226
234
|
const stopInputCapture = () => {
|
|
227
235
|
acceptingStageInput = false;
|
|
@@ -295,12 +303,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
295
303
|
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
296
304
|
activeStage = { name: normalizedName, kind, before };
|
|
297
305
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
306
|
+
if (stage.step && !playthroughOptions?.observe) {
|
|
307
|
+
throw new Error(
|
|
308
|
+
`${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.`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
298
311
|
if (stage.until()) {
|
|
299
312
|
throw new Error(
|
|
300
313
|
`${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.`
|
|
301
314
|
);
|
|
302
315
|
}
|
|
303
316
|
const inputsBefore = evidence.domInputEvents;
|
|
317
|
+
activeStageTargetedCanvas = false;
|
|
304
318
|
if (stage.act) {
|
|
305
319
|
acceptingStageInput = true;
|
|
306
320
|
try {
|
|
@@ -313,6 +327,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
313
327
|
`${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.`
|
|
314
328
|
);
|
|
315
329
|
}
|
|
330
|
+
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`${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.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
316
335
|
}
|
|
317
336
|
let advancedSteps = 0;
|
|
318
337
|
const stepBound = stage.maxSteps ?? 120;
|
|
@@ -115,6 +115,11 @@ var MIN_STAGES = 5;
|
|
|
115
115
|
var MIN_MILESTONES = 3;
|
|
116
116
|
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
117
117
|
var MAX_TRACE_LENGTH = 720;
|
|
118
|
+
function eventTargetsCanvas(event) {
|
|
119
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
120
|
+
if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
|
|
121
|
+
return event.target instanceof HTMLCanvasElement;
|
|
122
|
+
}
|
|
118
123
|
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
119
124
|
function truncateTraceValue(value, limit) {
|
|
120
125
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
@@ -208,9 +213,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
208
213
|
let stepTrace;
|
|
209
214
|
let failureTraceFactory;
|
|
210
215
|
let acceptingStageInput = false;
|
|
216
|
+
let activeStageTargetedCanvas = false;
|
|
211
217
|
let inputCaptureAttached = false;
|
|
212
|
-
const recordInput = () => {
|
|
213
|
-
if (acceptingStageInput)
|
|
218
|
+
const recordInput = (event) => {
|
|
219
|
+
if (!acceptingStageInput) return;
|
|
220
|
+
evidence.domInputEvents += 1;
|
|
221
|
+
activeStageTargetedCanvas ||= eventTargetsCanvas(event);
|
|
214
222
|
};
|
|
215
223
|
const stopInputCapture = () => {
|
|
216
224
|
acceptingStageInput = false;
|
|
@@ -284,12 +292,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
284
292
|
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
285
293
|
activeStage = { name: normalizedName, kind, before };
|
|
286
294
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
295
|
+
if (stage.step && !playthroughOptions?.observe) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
287
300
|
if (stage.until()) {
|
|
288
301
|
throw new Error(
|
|
289
302
|
`${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.`
|
|
290
303
|
);
|
|
291
304
|
}
|
|
292
305
|
const inputsBefore = evidence.domInputEvents;
|
|
306
|
+
activeStageTargetedCanvas = false;
|
|
293
307
|
if (stage.act) {
|
|
294
308
|
acceptingStageInput = true;
|
|
295
309
|
try {
|
|
@@ -302,6 +316,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
302
316
|
`${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.`
|
|
303
317
|
);
|
|
304
318
|
}
|
|
319
|
+
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
305
324
|
}
|
|
306
325
|
let advancedSteps = 0;
|
|
307
326
|
const stepBound = stage.maxSteps ?? 120;
|
|
@@ -481,6 +500,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
481
500
|
|
|
482
501
|
// src/react/react-playthrough-reporter.ts
|
|
483
502
|
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
503
|
+
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.";
|
|
484
504
|
function isMetadata(value) {
|
|
485
505
|
if (!value || typeof value !== "object") return false;
|
|
486
506
|
const metadata = value;
|
|
@@ -595,17 +615,23 @@ function repairGuidance(cause) {
|
|
|
595
615
|
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
596
616
|
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.";
|
|
597
617
|
}
|
|
618
|
+
if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
|
|
619
|
+
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.";
|
|
620
|
+
}
|
|
621
|
+
if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
|
|
622
|
+
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.";
|
|
623
|
+
}
|
|
598
624
|
if (/until condition must be false before its driver runs/i.test(cause)) {
|
|
599
625
|
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.";
|
|
600
626
|
}
|
|
601
627
|
if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
|
|
602
|
-
return "The stage ran and asserted, but its observable state matched the previous milestone.
|
|
628
|
+
return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe that result directly. Canvas or Controller games should make observe read the same production Controller that React renders. Do not substitute arbitrary labels, button visibility, navigation, or another weaker state change merely to produce a different fingerprint.";
|
|
603
629
|
}
|
|
604
630
|
if (/No step callback was provided/i.test(cause)) {
|
|
605
631
|
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.";
|
|
606
632
|
}
|
|
607
633
|
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
608
|
-
return "The stage driver ran, but gameplay did not reach
|
|
634
|
+
return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
|
|
609
635
|
}
|
|
610
636
|
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
611
637
|
return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
|
|
@@ -683,6 +709,9 @@ function formatReactPlaythroughReport(report) {
|
|
|
683
709
|
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
684
710
|
}
|
|
685
711
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
712
|
+
if (report.status === "FAILED") {
|
|
713
|
+
lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
|
|
714
|
+
}
|
|
686
715
|
return `
|
|
687
716
|
${lines.join("\n")}`;
|
|
688
717
|
}
|
|
@@ -81,6 +81,11 @@ var MIN_STAGES = 5;
|
|
|
81
81
|
var MIN_MILESTONES = 3;
|
|
82
82
|
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
83
83
|
var MAX_TRACE_LENGTH = 720;
|
|
84
|
+
function eventTargetsCanvas(event) {
|
|
85
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
86
|
+
if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
|
|
87
|
+
return event.target instanceof HTMLCanvasElement;
|
|
88
|
+
}
|
|
84
89
|
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
85
90
|
function truncateTraceValue(value, limit) {
|
|
86
91
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
@@ -174,9 +179,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
174
179
|
let stepTrace;
|
|
175
180
|
let failureTraceFactory;
|
|
176
181
|
let acceptingStageInput = false;
|
|
182
|
+
let activeStageTargetedCanvas = false;
|
|
177
183
|
let inputCaptureAttached = false;
|
|
178
|
-
const recordInput = () => {
|
|
179
|
-
if (acceptingStageInput)
|
|
184
|
+
const recordInput = (event) => {
|
|
185
|
+
if (!acceptingStageInput) return;
|
|
186
|
+
evidence.domInputEvents += 1;
|
|
187
|
+
activeStageTargetedCanvas ||= eventTargetsCanvas(event);
|
|
180
188
|
};
|
|
181
189
|
const stopInputCapture = () => {
|
|
182
190
|
acceptingStageInput = false;
|
|
@@ -250,12 +258,18 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
250
258
|
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
251
259
|
activeStage = { name: normalizedName, kind, before };
|
|
252
260
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
261
|
+
if (stage.step && !playthroughOptions?.observe) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`${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
|
+
);
|
|
265
|
+
}
|
|
253
266
|
if (stage.until()) {
|
|
254
267
|
throw new Error(
|
|
255
268
|
`${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.`
|
|
256
269
|
);
|
|
257
270
|
}
|
|
258
271
|
const inputsBefore = evidence.domInputEvents;
|
|
272
|
+
activeStageTargetedCanvas = false;
|
|
259
273
|
if (stage.act) {
|
|
260
274
|
acceptingStageInput = true;
|
|
261
275
|
try {
|
|
@@ -268,6 +282,11 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
268
282
|
`${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.`
|
|
269
283
|
);
|
|
270
284
|
}
|
|
285
|
+
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`${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
|
+
);
|
|
289
|
+
}
|
|
271
290
|
}
|
|
272
291
|
let advancedSteps = 0;
|
|
273
292
|
const stepBound = stage.maxSteps ?? 120;
|
|
@@ -447,6 +466,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
447
466
|
|
|
448
467
|
// src/react/react-playthrough-reporter.ts
|
|
449
468
|
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
469
|
+
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.";
|
|
450
470
|
function isMetadata(value) {
|
|
451
471
|
if (!value || typeof value !== "object") return false;
|
|
452
472
|
const metadata = value;
|
|
@@ -561,17 +581,23 @@ function repairGuidance(cause) {
|
|
|
561
581
|
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
562
582
|
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.";
|
|
563
583
|
}
|
|
584
|
+
if (/deterministic step advancement without an authoritative observe callback/i.test(cause)) {
|
|
585
|
+
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.";
|
|
586
|
+
}
|
|
587
|
+
if (/production input to Canvas without an authoritative observe callback/i.test(cause)) {
|
|
588
|
+
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.";
|
|
589
|
+
}
|
|
564
590
|
if (/until condition must be false before its driver runs/i.test(cause)) {
|
|
565
591
|
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.";
|
|
566
592
|
}
|
|
567
593
|
if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
|
|
568
|
-
return "The stage ran and asserted, but its observable state matched the previous milestone.
|
|
594
|
+
return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe that result directly. Canvas or Controller games should make observe read the same production Controller that React renders. Do not substitute arbitrary labels, button visibility, navigation, or another weaker state change merely to produce a different fingerprint.";
|
|
569
595
|
}
|
|
570
596
|
if (/No step callback was provided/i.test(cause)) {
|
|
571
597
|
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.";
|
|
572
598
|
}
|
|
573
599
|
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
574
|
-
return "The stage driver ran, but gameplay did not reach
|
|
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.";
|
|
575
601
|
}
|
|
576
602
|
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
577
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.";
|
|
@@ -649,6 +675,9 @@ function formatReactPlaythroughReport(report) {
|
|
|
649
675
|
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
650
676
|
}
|
|
651
677
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
678
|
+
if (report.status === "FAILED") {
|
|
679
|
+
lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
|
|
680
|
+
}
|
|
652
681
|
return `
|
|
653
682
|
${lines.join("\n")}`;
|
|
654
683
|
}
|
|
@@ -40,6 +40,12 @@ function internalGameImports(node) {
|
|
|
40
40
|
return importKind === "type" ? [] : [specifier.local.name];
|
|
41
41
|
});
|
|
42
42
|
}
|
|
43
|
+
function isStepProperty(node) {
|
|
44
|
+
return node.key.type === "Identifier" && node.key.name === "step" || node.key.type === "Literal" && node.key.value === "step";
|
|
45
|
+
}
|
|
46
|
+
function isEmptyFunction(value) {
|
|
47
|
+
return (value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression") && value.body.type === "BlockStatement" && value.body.body.length === 0;
|
|
48
|
+
}
|
|
43
49
|
var rule = {
|
|
44
50
|
meta: {
|
|
45
51
|
type: "problem",
|
|
@@ -50,7 +56,8 @@ var rule = {
|
|
|
50
56
|
boundExpect: "Use the expect provided by playthroughTest. An imported Vitest expect may belong to a different module instance and cannot provide reliable assertion evidence.",
|
|
51
57
|
providedUser: "Use the user provided by playthroughTest; do not import or create another userEvent instance in the production playthrough.",
|
|
52
58
|
productionTestingImport: "Production source must not import miaoda-game-devkit/react/testing. Inject test clocks and observers through the production App factory boundary.",
|
|
53
|
-
productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly."
|
|
59
|
+
productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly.",
|
|
60
|
+
emptyStep: "A deterministic step must advance production-owned time, frames, or queued work. An empty step is not gameplay evidence. Inject ManualGameClock through <App /> and call clock.stepFrame(...)."
|
|
54
61
|
},
|
|
55
62
|
schema: []
|
|
56
63
|
},
|
|
@@ -85,6 +92,12 @@ var rule = {
|
|
|
85
92
|
if (opening.name?.type === "JSXIdentifier" && opening.name.name && internalGameBindings.has(opening.name.name)) {
|
|
86
93
|
context.report({ node, messageId: "productionEntry" });
|
|
87
94
|
}
|
|
95
|
+
},
|
|
96
|
+
Property(node) {
|
|
97
|
+
if (!isProductionPlaythrough) return;
|
|
98
|
+
if (isStepProperty(node) && isEmptyFunction(node.value)) {
|
|
99
|
+
context.report({ node, messageId: "emptyStep" });
|
|
100
|
+
}
|
|
88
101
|
}
|
|
89
102
|
};
|
|
90
103
|
}
|