miaoda-game-devkit 0.6.1 → 0.6.3
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/index.js +11 -1
- package/dist/react/index.mjs +11 -1
- package/dist/react/testing.d.mts +19 -2
- package/dist/react/testing.d.ts +19 -2
- package/dist/react/testing.js +177 -31
- package/dist/react/testing.mjs +177 -31
- package/dist/react/vitest-config.js +342 -65
- package/dist/react/vitest-config.mjs +342 -65
- package/dist/react/vitest-setup.js +87 -4
- package/dist/react/vitest-setup.mjs +87 -4
- package/dist/rules/react-test-boundary-plugin.js +14 -1
- package/package.json +1 -1
|
@@ -13,14 +13,112 @@ import { render } from "@testing-library/react";
|
|
|
13
13
|
import userEvent from "@testing-library/user-event";
|
|
14
14
|
import { test } from "vitest";
|
|
15
15
|
|
|
16
|
+
// src/react/react-error-diagnostics.ts
|
|
17
|
+
var MAX_DIAGNOSTIC_LENGTH = 1e3;
|
|
18
|
+
function truncate(value) {
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
|
|
21
|
+
return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
|
|
22
|
+
}
|
|
23
|
+
function safeJson(value) {
|
|
24
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
25
|
+
try {
|
|
26
|
+
return JSON.stringify(value, (_key, nested) => {
|
|
27
|
+
if (typeof nested === "bigint") return `${nested}n`;
|
|
28
|
+
if (typeof nested === "function") {
|
|
29
|
+
return `Function<${nested.name || "anonymous"}>`;
|
|
30
|
+
}
|
|
31
|
+
if (typeof nested === "symbol") return nested.toString();
|
|
32
|
+
if (nested && typeof nested === "object") {
|
|
33
|
+
if (seen.has(nested)) return "[Circular]";
|
|
34
|
+
seen.add(nested);
|
|
35
|
+
}
|
|
36
|
+
return nested;
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function collectEntries(value, fallbackCode, seen) {
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
|
|
45
|
+
}
|
|
46
|
+
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
|
|
47
|
+
return [{ code: fallbackCode, message: String(value) }];
|
|
48
|
+
}
|
|
49
|
+
if (typeof value === "function") {
|
|
50
|
+
return [
|
|
51
|
+
{ code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
if (seen.has(value)) return [];
|
|
55
|
+
seen.add(value);
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
|
|
58
|
+
}
|
|
59
|
+
const record = value;
|
|
60
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
|
|
61
|
+
const entries = [];
|
|
62
|
+
if (typeof record.message === "string" && record.message.trim()) {
|
|
63
|
+
entries.push({ code, message: truncate(record.message) });
|
|
64
|
+
}
|
|
65
|
+
if (record.cause !== void 0) {
|
|
66
|
+
entries.push(...collectEntries(record.cause, fallbackCode, seen));
|
|
67
|
+
}
|
|
68
|
+
if (Array.isArray(record.errors)) {
|
|
69
|
+
entries.push(...collectEntries(record.errors, fallbackCode, seen));
|
|
70
|
+
}
|
|
71
|
+
if (entries.length > 0) return entries;
|
|
72
|
+
if (typeof record.stack === "string" && record.stack.trim()) {
|
|
73
|
+
return [{ code, message: truncate(record.stack) }];
|
|
74
|
+
}
|
|
75
|
+
const json = safeJson(value);
|
|
76
|
+
return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
|
|
77
|
+
}
|
|
78
|
+
function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
|
|
79
|
+
const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
|
|
80
|
+
const keys = /* @__PURE__ */ new Set();
|
|
81
|
+
return entries.filter((entry) => {
|
|
82
|
+
const key = `${entry.code}\0${entry.message}`;
|
|
83
|
+
if (keys.has(key)) return false;
|
|
84
|
+
keys.add(key);
|
|
85
|
+
return true;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
function createFailureDiagnostic(source, value) {
|
|
89
|
+
return { source, entries: extractFailureEntries(value) };
|
|
90
|
+
}
|
|
91
|
+
function appendCurrentAttemptFailures(current, runnerValue) {
|
|
92
|
+
const runner = createFailureDiagnostic("test-runtime", runnerValue);
|
|
93
|
+
if (!current || current.entries.length === 0) return runner;
|
|
94
|
+
const primary = current.entries[0];
|
|
95
|
+
const currentStart = runner.entries.findIndex(
|
|
96
|
+
(entry) => entry.code === primary.code && entry.message === primary.message
|
|
97
|
+
);
|
|
98
|
+
if (currentStart < 0) return current;
|
|
99
|
+
return {
|
|
100
|
+
source: current.source,
|
|
101
|
+
entries: extractFailureEntries([
|
|
102
|
+
...current.entries,
|
|
103
|
+
...runner.entries.slice(currentStart + 1)
|
|
104
|
+
])
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function codedError(code, message) {
|
|
108
|
+
const error = new Error(message);
|
|
109
|
+
error.code = code;
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
|
|
16
113
|
// src/react/react-playthrough-core.ts
|
|
17
114
|
import { act } from "@testing-library/react";
|
|
18
115
|
function throwIfAborted(signal) {
|
|
19
116
|
if (!signal?.aborted) return;
|
|
20
117
|
if (signal.reason instanceof Error) throw signal.reason;
|
|
21
|
-
throw
|
|
22
|
-
|
|
23
|
-
|
|
118
|
+
throw codedError(
|
|
119
|
+
"PLAYTHROUGH_CANCELLED",
|
|
120
|
+
`Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
|
|
121
|
+
);
|
|
24
122
|
}
|
|
25
123
|
function formatDiagnostics(read) {
|
|
26
124
|
if (!read) return void 0;
|
|
@@ -36,7 +134,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
|
|
|
36
134
|
if (waiverReason === void 0) return void 0;
|
|
37
135
|
const reason = waiverReason.trim();
|
|
38
136
|
if (reason.length < 20) {
|
|
39
|
-
throw
|
|
137
|
+
throw codedError(
|
|
138
|
+
"INVALID_PLAYTHROUGH_WAIVER",
|
|
40
139
|
"playthroughTest.skip reason must contain at least 20 characters."
|
|
41
140
|
);
|
|
42
141
|
}
|
|
@@ -45,7 +144,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
|
|
|
45
144
|
async function runBoundedUntil(condition, options = {}) {
|
|
46
145
|
const maxSteps = options.maxSteps ?? 120;
|
|
47
146
|
if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
|
|
48
|
-
throw
|
|
147
|
+
throw codedError(
|
|
148
|
+
"INVALID_STEP_BOUND",
|
|
49
149
|
"stepUntil maxSteps must be a safe integer between 0 and 10000."
|
|
50
150
|
);
|
|
51
151
|
}
|
|
@@ -62,7 +162,8 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
62
162
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
63
163
|
const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
|
|
64
164
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
65
|
-
throw
|
|
165
|
+
throw codedError(
|
|
166
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
|
|
66
167
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
67
168
|
);
|
|
68
169
|
}
|
|
@@ -81,6 +182,11 @@ var MIN_STAGES = 5;
|
|
|
81
182
|
var MIN_MILESTONES = 3;
|
|
82
183
|
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
83
184
|
var MAX_TRACE_LENGTH = 720;
|
|
185
|
+
function eventTargetsCanvas(event) {
|
|
186
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
187
|
+
if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
|
|
188
|
+
return event.target instanceof HTMLCanvasElement;
|
|
189
|
+
}
|
|
84
190
|
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
85
191
|
function truncateTraceValue(value, limit) {
|
|
86
192
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
@@ -110,14 +216,18 @@ function sampleObservedState(observe, stage) {
|
|
|
110
216
|
try {
|
|
111
217
|
value = observe();
|
|
112
218
|
} catch (error) {
|
|
113
|
-
throw
|
|
219
|
+
throw codedError(
|
|
220
|
+
"OBSERVE_FAILED",
|
|
221
|
+
`observe() threw at ${stage}: ${String(error)}`
|
|
222
|
+
);
|
|
114
223
|
}
|
|
115
224
|
try {
|
|
116
225
|
const fingerprint = JSON.stringify(value);
|
|
117
226
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
118
227
|
return { fingerprint, formatted: formatState(fingerprint) };
|
|
119
228
|
} catch {
|
|
120
|
-
throw
|
|
229
|
+
throw codedError(
|
|
230
|
+
"OBSERVE_NOT_SERIALIZABLE",
|
|
121
231
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
122
232
|
);
|
|
123
233
|
}
|
|
@@ -130,7 +240,7 @@ function createEvidence() {
|
|
|
130
240
|
return { domInputEvents: 0, stages: [], verified: false };
|
|
131
241
|
}
|
|
132
242
|
function createMetadata(waiverReason) {
|
|
133
|
-
return { version:
|
|
243
|
+
return { version: 5, waiverReason, evidence: createEvidence() };
|
|
134
244
|
}
|
|
135
245
|
function stageLabel(kind, name) {
|
|
136
246
|
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
@@ -166,6 +276,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
166
276
|
async ({ annotate, expect, onTestFailed, signal }) => {
|
|
167
277
|
metadata.evidence = createEvidence();
|
|
168
278
|
metadata.trace = void 0;
|
|
279
|
+
metadata.failure = void 0;
|
|
169
280
|
const evidence = metadata.evidence;
|
|
170
281
|
let entered = false;
|
|
171
282
|
let finished = false;
|
|
@@ -174,9 +285,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
174
285
|
let stepTrace;
|
|
175
286
|
let failureTraceFactory;
|
|
176
287
|
let acceptingStageInput = false;
|
|
288
|
+
let activeStageTargetedCanvas = false;
|
|
177
289
|
let inputCaptureAttached = false;
|
|
178
|
-
const recordInput = () => {
|
|
179
|
-
if (acceptingStageInput)
|
|
290
|
+
const recordInput = (event) => {
|
|
291
|
+
if (!acceptingStageInput) return;
|
|
292
|
+
evidence.domInputEvents += 1;
|
|
293
|
+
activeStageTargetedCanvas ||= eventTargetsCanvas(event);
|
|
180
294
|
};
|
|
181
295
|
const stopInputCapture = () => {
|
|
182
296
|
acceptingStageInput = false;
|
|
@@ -193,8 +307,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
193
307
|
return "stages=none";
|
|
194
308
|
}
|
|
195
309
|
};
|
|
196
|
-
onTestFailed(() => {
|
|
310
|
+
onTestFailed(({ task }) => {
|
|
197
311
|
metadata.trace ??= captureFailureTrace();
|
|
312
|
+
metadata.failure = appendCurrentAttemptFailures(
|
|
313
|
+
metadata.failure,
|
|
314
|
+
task.result?.errors ?? []
|
|
315
|
+
);
|
|
198
316
|
});
|
|
199
317
|
for (const event of INPUT_EVENTS) {
|
|
200
318
|
document.addEventListener(event, recordInput, true);
|
|
@@ -204,7 +322,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
204
322
|
try {
|
|
205
323
|
const view = render(element);
|
|
206
324
|
if (view.container.childNodes.length === 0) {
|
|
207
|
-
throw
|
|
325
|
+
throw codedError(
|
|
326
|
+
"PRODUCTION_ENTRY_NOT_RENDERED",
|
|
208
327
|
"playthroughTest must render the production game entry."
|
|
209
328
|
);
|
|
210
329
|
}
|
|
@@ -233,29 +352,42 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
233
352
|
const executeStage = async (name, kind, stage) => {
|
|
234
353
|
const normalizedName = name.trim();
|
|
235
354
|
if (normalizedName.length === 0) {
|
|
236
|
-
throw
|
|
355
|
+
throw codedError(
|
|
356
|
+
"INVALID_STAGE_NAME",
|
|
357
|
+
"playthrough stage names must be non-empty strings."
|
|
358
|
+
);
|
|
237
359
|
}
|
|
238
360
|
if (evidence.stages.some(
|
|
239
361
|
(completed) => completed.name === normalizedName
|
|
240
362
|
)) {
|
|
241
|
-
throw
|
|
363
|
+
throw codedError(
|
|
364
|
+
"DUPLICATE_STAGE_NAME",
|
|
242
365
|
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
243
366
|
);
|
|
244
367
|
}
|
|
245
368
|
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
246
|
-
throw
|
|
369
|
+
throw codedError(
|
|
370
|
+
"RESERVED_STAGE_NAME",
|
|
247
371
|
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
248
372
|
);
|
|
249
373
|
}
|
|
250
374
|
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
251
375
|
activeStage = { name: normalizedName, kind, before };
|
|
252
376
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
377
|
+
if (stage.step && !playthroughOptions?.observe) {
|
|
378
|
+
throw codedError(
|
|
379
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
|
|
380
|
+
`${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.`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
253
383
|
if (stage.until()) {
|
|
254
|
-
throw
|
|
384
|
+
throw codedError(
|
|
385
|
+
"STAGE_OUTCOME_ALREADY_REACHED",
|
|
255
386
|
`${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
387
|
);
|
|
257
388
|
}
|
|
258
389
|
const inputsBefore = evidence.domInputEvents;
|
|
390
|
+
activeStageTargetedCanvas = false;
|
|
259
391
|
if (stage.act) {
|
|
260
392
|
acceptingStageInput = true;
|
|
261
393
|
try {
|
|
@@ -264,10 +396,17 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
264
396
|
acceptingStageInput = false;
|
|
265
397
|
}
|
|
266
398
|
if (evidence.domInputEvents === inputsBefore) {
|
|
267
|
-
throw
|
|
399
|
+
throw codedError(
|
|
400
|
+
"PRODUCTION_INPUT_NOT_DISPATCHED",
|
|
268
401
|
`${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
402
|
);
|
|
270
403
|
}
|
|
404
|
+
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
405
|
+
throw codedError(
|
|
406
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
|
|
407
|
+
`${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.`
|
|
408
|
+
);
|
|
409
|
+
}
|
|
271
410
|
}
|
|
272
411
|
let advancedSteps = 0;
|
|
273
412
|
const stepBound = stage.maxSteps ?? 120;
|
|
@@ -283,7 +422,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
283
422
|
});
|
|
284
423
|
stepTrace = { bound: stepBound, completed: steps };
|
|
285
424
|
if (!stage.act && advancedSteps === 0) {
|
|
286
|
-
throw
|
|
425
|
+
throw codedError(
|
|
426
|
+
"AUTONOMOUS_STAGE_NOT_ADVANCED",
|
|
287
427
|
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
288
428
|
);
|
|
289
429
|
}
|
|
@@ -291,14 +431,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
291
431
|
await stage.assert({ expect, user, view });
|
|
292
432
|
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
293
433
|
if (assertions === 0) {
|
|
294
|
-
throw
|
|
434
|
+
throw codedError(
|
|
435
|
+
"STAGE_ASSERTION_MISSING",
|
|
295
436
|
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
296
437
|
);
|
|
297
438
|
}
|
|
298
439
|
const after = sampleState(`after ${normalizedName}`);
|
|
299
440
|
if (after.fingerprint === before.fingerprint) {
|
|
300
441
|
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
301
|
-
throw
|
|
442
|
+
throw codedError(
|
|
443
|
+
"STAGE_STATE_UNCHANGED",
|
|
302
444
|
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
303
445
|
);
|
|
304
446
|
}
|
|
@@ -320,27 +462,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
320
462
|
user,
|
|
321
463
|
async enter(stage) {
|
|
322
464
|
if (entered) {
|
|
323
|
-
throw
|
|
465
|
+
throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
|
|
324
466
|
}
|
|
325
467
|
if (evidence.stages.length > 0) {
|
|
326
|
-
throw
|
|
468
|
+
throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
|
|
327
469
|
}
|
|
328
470
|
await executeStage("entered", "entered", stage);
|
|
329
471
|
entered = true;
|
|
330
472
|
},
|
|
331
473
|
async milestone(name, stage) {
|
|
332
474
|
if (!entered) {
|
|
333
|
-
throw
|
|
475
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
|
|
334
476
|
}
|
|
335
477
|
if (finished) {
|
|
336
|
-
throw
|
|
478
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
|
|
337
479
|
}
|
|
338
480
|
await executeStage(name, "milestone", stage);
|
|
339
481
|
},
|
|
340
482
|
async finish(name, stage) {
|
|
341
|
-
if (!entered) throw
|
|
483
|
+
if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
|
|
342
484
|
if (finished) {
|
|
343
|
-
throw
|
|
485
|
+
throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
|
|
344
486
|
}
|
|
345
487
|
await executeStage(name, stage.kind, stage);
|
|
346
488
|
finished = true;
|
|
@@ -349,19 +491,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
349
491
|
const milestones = evidence.stages.filter(
|
|
350
492
|
(stage) => stage.kind === "milestone"
|
|
351
493
|
);
|
|
352
|
-
if (!entered) throw
|
|
494
|
+
if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
|
|
353
495
|
if (milestones.length < MIN_MILESTONES) {
|
|
354
|
-
throw
|
|
496
|
+
throw codedError(
|
|
497
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
355
498
|
`playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
|
|
356
499
|
);
|
|
357
500
|
}
|
|
358
501
|
if (!finished) {
|
|
359
|
-
throw
|
|
502
|
+
throw codedError(
|
|
503
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
360
504
|
'playthroughTest must call finish with kind "progress" or "terminal".'
|
|
361
505
|
);
|
|
362
506
|
}
|
|
363
507
|
if (evidence.stages.length < MIN_STAGES) {
|
|
364
|
-
throw
|
|
508
|
+
throw codedError(
|
|
509
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
365
510
|
`playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
|
|
366
511
|
);
|
|
367
512
|
}
|
|
@@ -369,6 +514,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
369
514
|
} catch (error) {
|
|
370
515
|
const trace = captureFailureTrace();
|
|
371
516
|
metadata.trace = trace;
|
|
517
|
+
metadata.failure = createFailureDiagnostic("playthrough", error);
|
|
372
518
|
try {
|
|
373
519
|
await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
374
520
|
} catch {
|
|
@@ -447,10 +593,18 @@ function auditReactPlaythroughRun(tests) {
|
|
|
447
593
|
|
|
448
594
|
// src/react/react-playthrough-reporter.ts
|
|
449
595
|
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
596
|
+
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
597
|
function isMetadata(value) {
|
|
451
598
|
if (!value || typeof value !== "object") return false;
|
|
452
599
|
const metadata = value;
|
|
453
|
-
if (metadata.version !==
|
|
600
|
+
if (metadata.version !== 5) return false;
|
|
601
|
+
if (metadata.failure !== void 0) {
|
|
602
|
+
if (!metadata.failure || typeof metadata.failure !== "object" || typeof metadata.failure.source !== "string" || !Array.isArray(metadata.failure.entries) || !metadata.failure.entries.every(
|
|
603
|
+
(entry) => Boolean(entry) && typeof entry === "object" && typeof entry.code === "string" && typeof entry.message === "string"
|
|
604
|
+
)) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
454
608
|
const evidence = metadata.evidence;
|
|
455
609
|
if (!evidence || typeof evidence !== "object") return false;
|
|
456
610
|
return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
|
|
@@ -465,10 +619,72 @@ function toAuditInput(test2) {
|
|
|
465
619
|
metadata: isMetadata(metadata) ? metadata : void 0
|
|
466
620
|
};
|
|
467
621
|
}
|
|
622
|
+
function findPendingProductTests(modules, projectRoot) {
|
|
623
|
+
return modules.flatMap((module) => {
|
|
624
|
+
const file = relative(projectRoot, module.moduleId).replaceAll("\\", "/");
|
|
625
|
+
if (file.split("/").includes("examples")) return [];
|
|
626
|
+
const tests = [...module.children.allTests()];
|
|
627
|
+
const pending = tests.filter((test2) => {
|
|
628
|
+
const mode = test2.options.mode;
|
|
629
|
+
if (mode !== "todo" && mode !== "skip") return false;
|
|
630
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
631
|
+
const approvedWaiver = mode === "skip" && isMetadata(metadata) && Boolean(metadata.waiverReason);
|
|
632
|
+
return !approvedWaiver;
|
|
633
|
+
});
|
|
634
|
+
const fileOnlyContainsPendingTests = tests.length > 0 && pending.length === tests.length;
|
|
635
|
+
return pending.map((test2) => ({
|
|
636
|
+
file,
|
|
637
|
+
test: test2.fullName,
|
|
638
|
+
mode: test2.options.mode,
|
|
639
|
+
fileOnlyContainsPendingTests
|
|
640
|
+
}));
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
function formatPendingProductTestReport(pending) {
|
|
644
|
+
if (pending.length === 0) return void 0;
|
|
645
|
+
const lines = [
|
|
646
|
+
"REACT_FOCUSED_TESTS: FAILED",
|
|
647
|
+
"CAUSE_CODE: TODO_OR_SKIP_TESTS",
|
|
648
|
+
"CAUSE: Product tests still contain explicit todo/skip cases."
|
|
649
|
+
];
|
|
650
|
+
const reportedIncompleteFiles = /* @__PURE__ */ new Set();
|
|
651
|
+
for (const item of pending) {
|
|
652
|
+
if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
|
|
653
|
+
lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
|
|
654
|
+
lines.push(
|
|
655
|
+
`FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
|
|
656
|
+
);
|
|
657
|
+
reportedIncompleteFiles.add(item.file);
|
|
658
|
+
}
|
|
659
|
+
lines.push(`FILE: ${item.file}`);
|
|
660
|
+
lines.push(`TEST: ${item.test}`);
|
|
661
|
+
lines.push(`MODE: ${item.mode}`);
|
|
662
|
+
}
|
|
663
|
+
lines.push(
|
|
664
|
+
"NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
|
|
665
|
+
);
|
|
666
|
+
return `
|
|
667
|
+
${lines.join("\n")}`;
|
|
668
|
+
}
|
|
468
669
|
function firstLine(value) {
|
|
469
670
|
if (typeof value !== "string") return void 0;
|
|
470
671
|
return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
471
672
|
}
|
|
673
|
+
function selectReactFailure(values, errorRecordCount = values.length) {
|
|
674
|
+
const entries = extractFailureEntries(values);
|
|
675
|
+
if (entries.length === 0) {
|
|
676
|
+
return {
|
|
677
|
+
code: "MISSING_FAILURE_DETAILS",
|
|
678
|
+
cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
|
|
679
|
+
rawCause: "",
|
|
680
|
+
related: []
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const primary = entries[0];
|
|
684
|
+
const rawCause = primary.message;
|
|
685
|
+
const cause = firstLine(rawCause) ?? rawCause;
|
|
686
|
+
return { code: primary.code, cause, rawCause, related: entries.slice(1) };
|
|
687
|
+
}
|
|
472
688
|
function failureHint(value) {
|
|
473
689
|
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
474
690
|
(match) => match[1]
|
|
@@ -502,30 +718,45 @@ function truncateReporterLine(value, limit) {
|
|
|
502
718
|
}
|
|
503
719
|
function toModuleResult(module, projectRoot) {
|
|
504
720
|
const tests = [...module.children.allTests()];
|
|
505
|
-
const
|
|
721
|
+
const moduleFailure = selectReactFailure(module.errors(), module.errors().length);
|
|
722
|
+
const moduleErrors = extractFailureEntries(module.errors()).map(
|
|
723
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
724
|
+
);
|
|
506
725
|
const errors = [
|
|
507
726
|
...moduleErrors,
|
|
508
727
|
...tests.flatMap(
|
|
509
|
-
(test2) => (test2.result().errors ?? []).map(
|
|
728
|
+
(test2) => extractFailureEntries(test2.result().errors ?? []).map(
|
|
729
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
730
|
+
)
|
|
510
731
|
)
|
|
511
|
-
]
|
|
732
|
+
];
|
|
512
733
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
513
|
-
const
|
|
734
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
735
|
+
const testErrors = test2.result().errors ?? [];
|
|
736
|
+
const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
|
|
737
|
+
const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
|
|
738
|
+
const selected = selectReactFailure(
|
|
739
|
+
[...attemptErrors, ...module.errors()],
|
|
740
|
+
testErrors.length + module.errors().length
|
|
741
|
+
);
|
|
514
742
|
return {
|
|
515
743
|
test: test2.fullName,
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
744
|
+
causeCode: selected.code,
|
|
745
|
+
cause: selected.cause,
|
|
746
|
+
related: selected.related,
|
|
747
|
+
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
|
|
748
|
+
hint: failureHint(selected.rawCause),
|
|
519
749
|
trace: failureTrace(test2)
|
|
520
750
|
};
|
|
521
751
|
});
|
|
522
752
|
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
523
|
-
const raw = module.errors()[0]?.message ?? "Module failed to load";
|
|
524
753
|
failures.push({
|
|
525
754
|
test: "<collection>",
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
755
|
+
causeCode: moduleFailure.code,
|
|
756
|
+
cause: moduleFailure.cause,
|
|
757
|
+
related: moduleFailure.related,
|
|
758
|
+
location: errorLocation(moduleFailure.rawCause),
|
|
759
|
+
hint: failureHint(moduleFailure.rawCause)
|
|
529
760
|
});
|
|
530
761
|
}
|
|
531
762
|
return {
|
|
@@ -533,6 +764,8 @@ function toModuleResult(module, projectRoot) {
|
|
|
533
764
|
state: module.state(),
|
|
534
765
|
errors,
|
|
535
766
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
767
|
+
primaryCauseCode: failures[0]?.causeCode,
|
|
768
|
+
relatedErrors: failures[0]?.related,
|
|
536
769
|
tests: tests.map(toAuditInput),
|
|
537
770
|
failures
|
|
538
771
|
};
|
|
@@ -549,7 +782,14 @@ function formatReactFailureSummary(modules) {
|
|
|
549
782
|
for (const [index, failure] of failures.entries()) {
|
|
550
783
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
551
784
|
lines.push(`TEST: ${failure.test}`);
|
|
785
|
+
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
552
786
|
lines.push(`CAUSE: ${failure.cause}`);
|
|
787
|
+
for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
|
|
788
|
+
lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
|
|
789
|
+
lines.push(
|
|
790
|
+
`RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
|
|
791
|
+
);
|
|
792
|
+
}
|
|
553
793
|
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
554
794
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
555
795
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
@@ -557,26 +797,36 @@ function formatReactFailureSummary(modules) {
|
|
|
557
797
|
lines.push("TEST_RESULT: FAIL");
|
|
558
798
|
return lines;
|
|
559
799
|
}
|
|
560
|
-
function repairGuidance(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
800
|
+
function repairGuidance(code) {
|
|
801
|
+
switch (code) {
|
|
802
|
+
case "GAME_SNAPSHOT_REFERENCE_REUSED":
|
|
803
|
+
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.";
|
|
804
|
+
case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP":
|
|
805
|
+
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.";
|
|
806
|
+
case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS":
|
|
807
|
+
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.";
|
|
808
|
+
case "STAGE_OUTCOME_ALREADY_REACHED":
|
|
809
|
+
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.";
|
|
810
|
+
case "STAGE_STATE_UNCHANGED":
|
|
811
|
+
return "The stage ran and asserted, but its observable state matched the previous milestone. Preserve the intended gameplay result and observe the same production Controller rendered by <App />. Do not substitute arbitrary labels or a weaker state change.";
|
|
812
|
+
case "PLAYTHROUGH_BOUND_EXHAUSTED":
|
|
813
|
+
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.";
|
|
814
|
+
case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
|
|
815
|
+
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.";
|
|
816
|
+
case "INVALID_STAGE_ORDER":
|
|
817
|
+
case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
|
|
818
|
+
case "INVALID_STAGE_NAME":
|
|
819
|
+
case "DUPLICATE_STAGE_NAME":
|
|
820
|
+
case "RESERVED_STAGE_NAME":
|
|
821
|
+
case "PRODUCTION_INPUT_NOT_DISPATCHED":
|
|
822
|
+
case "AUTONOMOUS_STAGE_NOT_ADVANCED":
|
|
823
|
+
case "STAGE_ASSERTION_MISSING":
|
|
824
|
+
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.";
|
|
825
|
+
case "MISSING_FAILURE_DETAILS":
|
|
826
|
+
return "Vitest reported a failed task without a readable serialized error. Inspect the RELATED records and rerun the focused file with the verbose reporter if no details are present.";
|
|
827
|
+
default:
|
|
828
|
+
return "Fix the first reported CAUSE, then rerun the same test. RELATED entries preserve the remaining Vitest errors in their original order.";
|
|
575
829
|
}
|
|
576
|
-
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
577
|
-
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.";
|
|
578
|
-
}
|
|
579
|
-
return "Start from the production entry and describe the real game as enter, named milestones, and finish. Enter must act through production input; later stages may act or step. Every stage waits for and asserts a new player-visible or authoritative result. Do not jump to an internal level or mutate gameplay state.";
|
|
580
830
|
}
|
|
581
831
|
function assessReactPlaythroughReport(input) {
|
|
582
832
|
const base = { file: input.expectedFile };
|
|
@@ -592,6 +842,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
592
842
|
return {
|
|
593
843
|
...base,
|
|
594
844
|
status: "FAILED",
|
|
845
|
+
causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
|
|
595
846
|
cause: "The required production playthrough test file does not exist.",
|
|
596
847
|
next: "Create the file and render <App />. Compose one real-input enter stage, at least three named gameplay milestones, and one finish stage. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result.",
|
|
597
848
|
failsRun: true
|
|
@@ -615,6 +866,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
615
866
|
return {
|
|
616
867
|
...base,
|
|
617
868
|
status: "NOT_RUN",
|
|
869
|
+
causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
|
|
618
870
|
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
619
871
|
next: "Fix the first Vitest syntax, import, environment, or collection error shown above, then run pnpm test again. Do not use skip to hide a load failure.",
|
|
620
872
|
failsRun: true
|
|
@@ -627,8 +879,12 @@ function assessReactPlaythroughReport(input) {
|
|
|
627
879
|
return {
|
|
628
880
|
...base,
|
|
629
881
|
status: "FAILED",
|
|
882
|
+
causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
630
883
|
cause,
|
|
631
|
-
|
|
884
|
+
related: productionModule.relatedErrors,
|
|
885
|
+
next: repairGuidance(
|
|
886
|
+
productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
|
|
887
|
+
),
|
|
632
888
|
failsRun: true
|
|
633
889
|
};
|
|
634
890
|
}
|
|
@@ -644,11 +900,21 @@ function assessReactPlaythroughReport(input) {
|
|
|
644
900
|
}
|
|
645
901
|
function formatReactPlaythroughReport(report) {
|
|
646
902
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
903
|
+
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
647
904
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
905
|
+
for (const [index, related] of (report.related ?? []).entries()) {
|
|
906
|
+
lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
|
|
907
|
+
lines.push(
|
|
908
|
+
`RELATED_${index + 1}: ${firstLine(related.message) ?? related.message}`
|
|
909
|
+
);
|
|
910
|
+
}
|
|
648
911
|
if (report.waiverReasons?.length) {
|
|
649
912
|
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
650
913
|
}
|
|
651
914
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
915
|
+
if (report.status === "FAILED") {
|
|
916
|
+
lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
|
|
917
|
+
}
|
|
652
918
|
return `
|
|
653
919
|
${lines.join("\n")}`;
|
|
654
920
|
}
|
|
@@ -674,6 +940,10 @@ var ReactPlaythroughReporter = class {
|
|
|
674
940
|
}
|
|
675
941
|
/** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
|
|
676
942
|
onTestRunEnd(testModules, unhandledErrors) {
|
|
943
|
+
const pendingProductTests = findPendingProductTests(
|
|
944
|
+
testModules,
|
|
945
|
+
this.projectRoot
|
|
946
|
+
);
|
|
677
947
|
const report = assessReactPlaythroughReport({
|
|
678
948
|
expectedFile: this.expectedFile,
|
|
679
949
|
expectedFileExists: existsSync(this.expectedModuleId),
|
|
@@ -682,7 +952,9 @@ var ReactPlaythroughReporter = class {
|
|
|
682
952
|
modules: testModules.map(
|
|
683
953
|
(module) => toModuleResult(module, this.projectRoot)
|
|
684
954
|
),
|
|
685
|
-
unhandledErrors: unhandledErrors.map(
|
|
955
|
+
unhandledErrors: extractFailureEntries(unhandledErrors).map(
|
|
956
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
957
|
+
)
|
|
686
958
|
});
|
|
687
959
|
const output = formatReactPlaythroughReport(report);
|
|
688
960
|
if (report.failsRun) {
|
|
@@ -693,10 +965,15 @@ var ReactPlaythroughReporter = class {
|
|
|
693
965
|
} else {
|
|
694
966
|
console.log(output);
|
|
695
967
|
}
|
|
968
|
+
const pendingOutput = formatPendingProductTestReport(pendingProductTests);
|
|
969
|
+
if (pendingOutput) {
|
|
970
|
+
console.error(pendingOutput);
|
|
971
|
+
process.exitCode = 1;
|
|
972
|
+
}
|
|
696
973
|
const summary = formatReactFailureSummary(
|
|
697
974
|
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
698
975
|
);
|
|
699
|
-
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
976
|
+
if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
700
977
|
summary[0] = "TEST_RESULT: FAIL";
|
|
701
978
|
}
|
|
702
979
|
console.log(`
|