miaoda-game-devkit 0.6.2 → 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/react/index.js +11 -1
- package/dist/react/index.mjs +11 -1
- package/dist/react/testing.d.mts +11 -1
- package/dist/react/testing.d.ts +11 -1
- package/dist/react/testing.js +158 -31
- package/dist/react/testing.mjs +158 -31
- package/dist/react/vitest-config.js +319 -71
- package/dist/react/vitest-config.mjs +319 -71
- package/dist/react/vitest-setup.js +87 -4
- package/dist/react/vitest-setup.mjs +87 -4
- 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
|
}
|
|
@@ -115,14 +216,18 @@ function sampleObservedState(observe, stage) {
|
|
|
115
216
|
try {
|
|
116
217
|
value = observe();
|
|
117
218
|
} catch (error) {
|
|
118
|
-
throw
|
|
219
|
+
throw codedError(
|
|
220
|
+
"OBSERVE_FAILED",
|
|
221
|
+
`observe() threw at ${stage}: ${String(error)}`
|
|
222
|
+
);
|
|
119
223
|
}
|
|
120
224
|
try {
|
|
121
225
|
const fingerprint = JSON.stringify(value);
|
|
122
226
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
123
227
|
return { fingerprint, formatted: formatState(fingerprint) };
|
|
124
228
|
} catch {
|
|
125
|
-
throw
|
|
229
|
+
throw codedError(
|
|
230
|
+
"OBSERVE_NOT_SERIALIZABLE",
|
|
126
231
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
127
232
|
);
|
|
128
233
|
}
|
|
@@ -135,7 +240,7 @@ function createEvidence() {
|
|
|
135
240
|
return { domInputEvents: 0, stages: [], verified: false };
|
|
136
241
|
}
|
|
137
242
|
function createMetadata(waiverReason) {
|
|
138
|
-
return { version:
|
|
243
|
+
return { version: 5, waiverReason, evidence: createEvidence() };
|
|
139
244
|
}
|
|
140
245
|
function stageLabel(kind, name) {
|
|
141
246
|
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
@@ -171,6 +276,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
171
276
|
async ({ annotate, expect, onTestFailed, signal }) => {
|
|
172
277
|
metadata.evidence = createEvidence();
|
|
173
278
|
metadata.trace = void 0;
|
|
279
|
+
metadata.failure = void 0;
|
|
174
280
|
const evidence = metadata.evidence;
|
|
175
281
|
let entered = false;
|
|
176
282
|
let finished = false;
|
|
@@ -201,8 +307,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
201
307
|
return "stages=none";
|
|
202
308
|
}
|
|
203
309
|
};
|
|
204
|
-
onTestFailed(() => {
|
|
310
|
+
onTestFailed(({ task }) => {
|
|
205
311
|
metadata.trace ??= captureFailureTrace();
|
|
312
|
+
metadata.failure = appendCurrentAttemptFailures(
|
|
313
|
+
metadata.failure,
|
|
314
|
+
task.result?.errors ?? []
|
|
315
|
+
);
|
|
206
316
|
});
|
|
207
317
|
for (const event of INPUT_EVENTS) {
|
|
208
318
|
document.addEventListener(event, recordInput, true);
|
|
@@ -212,7 +322,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
212
322
|
try {
|
|
213
323
|
const view = render(element);
|
|
214
324
|
if (view.container.childNodes.length === 0) {
|
|
215
|
-
throw
|
|
325
|
+
throw codedError(
|
|
326
|
+
"PRODUCTION_ENTRY_NOT_RENDERED",
|
|
216
327
|
"playthroughTest must render the production game entry."
|
|
217
328
|
);
|
|
218
329
|
}
|
|
@@ -241,17 +352,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
241
352
|
const executeStage = async (name, kind, stage) => {
|
|
242
353
|
const normalizedName = name.trim();
|
|
243
354
|
if (normalizedName.length === 0) {
|
|
244
|
-
throw
|
|
355
|
+
throw codedError(
|
|
356
|
+
"INVALID_STAGE_NAME",
|
|
357
|
+
"playthrough stage names must be non-empty strings."
|
|
358
|
+
);
|
|
245
359
|
}
|
|
246
360
|
if (evidence.stages.some(
|
|
247
361
|
(completed) => completed.name === normalizedName
|
|
248
362
|
)) {
|
|
249
|
-
throw
|
|
363
|
+
throw codedError(
|
|
364
|
+
"DUPLICATE_STAGE_NAME",
|
|
250
365
|
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
251
366
|
);
|
|
252
367
|
}
|
|
253
368
|
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
254
|
-
throw
|
|
369
|
+
throw codedError(
|
|
370
|
+
"RESERVED_STAGE_NAME",
|
|
255
371
|
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
256
372
|
);
|
|
257
373
|
}
|
|
@@ -259,12 +375,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
259
375
|
activeStage = { name: normalizedName, kind, before };
|
|
260
376
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
261
377
|
if (stage.step && !playthroughOptions?.observe) {
|
|
262
|
-
throw
|
|
378
|
+
throw codedError(
|
|
379
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
|
|
263
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.`
|
|
264
381
|
);
|
|
265
382
|
}
|
|
266
383
|
if (stage.until()) {
|
|
267
|
-
throw
|
|
384
|
+
throw codedError(
|
|
385
|
+
"STAGE_OUTCOME_ALREADY_REACHED",
|
|
268
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.`
|
|
269
387
|
);
|
|
270
388
|
}
|
|
@@ -278,12 +396,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
278
396
|
acceptingStageInput = false;
|
|
279
397
|
}
|
|
280
398
|
if (evidence.domInputEvents === inputsBefore) {
|
|
281
|
-
throw
|
|
399
|
+
throw codedError(
|
|
400
|
+
"PRODUCTION_INPUT_NOT_DISPATCHED",
|
|
282
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.`
|
|
283
402
|
);
|
|
284
403
|
}
|
|
285
404
|
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
286
|
-
throw
|
|
405
|
+
throw codedError(
|
|
406
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
|
|
287
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.`
|
|
288
408
|
);
|
|
289
409
|
}
|
|
@@ -302,7 +422,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
302
422
|
});
|
|
303
423
|
stepTrace = { bound: stepBound, completed: steps };
|
|
304
424
|
if (!stage.act && advancedSteps === 0) {
|
|
305
|
-
throw
|
|
425
|
+
throw codedError(
|
|
426
|
+
"AUTONOMOUS_STAGE_NOT_ADVANCED",
|
|
306
427
|
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
307
428
|
);
|
|
308
429
|
}
|
|
@@ -310,14 +431,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
310
431
|
await stage.assert({ expect, user, view });
|
|
311
432
|
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
312
433
|
if (assertions === 0) {
|
|
313
|
-
throw
|
|
434
|
+
throw codedError(
|
|
435
|
+
"STAGE_ASSERTION_MISSING",
|
|
314
436
|
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
315
437
|
);
|
|
316
438
|
}
|
|
317
439
|
const after = sampleState(`after ${normalizedName}`);
|
|
318
440
|
if (after.fingerprint === before.fingerprint) {
|
|
319
441
|
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
320
|
-
throw
|
|
442
|
+
throw codedError(
|
|
443
|
+
"STAGE_STATE_UNCHANGED",
|
|
321
444
|
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
322
445
|
);
|
|
323
446
|
}
|
|
@@ -339,27 +462,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
339
462
|
user,
|
|
340
463
|
async enter(stage) {
|
|
341
464
|
if (entered) {
|
|
342
|
-
throw
|
|
465
|
+
throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
|
|
343
466
|
}
|
|
344
467
|
if (evidence.stages.length > 0) {
|
|
345
|
-
throw
|
|
468
|
+
throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
|
|
346
469
|
}
|
|
347
470
|
await executeStage("entered", "entered", stage);
|
|
348
471
|
entered = true;
|
|
349
472
|
},
|
|
350
473
|
async milestone(name, stage) {
|
|
351
474
|
if (!entered) {
|
|
352
|
-
throw
|
|
475
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
|
|
353
476
|
}
|
|
354
477
|
if (finished) {
|
|
355
|
-
throw
|
|
478
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
|
|
356
479
|
}
|
|
357
480
|
await executeStage(name, "milestone", stage);
|
|
358
481
|
},
|
|
359
482
|
async finish(name, stage) {
|
|
360
|
-
if (!entered) throw
|
|
483
|
+
if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
|
|
361
484
|
if (finished) {
|
|
362
|
-
throw
|
|
485
|
+
throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
|
|
363
486
|
}
|
|
364
487
|
await executeStage(name, stage.kind, stage);
|
|
365
488
|
finished = true;
|
|
@@ -368,19 +491,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
368
491
|
const milestones = evidence.stages.filter(
|
|
369
492
|
(stage) => stage.kind === "milestone"
|
|
370
493
|
);
|
|
371
|
-
if (!entered) throw
|
|
494
|
+
if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
|
|
372
495
|
if (milestones.length < MIN_MILESTONES) {
|
|
373
|
-
throw
|
|
496
|
+
throw codedError(
|
|
497
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
374
498
|
`playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
|
|
375
499
|
);
|
|
376
500
|
}
|
|
377
501
|
if (!finished) {
|
|
378
|
-
throw
|
|
502
|
+
throw codedError(
|
|
503
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
379
504
|
'playthroughTest must call finish with kind "progress" or "terminal".'
|
|
380
505
|
);
|
|
381
506
|
}
|
|
382
507
|
if (evidence.stages.length < MIN_STAGES) {
|
|
383
|
-
throw
|
|
508
|
+
throw codedError(
|
|
509
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
384
510
|
`playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
|
|
385
511
|
);
|
|
386
512
|
}
|
|
@@ -388,6 +514,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
388
514
|
} catch (error) {
|
|
389
515
|
const trace = captureFailureTrace();
|
|
390
516
|
metadata.trace = trace;
|
|
517
|
+
metadata.failure = createFailureDiagnostic("playthrough", error);
|
|
391
518
|
try {
|
|
392
519
|
await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
393
520
|
} catch {
|
|
@@ -470,7 +597,14 @@ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the product
|
|
|
470
597
|
function isMetadata(value) {
|
|
471
598
|
if (!value || typeof value !== "object") return false;
|
|
472
599
|
const metadata = value;
|
|
473
|
-
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
|
+
}
|
|
474
608
|
const evidence = metadata.evidence;
|
|
475
609
|
if (!evidence || typeof evidence !== "object") return false;
|
|
476
610
|
return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
|
|
@@ -485,10 +619,72 @@ function toAuditInput(test2) {
|
|
|
485
619
|
metadata: isMetadata(metadata) ? metadata : void 0
|
|
486
620
|
};
|
|
487
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
|
+
}
|
|
488
669
|
function firstLine(value) {
|
|
489
670
|
if (typeof value !== "string") return void 0;
|
|
490
671
|
return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
491
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
|
+
}
|
|
492
688
|
function failureHint(value) {
|
|
493
689
|
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
494
690
|
(match) => match[1]
|
|
@@ -522,30 +718,45 @@ function truncateReporterLine(value, limit) {
|
|
|
522
718
|
}
|
|
523
719
|
function toModuleResult(module, projectRoot) {
|
|
524
720
|
const tests = [...module.children.allTests()];
|
|
525
|
-
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
|
+
);
|
|
526
725
|
const errors = [
|
|
527
726
|
...moduleErrors,
|
|
528
727
|
...tests.flatMap(
|
|
529
|
-
(test2) => (test2.result().errors ?? []).map(
|
|
728
|
+
(test2) => extractFailureEntries(test2.result().errors ?? []).map(
|
|
729
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
730
|
+
)
|
|
530
731
|
)
|
|
531
|
-
]
|
|
732
|
+
];
|
|
532
733
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
533
|
-
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
|
+
);
|
|
534
742
|
return {
|
|
535
743
|
test: test2.fullName,
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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),
|
|
539
749
|
trace: failureTrace(test2)
|
|
540
750
|
};
|
|
541
751
|
});
|
|
542
752
|
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
543
|
-
const raw = module.errors()[0]?.message ?? "Module failed to load";
|
|
544
753
|
failures.push({
|
|
545
754
|
test: "<collection>",
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
755
|
+
causeCode: moduleFailure.code,
|
|
756
|
+
cause: moduleFailure.cause,
|
|
757
|
+
related: moduleFailure.related,
|
|
758
|
+
location: errorLocation(moduleFailure.rawCause),
|
|
759
|
+
hint: failureHint(moduleFailure.rawCause)
|
|
549
760
|
});
|
|
550
761
|
}
|
|
551
762
|
return {
|
|
@@ -553,6 +764,8 @@ function toModuleResult(module, projectRoot) {
|
|
|
553
764
|
state: module.state(),
|
|
554
765
|
errors,
|
|
555
766
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
767
|
+
primaryCauseCode: failures[0]?.causeCode,
|
|
768
|
+
relatedErrors: failures[0]?.related,
|
|
556
769
|
tests: tests.map(toAuditInput),
|
|
557
770
|
failures
|
|
558
771
|
};
|
|
@@ -569,7 +782,14 @@ function formatReactFailureSummary(modules) {
|
|
|
569
782
|
for (const [index, failure] of failures.entries()) {
|
|
570
783
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
571
784
|
lines.push(`TEST: ${failure.test}`);
|
|
785
|
+
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
572
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
|
+
}
|
|
573
793
|
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
574
794
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
575
795
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
@@ -577,32 +797,36 @@ function formatReactFailureSummary(modules) {
|
|
|
577
797
|
lines.push("TEST_RESULT: FAIL");
|
|
578
798
|
return lines;
|
|
579
799
|
}
|
|
580
|
-
function repairGuidance(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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.";
|
|
598
829
|
}
|
|
599
|
-
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
600
|
-
return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
|
|
601
|
-
}
|
|
602
|
-
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
603
|
-
return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
|
|
604
|
-
}
|
|
605
|
-
return "Start from the production entry and describe the real game as enter, named milestones, and finish. Enter must act through production input; later stages may act or step. Every stage waits for and asserts a new player-visible or authoritative result. Do not jump to an internal level or mutate gameplay state.";
|
|
606
830
|
}
|
|
607
831
|
function assessReactPlaythroughReport(input) {
|
|
608
832
|
const base = { file: input.expectedFile };
|
|
@@ -618,6 +842,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
618
842
|
return {
|
|
619
843
|
...base,
|
|
620
844
|
status: "FAILED",
|
|
845
|
+
causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
|
|
621
846
|
cause: "The required production playthrough test file does not exist.",
|
|
622
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.",
|
|
623
848
|
failsRun: true
|
|
@@ -641,6 +866,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
641
866
|
return {
|
|
642
867
|
...base,
|
|
643
868
|
status: "NOT_RUN",
|
|
869
|
+
causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
|
|
644
870
|
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
645
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.",
|
|
646
872
|
failsRun: true
|
|
@@ -653,8 +879,12 @@ function assessReactPlaythroughReport(input) {
|
|
|
653
879
|
return {
|
|
654
880
|
...base,
|
|
655
881
|
status: "FAILED",
|
|
882
|
+
causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
656
883
|
cause,
|
|
657
|
-
|
|
884
|
+
related: productionModule.relatedErrors,
|
|
885
|
+
next: repairGuidance(
|
|
886
|
+
productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
|
|
887
|
+
),
|
|
658
888
|
failsRun: true
|
|
659
889
|
};
|
|
660
890
|
}
|
|
@@ -670,7 +900,14 @@ function assessReactPlaythroughReport(input) {
|
|
|
670
900
|
}
|
|
671
901
|
function formatReactPlaythroughReport(report) {
|
|
672
902
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
903
|
+
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
673
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
|
+
}
|
|
674
911
|
if (report.waiverReasons?.length) {
|
|
675
912
|
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
676
913
|
}
|
|
@@ -703,6 +940,10 @@ var ReactPlaythroughReporter = class {
|
|
|
703
940
|
}
|
|
704
941
|
/** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
|
|
705
942
|
onTestRunEnd(testModules, unhandledErrors) {
|
|
943
|
+
const pendingProductTests = findPendingProductTests(
|
|
944
|
+
testModules,
|
|
945
|
+
this.projectRoot
|
|
946
|
+
);
|
|
706
947
|
const report = assessReactPlaythroughReport({
|
|
707
948
|
expectedFile: this.expectedFile,
|
|
708
949
|
expectedFileExists: existsSync(this.expectedModuleId),
|
|
@@ -711,7 +952,9 @@ var ReactPlaythroughReporter = class {
|
|
|
711
952
|
modules: testModules.map(
|
|
712
953
|
(module) => toModuleResult(module, this.projectRoot)
|
|
713
954
|
),
|
|
714
|
-
unhandledErrors: unhandledErrors.map(
|
|
955
|
+
unhandledErrors: extractFailureEntries(unhandledErrors).map(
|
|
956
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
957
|
+
)
|
|
715
958
|
});
|
|
716
959
|
const output = formatReactPlaythroughReport(report);
|
|
717
960
|
if (report.failsRun) {
|
|
@@ -722,10 +965,15 @@ var ReactPlaythroughReporter = class {
|
|
|
722
965
|
} else {
|
|
723
966
|
console.log(output);
|
|
724
967
|
}
|
|
968
|
+
const pendingOutput = formatPendingProductTestReport(pendingProductTests);
|
|
969
|
+
if (pendingOutput) {
|
|
970
|
+
console.error(pendingOutput);
|
|
971
|
+
process.exitCode = 1;
|
|
972
|
+
}
|
|
725
973
|
const summary = formatReactFailureSummary(
|
|
726
974
|
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
727
975
|
);
|
|
728
|
-
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
976
|
+
if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
729
977
|
summary[0] = "TEST_RESULT: FAIL";
|
|
730
978
|
}
|
|
731
979
|
console.log(`
|