miaoda-game-devkit 0.6.4 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/phaser-lint.js +2 -2
- package/dist/cli/react-lint.js +2 -2
- package/dist/react/testing.d.mts +12 -0
- package/dist/react/testing.d.ts +12 -0
- package/dist/react/testing.js +112 -14
- package/dist/react/testing.mjs +112 -14
- package/dist/react/vitest-config.js +636 -440
- package/dist/react/vitest-config.mjs +640 -444
- package/dist/react/vitest-setup.js +112 -14
- package/dist/react/vitest-setup.mjs +112 -14
- package/dist/rules/react-test-boundary-plugin.js +1 -1
- package/package.json +1 -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. Observe the same production Controller rendered by <App /> through Telemetry; DOM labels are not an authoritative gameplay boundary.`
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
229
|
-
issues.push(
|
|
230
|
-
`${PRODUCTION_PLAYTHROUGH} must import ManualGameClock because production owns gameplay time in ${clockFiles.join(", ")}. Inject it through the production <App /> factory and advance it with a non-empty deterministic step.`
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
return {
|
|
234
|
-
ok: issues.length === 0,
|
|
235
|
-
issues,
|
|
236
|
-
clockFiles,
|
|
237
|
-
controllerFiles,
|
|
238
|
-
productionEntryExists,
|
|
239
|
-
productionUsesExample,
|
|
240
|
-
staleExampleTestFiles
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
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());
|
|
278
40
|
}
|
|
279
|
-
if (value === null ||
|
|
280
|
-
return
|
|
41
|
+
if (value === null || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
42
|
+
return String(value);
|
|
281
43
|
}
|
|
44
|
+
if (typeof value === "symbol") return value.toString();
|
|
282
45
|
if (typeof value === "function") {
|
|
283
|
-
return
|
|
284
|
-
{ code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
|
|
285
|
-
];
|
|
46
|
+
return `Function<${value.name || "anonymous"}>`;
|
|
286
47
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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";
|
|
291
58
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (typeof record.message === "string" && record.message.trim()) {
|
|
296
|
-
entries.push({ code, message: truncate(record.message) });
|
|
59
|
+
if (normalized.includes("/node_modules/")) return "dependency";
|
|
60
|
+
if (/(?:^|\/)tests?\//.test(normalized) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalized)) {
|
|
61
|
+
return "test";
|
|
297
62
|
}
|
|
298
|
-
if (
|
|
299
|
-
|
|
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;
|
|
300
71
|
}
|
|
301
|
-
|
|
302
|
-
|
|
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;
|
|
303
82
|
}
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
});
|
|
307
92
|
}
|
|
308
|
-
|
|
309
|
-
return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
|
|
93
|
+
return origins;
|
|
310
94
|
}
|
|
311
|
-
function
|
|
312
|
-
const
|
|
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) }] : [];
|
|
131
|
+
}
|
|
132
|
+
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
|
|
133
|
+
return [{ code: fallbackCode2, message: String(value) }];
|
|
134
|
+
}
|
|
135
|
+
if (typeof value === "function") {
|
|
136
|
+
return [
|
|
137
|
+
{ code: fallbackCode2, message: `Function<${value.name || "anonymous"}>` }
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
if (seen.has(value)) return [];
|
|
141
|
+
seen.add(value);
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
return value.flatMap((item) => collectEntries(item, fallbackCode2, seen));
|
|
144
|
+
}
|
|
145
|
+
const record = value;
|
|
146
|
+
const entries = [];
|
|
147
|
+
if (typeof record.message === "string" && record.message.trim()) {
|
|
148
|
+
entries.push(structuredEntry(record, record.message, fallbackCode2));
|
|
149
|
+
}
|
|
150
|
+
if (record.cause !== void 0) {
|
|
151
|
+
entries.push(...collectEntries(record.cause, fallbackCode2, seen));
|
|
152
|
+
}
|
|
153
|
+
if (Array.isArray(record.errors)) {
|
|
154
|
+
entries.push(...collectEntries(record.errors, fallbackCode2, seen));
|
|
155
|
+
}
|
|
156
|
+
if (entries.length > 0) return entries;
|
|
157
|
+
if (typeof record.stack === "string" && record.stack.trim()) {
|
|
158
|
+
return [structuredEntry(record, record.stack, fallbackCode2)];
|
|
159
|
+
}
|
|
160
|
+
const json = safeJson(value);
|
|
161
|
+
return json && json !== "{}" ? [structuredEntry(record, json, fallbackCode2)] : [];
|
|
162
|
+
}
|
|
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) {
|
|
@@ -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,6 +744,131 @@ function findPendingProductTests(modules, projectRoot) {
|
|
|
892
744
|
}));
|
|
893
745
|
});
|
|
894
746
|
}
|
|
747
|
+
function selectReactFailure(values, errorRecordCount = values.length) {
|
|
748
|
+
const entries = extractFailureEntries(values);
|
|
749
|
+
if (entries.length === 0) {
|
|
750
|
+
return {
|
|
751
|
+
code: "MISSING_FAILURE_DETAILS",
|
|
752
|
+
cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
|
|
753
|
+
rawCause: "",
|
|
754
|
+
related: []
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
const primary = entries[0];
|
|
758
|
+
const rawCause = primary.message;
|
|
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
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function failureHint(value) {
|
|
772
|
+
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
773
|
+
(match) => match[1]
|
|
774
|
+
);
|
|
775
|
+
if (names.length === 0) return void 0;
|
|
776
|
+
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
777
|
+
}
|
|
778
|
+
function errorLocation(value) {
|
|
779
|
+
const match = value.match(
|
|
780
|
+
/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
|
|
781
|
+
);
|
|
782
|
+
return match?.[1];
|
|
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
|
+
}
|
|
789
|
+
function failureTrace(test2) {
|
|
790
|
+
const annotations = test2.annotations();
|
|
791
|
+
let annotationTrace;
|
|
792
|
+
for (let index = annotations.length - 1; index >= 0; index -= 1) {
|
|
793
|
+
if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
|
|
794
|
+
annotationTrace = annotations[index].message;
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
799
|
+
const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
|
|
800
|
+
return trace ? truncateReporterLine(trace, 720) : void 0;
|
|
801
|
+
}
|
|
802
|
+
function toReactPlaythroughModuleResult(module, projectRoot) {
|
|
803
|
+
const tests = [...module.children.allTests()];
|
|
804
|
+
const moduleFailure = selectReactFailure(
|
|
805
|
+
module.errors(),
|
|
806
|
+
module.errors().length
|
|
807
|
+
);
|
|
808
|
+
const moduleErrors = extractFailureEntries(module.errors()).map(
|
|
809
|
+
(entry) => firstFailureLine(entry.message) ?? entry.message
|
|
810
|
+
);
|
|
811
|
+
const errors = [
|
|
812
|
+
...moduleErrors,
|
|
813
|
+
...tests.flatMap(
|
|
814
|
+
(test2) => extractFailureEntries(test2.result().errors ?? []).map(
|
|
815
|
+
(entry) => firstFailureLine(entry.message) ?? entry.message
|
|
816
|
+
)
|
|
817
|
+
)
|
|
818
|
+
];
|
|
819
|
+
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
820
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
821
|
+
const testErrors = test2.result().errors ?? [];
|
|
822
|
+
const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
|
|
823
|
+
const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
|
|
824
|
+
const selected = selectReactFailure(
|
|
825
|
+
[...attemptErrors, ...module.errors()],
|
|
826
|
+
testErrors.length + module.errors().length
|
|
827
|
+
);
|
|
828
|
+
return {
|
|
829
|
+
test: test2.fullName,
|
|
830
|
+
causeCode: selected.code,
|
|
831
|
+
cause: selected.cause,
|
|
832
|
+
errorName: selected.errorName,
|
|
833
|
+
actual: selected.actual,
|
|
834
|
+
expected: selected.expected,
|
|
835
|
+
origin: selected.origin,
|
|
836
|
+
layer: selected.layer,
|
|
837
|
+
related: selected.related,
|
|
838
|
+
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
|
|
839
|
+
hint: failureHint(selected.rawCause),
|
|
840
|
+
trace: failureTrace(test2)
|
|
841
|
+
};
|
|
842
|
+
});
|
|
843
|
+
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
844
|
+
failures.push({
|
|
845
|
+
test: "<collection>",
|
|
846
|
+
causeCode: moduleFailure.code,
|
|
847
|
+
cause: moduleFailure.cause,
|
|
848
|
+
errorName: moduleFailure.errorName,
|
|
849
|
+
actual: moduleFailure.actual,
|
|
850
|
+
expected: moduleFailure.expected,
|
|
851
|
+
origin: moduleFailure.origin,
|
|
852
|
+
layer: moduleFailure.layer,
|
|
853
|
+
related: moduleFailure.related,
|
|
854
|
+
location: errorLocation(moduleFailure.rawCause),
|
|
855
|
+
hint: failureHint(moduleFailure.rawCause)
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
860
|
+
state: module.state(),
|
|
861
|
+
errors,
|
|
862
|
+
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
863
|
+
primaryCauseCode: failures[0]?.causeCode,
|
|
864
|
+
relatedErrors: failures[0]?.related,
|
|
865
|
+
tests: tests.map(toAuditInput),
|
|
866
|
+
failures
|
|
867
|
+
};
|
|
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.";
|
|
895
872
|
function formatPendingProductTestReport(pending) {
|
|
896
873
|
if (pending.length === 0) return void 0;
|
|
897
874
|
const lines = [
|
|
@@ -912,145 +889,379 @@ function formatPendingProductTestReport(pending) {
|
|
|
912
889
|
lines.push(`TEST: ${item.test}`);
|
|
913
890
|
lines.push(`MODE: ${item.mode}`);
|
|
914
891
|
}
|
|
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
|
|
922
|
-
|
|
923
|
-
|
|
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
|
+
}
|
|
910
|
+
function formatReactFailureSummary(modules) {
|
|
911
|
+
const failures = modules.flatMap(
|
|
912
|
+
(module) => (module.failures ?? []).map((failure) => ({
|
|
913
|
+
...failure,
|
|
914
|
+
file: module.file
|
|
915
|
+
}))
|
|
916
|
+
);
|
|
917
|
+
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
918
|
+
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
919
|
+
for (const [index, failure] of failures.entries()) {
|
|
920
|
+
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
921
|
+
lines.push(`TEST: ${failure.test}`);
|
|
922
|
+
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
923
|
+
lines.push(`CAUSE: ${failure.cause}`);
|
|
924
|
+
appendFailureDetails(lines, failure);
|
|
925
|
+
for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
|
|
926
|
+
lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
|
|
927
|
+
lines.push(
|
|
928
|
+
`RELATED_${relatedIndex + 1}: ${firstFailureLine(related.message) ?? related.message}`
|
|
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
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
940
|
+
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
941
|
+
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
942
|
+
}
|
|
943
|
+
lines.push("TEST_RESULT: FAIL");
|
|
944
|
+
return lines;
|
|
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
|
+
var PRODUCTION_PLAYTHROUGH = "tests/production-playthrough.test.tsx";
|
|
1017
|
+
var PRODUCTION_APP = "src/App.tsx";
|
|
1018
|
+
var EXAMPLE_IMPORT_PREFIX = "@/game/example/";
|
|
1019
|
+
var REACT_RUNTIME_ENTRY = "miaoda-game-devkit/react";
|
|
1020
|
+
var REACT_TESTING_ENTRY = "miaoda-game-devkit/react/testing";
|
|
1021
|
+
var CLOCK_IMPORTS = /* @__PURE__ */ new Set(["browserGameClock"]);
|
|
1022
|
+
var CONTROLLER_IMPORTS = /* @__PURE__ */ new Set([
|
|
1023
|
+
"useGameController",
|
|
1024
|
+
"useOwnedGameController"
|
|
1025
|
+
]);
|
|
1026
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
|
|
1027
|
+
function runnableTestFiles(root, directory = join(root, "tests")) {
|
|
1028
|
+
if (!existsSync(directory)) return [];
|
|
1029
|
+
const files = [];
|
|
1030
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1031
|
+
const path = join(directory, entry.name);
|
|
1032
|
+
if (entry.isDirectory()) {
|
|
1033
|
+
files.push(...runnableTestFiles(root, path));
|
|
1034
|
+
} else if (entry.isFile() && /\.test\.[cm]?[jt]sx?$/.test(entry.name)) {
|
|
1035
|
+
files.push(path);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return files;
|
|
1039
|
+
}
|
|
1040
|
+
function extension(path) {
|
|
1041
|
+
const index = path.lastIndexOf(".");
|
|
1042
|
+
return index < 0 ? "" : path.slice(index);
|
|
1043
|
+
}
|
|
1044
|
+
function sourceFiles(root, directory = join(root, "src")) {
|
|
1045
|
+
if (!existsSync(directory)) return [];
|
|
1046
|
+
const files = [];
|
|
1047
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1048
|
+
const path = join(directory, entry.name);
|
|
1049
|
+
const projectPath = relative2(root, path).replaceAll("\\", "/");
|
|
1050
|
+
if (entry.isDirectory()) {
|
|
1051
|
+
if (projectPath === "src/game/example") continue;
|
|
1052
|
+
files.push(...sourceFiles(root, path));
|
|
1053
|
+
} else if (entry.isFile() && SOURCE_EXTENSIONS.has(extension(entry.name))) {
|
|
1054
|
+
files.push(path);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return files;
|
|
1058
|
+
}
|
|
1059
|
+
function withoutComments(source) {
|
|
1060
|
+
let output = "";
|
|
1061
|
+
let state = "code";
|
|
1062
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1063
|
+
const char = source[index];
|
|
1064
|
+
const next = source[index + 1];
|
|
1065
|
+
if (state === "line") {
|
|
1066
|
+
if (char === "\n") {
|
|
1067
|
+
state = "code";
|
|
1068
|
+
output += char;
|
|
1069
|
+
} else {
|
|
1070
|
+
output += " ";
|
|
1071
|
+
}
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (state === "block") {
|
|
1075
|
+
if (char === "*" && next === "/") {
|
|
1076
|
+
output += " ";
|
|
1077
|
+
index += 1;
|
|
1078
|
+
state = "code";
|
|
1079
|
+
} else {
|
|
1080
|
+
output += char === "\n" ? "\n" : " ";
|
|
1081
|
+
}
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
if (state === "code" && char === "/" && next === "/") {
|
|
1085
|
+
output += " ";
|
|
1086
|
+
index += 1;
|
|
1087
|
+
state = "line";
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
if (state === "code" && char === "/" && next === "*") {
|
|
1091
|
+
output += " ";
|
|
1092
|
+
index += 1;
|
|
1093
|
+
state = "block";
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (state === "code" && char === "'") state = "single";
|
|
1097
|
+
else if (state === "code" && char === '"') state = "double";
|
|
1098
|
+
else if (state === "code" && char === "`") state = "template";
|
|
1099
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
1100
|
+
state = "code";
|
|
1101
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
1102
|
+
state = "code";
|
|
1103
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
1104
|
+
state = "code";
|
|
1105
|
+
}
|
|
1106
|
+
output += char;
|
|
1107
|
+
}
|
|
1108
|
+
return output;
|
|
1109
|
+
}
|
|
1110
|
+
function codePositions(source) {
|
|
1111
|
+
const positions = Array.from({ length: source.length }, () => false);
|
|
1112
|
+
let state = "code";
|
|
1113
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1114
|
+
const char = source[index];
|
|
1115
|
+
if (state === "code") positions[index] = true;
|
|
1116
|
+
if (state === "code" && char === "'") state = "single";
|
|
1117
|
+
else if (state === "code" && char === '"') state = "double";
|
|
1118
|
+
else if (state === "code" && char === "`") state = "template";
|
|
1119
|
+
else if (state === "single" && char === "'" && source[index - 1] !== "\\") {
|
|
1120
|
+
state = "code";
|
|
1121
|
+
} else if (state === "double" && char === '"' && source[index - 1] !== "\\") {
|
|
1122
|
+
state = "code";
|
|
1123
|
+
} else if (state === "template" && char === "`" && source[index - 1] !== "\\") {
|
|
1124
|
+
state = "code";
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return positions;
|
|
924
1128
|
}
|
|
925
|
-
function
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
related: []
|
|
933
|
-
};
|
|
1129
|
+
function importedModuleSpecifiers(source) {
|
|
1130
|
+
const clean = withoutComments(source);
|
|
1131
|
+
const positions = codePositions(clean);
|
|
1132
|
+
const modules = [];
|
|
1133
|
+
const pattern = /\b(?:from\s+|import\s*(?:\(\s*)?)["']([^"']+)["']\s*\)?/g;
|
|
1134
|
+
for (const match of clean.matchAll(pattern)) {
|
|
1135
|
+
if (positions[match.index]) modules.push(match[1]);
|
|
934
1136
|
}
|
|
935
|
-
|
|
936
|
-
const rawCause = primary.message;
|
|
937
|
-
const cause = firstLine(rawCause) ?? rawCause;
|
|
938
|
-
return { code: primary.code, cause, rawCause, related: entries.slice(1) };
|
|
1137
|
+
return modules;
|
|
939
1138
|
}
|
|
940
|
-
function
|
|
941
|
-
const
|
|
942
|
-
|
|
1139
|
+
function productionFileImportsExample(file, projectRoot) {
|
|
1140
|
+
const exampleRoot = join(projectRoot, "src/game/example");
|
|
1141
|
+
return importedModuleSpecifiers(readFileSync(file, "utf8")).some(
|
|
1142
|
+
(moduleName) => {
|
|
1143
|
+
if (moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)) return true;
|
|
1144
|
+
if (!moduleName.startsWith(".")) return false;
|
|
1145
|
+
const target = resolve(dirname(file), moduleName);
|
|
1146
|
+
return target === exampleRoot || target.startsWith(`${exampleRoot}${sep}`);
|
|
1147
|
+
}
|
|
943
1148
|
);
|
|
944
|
-
if (names.length === 0) return void 0;
|
|
945
|
-
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
946
1149
|
}
|
|
947
|
-
function
|
|
948
|
-
|
|
949
|
-
|
|
1150
|
+
function importsExampleAlias(source) {
|
|
1151
|
+
return importedModuleSpecifiers(source).some(
|
|
1152
|
+
(moduleName) => moduleName.startsWith(EXAMPLE_IMPORT_PREFIX)
|
|
950
1153
|
);
|
|
951
|
-
return match?.[1];
|
|
952
1154
|
}
|
|
953
|
-
function
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1155
|
+
function namedImports(source, moduleName) {
|
|
1156
|
+
const names = /* @__PURE__ */ new Set();
|
|
1157
|
+
const clean = withoutComments(source);
|
|
1158
|
+
const positions = codePositions(clean);
|
|
1159
|
+
const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1160
|
+
const pattern = new RegExp(
|
|
1161
|
+
`^\\s*import\\s+(?:type\\s+)?\\{([\\s\\S]*?)\\}\\s+from\\s+["']${escapedModule}["']`,
|
|
1162
|
+
"gm"
|
|
1163
|
+
);
|
|
1164
|
+
for (const match of clean.matchAll(pattern)) {
|
|
1165
|
+
if (!positions[match.index]) continue;
|
|
1166
|
+
for (const specifier of match[1].split(",")) {
|
|
1167
|
+
const imported = specifier.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim();
|
|
1168
|
+
if (imported) names.add(imported);
|
|
960
1169
|
}
|
|
961
1170
|
}
|
|
962
|
-
|
|
963
|
-
const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
|
|
964
|
-
return trace ? truncateReporterLine(trace, 720) : void 0;
|
|
1171
|
+
return names;
|
|
965
1172
|
}
|
|
966
|
-
function
|
|
967
|
-
|
|
968
|
-
if (compact.length <= limit) return compact;
|
|
969
|
-
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
1173
|
+
function containsAny(values, expected) {
|
|
1174
|
+
return [...values].some((value) => expected.has(value));
|
|
970
1175
|
}
|
|
971
|
-
function
|
|
972
|
-
const
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1176
|
+
function declaresObserve(source) {
|
|
1177
|
+
const clean = withoutComments(source);
|
|
1178
|
+
const positions = codePositions(clean);
|
|
1179
|
+
for (const match of clean.matchAll(/\bobserve\s*:/g)) {
|
|
1180
|
+
if (positions[match.index]) return true;
|
|
1181
|
+
}
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
function auditReactAuthoritativePlaythrough(projectRoot) {
|
|
1185
|
+
const clockFiles = [];
|
|
1186
|
+
const controllerFiles = [];
|
|
1187
|
+
const productionFiles = sourceFiles(projectRoot);
|
|
1188
|
+
for (const file of productionFiles) {
|
|
1189
|
+
const imports = namedImports(
|
|
1190
|
+
readFileSync(file, "utf8"),
|
|
1191
|
+
REACT_RUNTIME_ENTRY
|
|
1192
|
+
);
|
|
1193
|
+
const projectPath = relative2(projectRoot, file).replaceAll("\\", "/");
|
|
1194
|
+
if (containsAny(imports, CLOCK_IMPORTS)) clockFiles.push(projectPath);
|
|
1195
|
+
if (containsAny(imports, CONTROLLER_IMPORTS))
|
|
1196
|
+
controllerFiles.push(projectPath);
|
|
1197
|
+
}
|
|
1198
|
+
const appPath = join(projectRoot, PRODUCTION_APP);
|
|
1199
|
+
const productionEntryExists = existsSync(appPath);
|
|
1200
|
+
const productionUsesExample = productionFiles.some(
|
|
1201
|
+
(file) => productionFileImportsExample(file, projectRoot)
|
|
979
1202
|
);
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
)
|
|
986
|
-
)
|
|
987
|
-
];
|
|
988
|
-
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
989
|
-
const metadata = test2.meta().reactPlaythrough;
|
|
990
|
-
const testErrors = test2.result().errors ?? [];
|
|
991
|
-
const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
|
|
992
|
-
const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
|
|
993
|
-
const selected = selectReactFailure(
|
|
994
|
-
[...attemptErrors, ...module.errors()],
|
|
995
|
-
testErrors.length + module.errors().length
|
|
1203
|
+
const staleExampleTestFiles = productionUsesExample ? [] : runnableTestFiles(projectRoot).filter((file) => importsExampleAlias(readFileSync(file, "utf8"))).map((file) => relative2(projectRoot, file).replaceAll("\\", "/"));
|
|
1204
|
+
const issues = [];
|
|
1205
|
+
if (staleExampleTestFiles.length > 0) {
|
|
1206
|
+
issues.push(
|
|
1207
|
+
`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.`
|
|
996
1208
|
);
|
|
1209
|
+
}
|
|
1210
|
+
if (clockFiles.length === 0 && controllerFiles.length === 0) {
|
|
997
1211
|
return {
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1212
|
+
ok: issues.length === 0,
|
|
1213
|
+
issues,
|
|
1214
|
+
clockFiles,
|
|
1215
|
+
controllerFiles,
|
|
1216
|
+
productionEntryExists,
|
|
1217
|
+
productionUsesExample,
|
|
1218
|
+
staleExampleTestFiles
|
|
1005
1219
|
};
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1220
|
+
}
|
|
1221
|
+
const testPath = join(projectRoot, PRODUCTION_PLAYTHROUGH);
|
|
1222
|
+
const testSource = existsSync(testPath) ? readFileSync(testPath, "utf8") : "";
|
|
1223
|
+
const testingImports = namedImports(testSource, REACT_TESTING_ENTRY);
|
|
1224
|
+
const hasObserve = declaresObserve(testSource);
|
|
1225
|
+
if (!hasObserve) {
|
|
1226
|
+
issues.push(
|
|
1227
|
+
`${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.`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
if (clockFiles.length > 0 && !testingImports.has("ManualGameClock")) {
|
|
1231
|
+
issues.push(
|
|
1232
|
+
`${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().`
|
|
1233
|
+
);
|
|
1016
1234
|
}
|
|
1017
1235
|
return {
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
failures
|
|
1236
|
+
ok: issues.length === 0,
|
|
1237
|
+
issues,
|
|
1238
|
+
clockFiles,
|
|
1239
|
+
controllerFiles,
|
|
1240
|
+
productionEntryExists,
|
|
1241
|
+
productionUsesExample,
|
|
1242
|
+
staleExampleTestFiles
|
|
1026
1243
|
};
|
|
1027
1244
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
}
|
|
1034
|
-
);
|
|
1035
|
-
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
1036
|
-
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
1037
|
-
for (const [index, failure] of failures.entries()) {
|
|
1038
|
-
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
1039
|
-
lines.push(`TEST: ${failure.test}`);
|
|
1040
|
-
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
1041
|
-
lines.push(`CAUSE: ${failure.cause}`);
|
|
1042
|
-
for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
|
|
1043
|
-
lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
|
|
1044
|
-
lines.push(
|
|
1045
|
-
`RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
|
|
1046
|
-
);
|
|
1047
|
-
}
|
|
1048
|
-
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
1049
|
-
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
1050
|
-
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
1245
|
+
|
|
1246
|
+
// src/react/react-playthrough-policy.ts
|
|
1247
|
+
function assessProductTestAlignment(projectRoot) {
|
|
1248
|
+
const audit = auditReactAuthoritativePlaythrough(projectRoot);
|
|
1249
|
+
if (audit.staleExampleTestFiles.length > 0) {
|
|
1250
|
+
return { status: "FAILED", cause: audit.issues[0] };
|
|
1051
1251
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1252
|
+
if (!audit.productionEntryExists) {
|
|
1253
|
+
return {
|
|
1254
|
+
status: "NOT_VERIFIED",
|
|
1255
|
+
cause: "The production src/App.tsx entry does not exist, so product-test alignment could not be verified."
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
if (audit.productionUsesExample) {
|
|
1259
|
+
return {
|
|
1260
|
+
status: "NOT_VERIFIED",
|
|
1261
|
+
cause: "The production App still uses the replaceable src/game/example teaching game, so replacement-game test alignment is not applicable yet."
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
return { status: "PASS" };
|
|
1054
1265
|
}
|
|
1055
1266
|
function repairGuidance(code) {
|
|
1056
1267
|
switch (code) {
|
|
@@ -1068,6 +1279,12 @@ function repairGuidance(code) {
|
|
|
1068
1279
|
return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
|
|
1069
1280
|
case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
|
|
1070
1281
|
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.";
|
|
1282
|
+
case "EXPECTATION_MISMATCH":
|
|
1283
|
+
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.";
|
|
1284
|
+
case "PRODUCT_RUNTIME_TYPE_ERROR":
|
|
1285
|
+
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.";
|
|
1286
|
+
case "TEST_API_MISMATCH":
|
|
1287
|
+
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
1288
|
case "INVALID_STAGE_ORDER":
|
|
1072
1289
|
case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
|
|
1073
1290
|
case "INVALID_STAGE_NAME":
|
|
@@ -1153,31 +1370,9 @@ function assessReactPlaythroughReport(input) {
|
|
|
1153
1370
|
}
|
|
1154
1371
|
return { ...base, status: "PASS", failsRun: false };
|
|
1155
1372
|
}
|
|
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
|
-
}
|
|
1373
|
+
|
|
1374
|
+
// src/react/react-playthrough-reporter.ts
|
|
1375
|
+
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
1181
1376
|
var ReactPlaythroughReporter = class {
|
|
1182
1377
|
/** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
|
|
1183
1378
|
constructor(projectRoot) {
|
|
@@ -1204,16 +1399,17 @@ var ReactPlaythroughReporter = class {
|
|
|
1204
1399
|
testModules,
|
|
1205
1400
|
this.projectRoot
|
|
1206
1401
|
);
|
|
1402
|
+
const moduleResults = testModules.map(
|
|
1403
|
+
(module) => toReactPlaythroughModuleResult(module, this.projectRoot)
|
|
1404
|
+
);
|
|
1207
1405
|
const report = assessReactPlaythroughReport({
|
|
1208
1406
|
expectedFile: this.expectedFile,
|
|
1209
1407
|
expectedFileExists: existsSync2(this.expectedModuleId),
|
|
1210
1408
|
expectedFileScheduled: this.expectedFileScheduled,
|
|
1211
1409
|
focusedSelection: this.focusedSelection,
|
|
1212
|
-
modules:
|
|
1213
|
-
(module) => toModuleResult(module, this.projectRoot)
|
|
1214
|
-
),
|
|
1410
|
+
modules: moduleResults,
|
|
1215
1411
|
unhandledErrors: extractFailureEntries(unhandledErrors).map(
|
|
1216
|
-
(entry) =>
|
|
1412
|
+
(entry) => firstFailureLine(entry.message) ?? entry.message
|
|
1217
1413
|
)
|
|
1218
1414
|
});
|
|
1219
1415
|
const alignment = assessProductTestAlignment(this.projectRoot);
|
|
@@ -1231,14 +1427,14 @@ var ReactPlaythroughReporter = class {
|
|
|
1231
1427
|
console.error(pendingOutput);
|
|
1232
1428
|
process.exitCode = 1;
|
|
1233
1429
|
}
|
|
1234
|
-
const summary = formatReactFailureSummary(
|
|
1235
|
-
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
1236
|
-
);
|
|
1430
|
+
const summary = formatReactFailureSummary(moduleResults);
|
|
1237
1431
|
if ((report.failsRun || alignment.status === "FAILED" || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
1238
1432
|
summary[0] = "TEST_RESULT: FAIL";
|
|
1239
1433
|
}
|
|
1240
|
-
console.log(
|
|
1241
|
-
|
|
1434
|
+
console.log(
|
|
1435
|
+
`
|
|
1436
|
+
${[...formatReactTestCountSummary(moduleResults), ...summary].join("\n")}`
|
|
1437
|
+
);
|
|
1242
1438
|
}
|
|
1243
1439
|
};
|
|
1244
1440
|
|