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
|
@@ -47,14 +47,112 @@ var import_react2 = require("@testing-library/react");
|
|
|
47
47
|
var import_user_event = __toESM(require("@testing-library/user-event"));
|
|
48
48
|
var import_vitest = require("vitest");
|
|
49
49
|
|
|
50
|
+
// src/react/react-error-diagnostics.ts
|
|
51
|
+
var MAX_DIAGNOSTIC_LENGTH = 1e3;
|
|
52
|
+
function truncate(value) {
|
|
53
|
+
const trimmed = value.trim();
|
|
54
|
+
if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
|
|
55
|
+
return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
|
|
56
|
+
}
|
|
57
|
+
function safeJson(value) {
|
|
58
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
59
|
+
try {
|
|
60
|
+
return JSON.stringify(value, (_key, nested) => {
|
|
61
|
+
if (typeof nested === "bigint") return `${nested}n`;
|
|
62
|
+
if (typeof nested === "function") {
|
|
63
|
+
return `Function<${nested.name || "anonymous"}>`;
|
|
64
|
+
}
|
|
65
|
+
if (typeof nested === "symbol") return nested.toString();
|
|
66
|
+
if (nested && typeof nested === "object") {
|
|
67
|
+
if (seen.has(nested)) return "[Circular]";
|
|
68
|
+
seen.add(nested);
|
|
69
|
+
}
|
|
70
|
+
return nested;
|
|
71
|
+
});
|
|
72
|
+
} catch {
|
|
73
|
+
return void 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function collectEntries(value, fallbackCode, seen) {
|
|
77
|
+
if (typeof value === "string") {
|
|
78
|
+
return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
|
|
79
|
+
}
|
|
80
|
+
if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
|
|
81
|
+
return [{ code: fallbackCode, message: String(value) }];
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === "function") {
|
|
84
|
+
return [
|
|
85
|
+
{ code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
if (seen.has(value)) return [];
|
|
89
|
+
seen.add(value);
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
|
|
92
|
+
}
|
|
93
|
+
const record = value;
|
|
94
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
|
|
95
|
+
const entries = [];
|
|
96
|
+
if (typeof record.message === "string" && record.message.trim()) {
|
|
97
|
+
entries.push({ code, message: truncate(record.message) });
|
|
98
|
+
}
|
|
99
|
+
if (record.cause !== void 0) {
|
|
100
|
+
entries.push(...collectEntries(record.cause, fallbackCode, seen));
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(record.errors)) {
|
|
103
|
+
entries.push(...collectEntries(record.errors, fallbackCode, seen));
|
|
104
|
+
}
|
|
105
|
+
if (entries.length > 0) return entries;
|
|
106
|
+
if (typeof record.stack === "string" && record.stack.trim()) {
|
|
107
|
+
return [{ code, message: truncate(record.stack) }];
|
|
108
|
+
}
|
|
109
|
+
const json = safeJson(value);
|
|
110
|
+
return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
|
|
111
|
+
}
|
|
112
|
+
function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
|
|
113
|
+
const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
|
|
114
|
+
const keys = /* @__PURE__ */ new Set();
|
|
115
|
+
return entries.filter((entry) => {
|
|
116
|
+
const key = `${entry.code}\0${entry.message}`;
|
|
117
|
+
if (keys.has(key)) return false;
|
|
118
|
+
keys.add(key);
|
|
119
|
+
return true;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function createFailureDiagnostic(source, value) {
|
|
123
|
+
return { source, entries: extractFailureEntries(value) };
|
|
124
|
+
}
|
|
125
|
+
function appendCurrentAttemptFailures(current, runnerValue) {
|
|
126
|
+
const runner = createFailureDiagnostic("test-runtime", runnerValue);
|
|
127
|
+
if (!current || current.entries.length === 0) return runner;
|
|
128
|
+
const primary = current.entries[0];
|
|
129
|
+
const currentStart = runner.entries.findIndex(
|
|
130
|
+
(entry) => entry.code === primary.code && entry.message === primary.message
|
|
131
|
+
);
|
|
132
|
+
if (currentStart < 0) return current;
|
|
133
|
+
return {
|
|
134
|
+
source: current.source,
|
|
135
|
+
entries: extractFailureEntries([
|
|
136
|
+
...current.entries,
|
|
137
|
+
...runner.entries.slice(currentStart + 1)
|
|
138
|
+
])
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function codedError(code, message) {
|
|
142
|
+
const error = new Error(message);
|
|
143
|
+
error.code = code;
|
|
144
|
+
return error;
|
|
145
|
+
}
|
|
146
|
+
|
|
50
147
|
// src/react/react-playthrough-core.ts
|
|
51
148
|
var import_react = require("@testing-library/react");
|
|
52
149
|
function throwIfAborted(signal) {
|
|
53
150
|
if (!signal?.aborted) return;
|
|
54
151
|
if (signal.reason instanceof Error) throw signal.reason;
|
|
55
|
-
throw
|
|
56
|
-
|
|
57
|
-
|
|
152
|
+
throw codedError(
|
|
153
|
+
"PLAYTHROUGH_CANCELLED",
|
|
154
|
+
`Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
|
|
155
|
+
);
|
|
58
156
|
}
|
|
59
157
|
function formatDiagnostics(read) {
|
|
60
158
|
if (!read) return void 0;
|
|
@@ -70,7 +168,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
|
|
|
70
168
|
if (waiverReason === void 0) return void 0;
|
|
71
169
|
const reason = waiverReason.trim();
|
|
72
170
|
if (reason.length < 20) {
|
|
73
|
-
throw
|
|
171
|
+
throw codedError(
|
|
172
|
+
"INVALID_PLAYTHROUGH_WAIVER",
|
|
74
173
|
"playthroughTest.skip reason must contain at least 20 characters."
|
|
75
174
|
);
|
|
76
175
|
}
|
|
@@ -79,7 +178,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
|
|
|
79
178
|
async function runBoundedUntil(condition, options = {}) {
|
|
80
179
|
const maxSteps = options.maxSteps ?? 120;
|
|
81
180
|
if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
|
|
82
|
-
throw
|
|
181
|
+
throw codedError(
|
|
182
|
+
"INVALID_STEP_BOUND",
|
|
83
183
|
"stepUntil maxSteps must be a safe integer between 0 and 10000."
|
|
84
184
|
);
|
|
85
185
|
}
|
|
@@ -96,7 +196,8 @@ async function runBoundedUntil(condition, options = {}) {
|
|
|
96
196
|
const diagnostics = formatDiagnostics(options.diagnostics);
|
|
97
197
|
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().";
|
|
98
198
|
const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
|
|
99
|
-
throw
|
|
199
|
+
throw codedError(
|
|
200
|
+
options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
|
|
100
201
|
`Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
|
|
101
202
|
);
|
|
102
203
|
}
|
|
@@ -115,6 +216,11 @@ var MIN_STAGES = 5;
|
|
|
115
216
|
var MIN_MILESTONES = 3;
|
|
116
217
|
var MAX_TRACE_VALUE_LENGTH = 140;
|
|
117
218
|
var MAX_TRACE_LENGTH = 720;
|
|
219
|
+
function eventTargetsCanvas(event) {
|
|
220
|
+
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
|
|
221
|
+
if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
|
|
222
|
+
return event.target instanceof HTMLCanvasElement;
|
|
223
|
+
}
|
|
118
224
|
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
119
225
|
function truncateTraceValue(value, limit) {
|
|
120
226
|
const compact = value.replace(/\s+/g, " ").trim();
|
|
@@ -144,14 +250,18 @@ function sampleObservedState(observe, stage) {
|
|
|
144
250
|
try {
|
|
145
251
|
value = observe();
|
|
146
252
|
} catch (error) {
|
|
147
|
-
throw
|
|
253
|
+
throw codedError(
|
|
254
|
+
"OBSERVE_FAILED",
|
|
255
|
+
`observe() threw at ${stage}: ${String(error)}`
|
|
256
|
+
);
|
|
148
257
|
}
|
|
149
258
|
try {
|
|
150
259
|
const fingerprint = JSON.stringify(value);
|
|
151
260
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
152
261
|
return { fingerprint, formatted: formatState(fingerprint) };
|
|
153
262
|
} catch {
|
|
154
|
-
throw
|
|
263
|
+
throw codedError(
|
|
264
|
+
"OBSERVE_NOT_SERIALIZABLE",
|
|
155
265
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
156
266
|
);
|
|
157
267
|
}
|
|
@@ -164,7 +274,7 @@ function createEvidence() {
|
|
|
164
274
|
return { domInputEvents: 0, stages: [], verified: false };
|
|
165
275
|
}
|
|
166
276
|
function createMetadata(waiverReason) {
|
|
167
|
-
return { version:
|
|
277
|
+
return { version: 5, waiverReason, evidence: createEvidence() };
|
|
168
278
|
}
|
|
169
279
|
function stageLabel(kind, name) {
|
|
170
280
|
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
@@ -200,6 +310,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
200
310
|
async ({ annotate, expect, onTestFailed, signal }) => {
|
|
201
311
|
metadata.evidence = createEvidence();
|
|
202
312
|
metadata.trace = void 0;
|
|
313
|
+
metadata.failure = void 0;
|
|
203
314
|
const evidence = metadata.evidence;
|
|
204
315
|
let entered = false;
|
|
205
316
|
let finished = false;
|
|
@@ -208,9 +319,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
208
319
|
let stepTrace;
|
|
209
320
|
let failureTraceFactory;
|
|
210
321
|
let acceptingStageInput = false;
|
|
322
|
+
let activeStageTargetedCanvas = false;
|
|
211
323
|
let inputCaptureAttached = false;
|
|
212
|
-
const recordInput = () => {
|
|
213
|
-
if (acceptingStageInput)
|
|
324
|
+
const recordInput = (event) => {
|
|
325
|
+
if (!acceptingStageInput) return;
|
|
326
|
+
evidence.domInputEvents += 1;
|
|
327
|
+
activeStageTargetedCanvas ||= eventTargetsCanvas(event);
|
|
214
328
|
};
|
|
215
329
|
const stopInputCapture = () => {
|
|
216
330
|
acceptingStageInput = false;
|
|
@@ -227,8 +341,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
227
341
|
return "stages=none";
|
|
228
342
|
}
|
|
229
343
|
};
|
|
230
|
-
onTestFailed(() => {
|
|
344
|
+
onTestFailed(({ task }) => {
|
|
231
345
|
metadata.trace ??= captureFailureTrace();
|
|
346
|
+
metadata.failure = appendCurrentAttemptFailures(
|
|
347
|
+
metadata.failure,
|
|
348
|
+
task.result?.errors ?? []
|
|
349
|
+
);
|
|
232
350
|
});
|
|
233
351
|
for (const event of INPUT_EVENTS) {
|
|
234
352
|
document.addEventListener(event, recordInput, true);
|
|
@@ -238,7 +356,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
238
356
|
try {
|
|
239
357
|
const view = (0, import_react2.render)(element);
|
|
240
358
|
if (view.container.childNodes.length === 0) {
|
|
241
|
-
throw
|
|
359
|
+
throw codedError(
|
|
360
|
+
"PRODUCTION_ENTRY_NOT_RENDERED",
|
|
242
361
|
"playthroughTest must render the production game entry."
|
|
243
362
|
);
|
|
244
363
|
}
|
|
@@ -267,29 +386,42 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
267
386
|
const executeStage = async (name, kind, stage) => {
|
|
268
387
|
const normalizedName = name.trim();
|
|
269
388
|
if (normalizedName.length === 0) {
|
|
270
|
-
throw
|
|
389
|
+
throw codedError(
|
|
390
|
+
"INVALID_STAGE_NAME",
|
|
391
|
+
"playthrough stage names must be non-empty strings."
|
|
392
|
+
);
|
|
271
393
|
}
|
|
272
394
|
if (evidence.stages.some(
|
|
273
395
|
(completed) => completed.name === normalizedName
|
|
274
396
|
)) {
|
|
275
|
-
throw
|
|
397
|
+
throw codedError(
|
|
398
|
+
"DUPLICATE_STAGE_NAME",
|
|
276
399
|
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
277
400
|
);
|
|
278
401
|
}
|
|
279
402
|
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
280
|
-
throw
|
|
403
|
+
throw codedError(
|
|
404
|
+
"RESERVED_STAGE_NAME",
|
|
281
405
|
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
282
406
|
);
|
|
283
407
|
}
|
|
284
408
|
const before = lastSample ?? sampleState(`before ${normalizedName}`);
|
|
285
409
|
activeStage = { name: normalizedName, kind, before };
|
|
286
410
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
411
|
+
if (stage.step && !playthroughOptions?.observe) {
|
|
412
|
+
throw codedError(
|
|
413
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
|
|
414
|
+
`${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.`
|
|
415
|
+
);
|
|
416
|
+
}
|
|
287
417
|
if (stage.until()) {
|
|
288
|
-
throw
|
|
418
|
+
throw codedError(
|
|
419
|
+
"STAGE_OUTCOME_ALREADY_REACHED",
|
|
289
420
|
`${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
|
|
290
421
|
);
|
|
291
422
|
}
|
|
292
423
|
const inputsBefore = evidence.domInputEvents;
|
|
424
|
+
activeStageTargetedCanvas = false;
|
|
293
425
|
if (stage.act) {
|
|
294
426
|
acceptingStageInput = true;
|
|
295
427
|
try {
|
|
@@ -298,10 +430,17 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
298
430
|
acceptingStageInput = false;
|
|
299
431
|
}
|
|
300
432
|
if (evidence.domInputEvents === inputsBefore) {
|
|
301
|
-
throw
|
|
433
|
+
throw codedError(
|
|
434
|
+
"PRODUCTION_INPUT_NOT_DISPATCHED",
|
|
302
435
|
`${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
|
|
303
436
|
);
|
|
304
437
|
}
|
|
438
|
+
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
439
|
+
throw codedError(
|
|
440
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
|
|
441
|
+
`${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.`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
305
444
|
}
|
|
306
445
|
let advancedSteps = 0;
|
|
307
446
|
const stepBound = stage.maxSteps ?? 120;
|
|
@@ -317,7 +456,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
317
456
|
});
|
|
318
457
|
stepTrace = { bound: stepBound, completed: steps };
|
|
319
458
|
if (!stage.act && advancedSteps === 0) {
|
|
320
|
-
throw
|
|
459
|
+
throw codedError(
|
|
460
|
+
"AUTONOMOUS_STAGE_NOT_ADVANCED",
|
|
321
461
|
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
322
462
|
);
|
|
323
463
|
}
|
|
@@ -325,14 +465,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
325
465
|
await stage.assert({ expect, user, view });
|
|
326
466
|
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
327
467
|
if (assertions === 0) {
|
|
328
|
-
throw
|
|
468
|
+
throw codedError(
|
|
469
|
+
"STAGE_ASSERTION_MISSING",
|
|
329
470
|
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
330
471
|
);
|
|
331
472
|
}
|
|
332
473
|
const after = sampleState(`after ${normalizedName}`);
|
|
333
474
|
if (after.fingerprint === before.fingerprint) {
|
|
334
475
|
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
335
|
-
throw
|
|
476
|
+
throw codedError(
|
|
477
|
+
"STAGE_STATE_UNCHANGED",
|
|
336
478
|
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
337
479
|
);
|
|
338
480
|
}
|
|
@@ -354,27 +496,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
354
496
|
user,
|
|
355
497
|
async enter(stage) {
|
|
356
498
|
if (entered) {
|
|
357
|
-
throw
|
|
499
|
+
throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
|
|
358
500
|
}
|
|
359
501
|
if (evidence.stages.length > 0) {
|
|
360
|
-
throw
|
|
502
|
+
throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
|
|
361
503
|
}
|
|
362
504
|
await executeStage("entered", "entered", stage);
|
|
363
505
|
entered = true;
|
|
364
506
|
},
|
|
365
507
|
async milestone(name, stage) {
|
|
366
508
|
if (!entered) {
|
|
367
|
-
throw
|
|
509
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
|
|
368
510
|
}
|
|
369
511
|
if (finished) {
|
|
370
|
-
throw
|
|
512
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
|
|
371
513
|
}
|
|
372
514
|
await executeStage(name, "milestone", stage);
|
|
373
515
|
},
|
|
374
516
|
async finish(name, stage) {
|
|
375
|
-
if (!entered) throw
|
|
517
|
+
if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
|
|
376
518
|
if (finished) {
|
|
377
|
-
throw
|
|
519
|
+
throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
|
|
378
520
|
}
|
|
379
521
|
await executeStage(name, stage.kind, stage);
|
|
380
522
|
finished = true;
|
|
@@ -383,19 +525,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
383
525
|
const milestones = evidence.stages.filter(
|
|
384
526
|
(stage) => stage.kind === "milestone"
|
|
385
527
|
);
|
|
386
|
-
if (!entered) throw
|
|
528
|
+
if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
|
|
387
529
|
if (milestones.length < MIN_MILESTONES) {
|
|
388
|
-
throw
|
|
530
|
+
throw codedError(
|
|
531
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
389
532
|
`playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
|
|
390
533
|
);
|
|
391
534
|
}
|
|
392
535
|
if (!finished) {
|
|
393
|
-
throw
|
|
536
|
+
throw codedError(
|
|
537
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
394
538
|
'playthroughTest must call finish with kind "progress" or "terminal".'
|
|
395
539
|
);
|
|
396
540
|
}
|
|
397
541
|
if (evidence.stages.length < MIN_STAGES) {
|
|
398
|
-
throw
|
|
542
|
+
throw codedError(
|
|
543
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
399
544
|
`playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
|
|
400
545
|
);
|
|
401
546
|
}
|
|
@@ -403,6 +548,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
403
548
|
} catch (error) {
|
|
404
549
|
const trace = captureFailureTrace();
|
|
405
550
|
metadata.trace = trace;
|
|
551
|
+
metadata.failure = createFailureDiagnostic("playthrough", error);
|
|
406
552
|
try {
|
|
407
553
|
await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
408
554
|
} catch {
|
|
@@ -481,10 +627,18 @@ function auditReactPlaythroughRun(tests) {
|
|
|
481
627
|
|
|
482
628
|
// src/react/react-playthrough-reporter.ts
|
|
483
629
|
var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
630
|
+
var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the production mechanic, deterministic driver, or authoritative observation that prevents it. Do not make the test pass by weakening or deleting assertions, replacing the outcome with back/menu/exit navigation, observing arbitrary UI text only to change a fingerprint, using a no-op step, or treating an intermediate active/in-flight/running phase as meaningful progress.";
|
|
484
631
|
function isMetadata(value) {
|
|
485
632
|
if (!value || typeof value !== "object") return false;
|
|
486
633
|
const metadata = value;
|
|
487
|
-
if (metadata.version !==
|
|
634
|
+
if (metadata.version !== 5) return false;
|
|
635
|
+
if (metadata.failure !== void 0) {
|
|
636
|
+
if (!metadata.failure || typeof metadata.failure !== "object" || typeof metadata.failure.source !== "string" || !Array.isArray(metadata.failure.entries) || !metadata.failure.entries.every(
|
|
637
|
+
(entry) => Boolean(entry) && typeof entry === "object" && typeof entry.code === "string" && typeof entry.message === "string"
|
|
638
|
+
)) {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
488
642
|
const evidence = metadata.evidence;
|
|
489
643
|
if (!evidence || typeof evidence !== "object") return false;
|
|
490
644
|
return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
|
|
@@ -499,10 +653,72 @@ function toAuditInput(test2) {
|
|
|
499
653
|
metadata: isMetadata(metadata) ? metadata : void 0
|
|
500
654
|
};
|
|
501
655
|
}
|
|
656
|
+
function findPendingProductTests(modules, projectRoot) {
|
|
657
|
+
return modules.flatMap((module2) => {
|
|
658
|
+
const file = (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/");
|
|
659
|
+
if (file.split("/").includes("examples")) return [];
|
|
660
|
+
const tests = [...module2.children.allTests()];
|
|
661
|
+
const pending = tests.filter((test2) => {
|
|
662
|
+
const mode = test2.options.mode;
|
|
663
|
+
if (mode !== "todo" && mode !== "skip") return false;
|
|
664
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
665
|
+
const approvedWaiver = mode === "skip" && isMetadata(metadata) && Boolean(metadata.waiverReason);
|
|
666
|
+
return !approvedWaiver;
|
|
667
|
+
});
|
|
668
|
+
const fileOnlyContainsPendingTests = tests.length > 0 && pending.length === tests.length;
|
|
669
|
+
return pending.map((test2) => ({
|
|
670
|
+
file,
|
|
671
|
+
test: test2.fullName,
|
|
672
|
+
mode: test2.options.mode,
|
|
673
|
+
fileOnlyContainsPendingTests
|
|
674
|
+
}));
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
function formatPendingProductTestReport(pending) {
|
|
678
|
+
if (pending.length === 0) return void 0;
|
|
679
|
+
const lines = [
|
|
680
|
+
"REACT_FOCUSED_TESTS: FAILED",
|
|
681
|
+
"CAUSE_CODE: TODO_OR_SKIP_TESTS",
|
|
682
|
+
"CAUSE: Product tests still contain explicit todo/skip cases."
|
|
683
|
+
];
|
|
684
|
+
const reportedIncompleteFiles = /* @__PURE__ */ new Set();
|
|
685
|
+
for (const item of pending) {
|
|
686
|
+
if (item.fileOnlyContainsPendingTests && !reportedIncompleteFiles.has(item.file)) {
|
|
687
|
+
lines.push("FILE_CAUSE_CODE: PRODUCT_TEST_FILE_NOT_IMPLEMENTED");
|
|
688
|
+
lines.push(
|
|
689
|
+
`FILE_CAUSE: Every collected test in ${item.file} is marked todo/skip.`
|
|
690
|
+
);
|
|
691
|
+
reportedIncompleteFiles.add(item.file);
|
|
692
|
+
}
|
|
693
|
+
lines.push(`FILE: ${item.file}`);
|
|
694
|
+
lines.push(`TEST: ${item.test}`);
|
|
695
|
+
lines.push(`MODE: ${item.mode}`);
|
|
696
|
+
}
|
|
697
|
+
lines.push(
|
|
698
|
+
"NEXT: Implement each focused product test or delete a genuinely inapplicable slot. Do not replace todo with skip."
|
|
699
|
+
);
|
|
700
|
+
return `
|
|
701
|
+
${lines.join("\n")}`;
|
|
702
|
+
}
|
|
502
703
|
function firstLine(value) {
|
|
503
704
|
if (typeof value !== "string") return void 0;
|
|
504
705
|
return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
505
706
|
}
|
|
707
|
+
function selectReactFailure(values, errorRecordCount = values.length) {
|
|
708
|
+
const entries = extractFailureEntries(values);
|
|
709
|
+
if (entries.length === 0) {
|
|
710
|
+
return {
|
|
711
|
+
code: "MISSING_FAILURE_DETAILS",
|
|
712
|
+
cause: `Vitest marked this test as failed but returned no readable message in ${errorRecordCount} error record${errorRecordCount === 1 ? "" : "s"}.`,
|
|
713
|
+
rawCause: "",
|
|
714
|
+
related: []
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
const primary = entries[0];
|
|
718
|
+
const rawCause = primary.message;
|
|
719
|
+
const cause = firstLine(rawCause) ?? rawCause;
|
|
720
|
+
return { code: primary.code, cause, rawCause, related: entries.slice(1) };
|
|
721
|
+
}
|
|
506
722
|
function failureHint(value) {
|
|
507
723
|
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
508
724
|
(match) => match[1]
|
|
@@ -536,30 +752,45 @@ function truncateReporterLine(value, limit) {
|
|
|
536
752
|
}
|
|
537
753
|
function toModuleResult(module2, projectRoot) {
|
|
538
754
|
const tests = [...module2.children.allTests()];
|
|
539
|
-
const
|
|
755
|
+
const moduleFailure = selectReactFailure(module2.errors(), module2.errors().length);
|
|
756
|
+
const moduleErrors = extractFailureEntries(module2.errors()).map(
|
|
757
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
758
|
+
);
|
|
540
759
|
const errors = [
|
|
541
760
|
...moduleErrors,
|
|
542
761
|
...tests.flatMap(
|
|
543
|
-
(test2) => (test2.result().errors ?? []).map(
|
|
762
|
+
(test2) => extractFailureEntries(test2.result().errors ?? []).map(
|
|
763
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
764
|
+
)
|
|
544
765
|
)
|
|
545
|
-
]
|
|
766
|
+
];
|
|
546
767
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
547
|
-
const
|
|
768
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
769
|
+
const testErrors = test2.result().errors ?? [];
|
|
770
|
+
const metadataErrors = isMetadata(metadata) ? metadata.failure?.entries ?? [] : [];
|
|
771
|
+
const attemptErrors = metadataErrors.length > 0 ? metadataErrors : testErrors;
|
|
772
|
+
const selected = selectReactFailure(
|
|
773
|
+
[...attemptErrors, ...module2.errors()],
|
|
774
|
+
testErrors.length + module2.errors().length
|
|
775
|
+
);
|
|
548
776
|
return {
|
|
549
777
|
test: test2.fullName,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
778
|
+
causeCode: selected.code,
|
|
779
|
+
cause: selected.cause,
|
|
780
|
+
related: selected.related,
|
|
781
|
+
location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(selected.rawCause),
|
|
782
|
+
hint: failureHint(selected.rawCause),
|
|
553
783
|
trace: failureTrace(test2)
|
|
554
784
|
};
|
|
555
785
|
});
|
|
556
786
|
if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
|
|
557
|
-
const raw = module2.errors()[0]?.message ?? "Module failed to load";
|
|
558
787
|
failures.push({
|
|
559
788
|
test: "<collection>",
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
789
|
+
causeCode: moduleFailure.code,
|
|
790
|
+
cause: moduleFailure.cause,
|
|
791
|
+
related: moduleFailure.related,
|
|
792
|
+
location: errorLocation(moduleFailure.rawCause),
|
|
793
|
+
hint: failureHint(moduleFailure.rawCause)
|
|
563
794
|
});
|
|
564
795
|
}
|
|
565
796
|
return {
|
|
@@ -567,6 +798,8 @@ function toModuleResult(module2, projectRoot) {
|
|
|
567
798
|
state: module2.state(),
|
|
568
799
|
errors,
|
|
569
800
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
801
|
+
primaryCauseCode: failures[0]?.causeCode,
|
|
802
|
+
relatedErrors: failures[0]?.related,
|
|
570
803
|
tests: tests.map(toAuditInput),
|
|
571
804
|
failures
|
|
572
805
|
};
|
|
@@ -583,7 +816,14 @@ function formatReactFailureSummary(modules) {
|
|
|
583
816
|
for (const [index, failure] of failures.entries()) {
|
|
584
817
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
585
818
|
lines.push(`TEST: ${failure.test}`);
|
|
819
|
+
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
586
820
|
lines.push(`CAUSE: ${failure.cause}`);
|
|
821
|
+
for (const [relatedIndex, related] of (failure.related ?? []).entries()) {
|
|
822
|
+
lines.push(`RELATED_${relatedIndex + 1}_CODE: ${related.code}`);
|
|
823
|
+
lines.push(
|
|
824
|
+
`RELATED_${relatedIndex + 1}: ${firstLine(related.message) ?? related.message}`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
587
827
|
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
588
828
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
589
829
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
@@ -591,26 +831,36 @@ function formatReactFailureSummary(modules) {
|
|
|
591
831
|
lines.push("TEST_RESULT: FAIL");
|
|
592
832
|
return lines;
|
|
593
833
|
}
|
|
594
|
-
function repairGuidance(
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
834
|
+
function repairGuidance(code) {
|
|
835
|
+
switch (code) {
|
|
836
|
+
case "GAME_SNAPSHOT_REFERENCE_REUSED":
|
|
837
|
+
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.";
|
|
838
|
+
case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP":
|
|
839
|
+
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.";
|
|
840
|
+
case "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS":
|
|
841
|
+
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.";
|
|
842
|
+
case "STAGE_OUTCOME_ALREADY_REACHED":
|
|
843
|
+
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.";
|
|
844
|
+
case "STAGE_STATE_UNCHANGED":
|
|
845
|
+
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.";
|
|
846
|
+
case "PLAYTHROUGH_BOUND_EXHAUSTED":
|
|
847
|
+
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.";
|
|
848
|
+
case "PLAYTHROUGH_CLOCK_NOT_ADVANCED":
|
|
849
|
+
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.";
|
|
850
|
+
case "INVALID_STAGE_ORDER":
|
|
851
|
+
case "INCOMPLETE_PLAYTHROUGH_EVIDENCE":
|
|
852
|
+
case "INVALID_STAGE_NAME":
|
|
853
|
+
case "DUPLICATE_STAGE_NAME":
|
|
854
|
+
case "RESERVED_STAGE_NAME":
|
|
855
|
+
case "PRODUCTION_INPUT_NOT_DISPATCHED":
|
|
856
|
+
case "AUTONOMOUS_STAGE_NOT_ADVANCED":
|
|
857
|
+
case "STAGE_ASSERTION_MISSING":
|
|
858
|
+
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.";
|
|
859
|
+
case "MISSING_FAILURE_DETAILS":
|
|
860
|
+
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.";
|
|
861
|
+
default:
|
|
862
|
+
return "Fix the first reported CAUSE, then rerun the same test. RELATED entries preserve the remaining Vitest errors in their original order.";
|
|
609
863
|
}
|
|
610
|
-
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
611
|
-
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.";
|
|
612
|
-
}
|
|
613
|
-
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.";
|
|
614
864
|
}
|
|
615
865
|
function assessReactPlaythroughReport(input) {
|
|
616
866
|
const base = { file: input.expectedFile };
|
|
@@ -626,6 +876,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
626
876
|
return {
|
|
627
877
|
...base,
|
|
628
878
|
status: "FAILED",
|
|
879
|
+
causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
|
|
629
880
|
cause: "The required production playthrough test file does not exist.",
|
|
630
881
|
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.",
|
|
631
882
|
failsRun: true
|
|
@@ -649,6 +900,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
649
900
|
return {
|
|
650
901
|
...base,
|
|
651
902
|
status: "NOT_RUN",
|
|
903
|
+
causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
|
|
652
904
|
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
653
905
|
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.",
|
|
654
906
|
failsRun: true
|
|
@@ -661,8 +913,12 @@ function assessReactPlaythroughReport(input) {
|
|
|
661
913
|
return {
|
|
662
914
|
...base,
|
|
663
915
|
status: "FAILED",
|
|
916
|
+
causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
664
917
|
cause,
|
|
665
|
-
|
|
918
|
+
related: productionModule.relatedErrors,
|
|
919
|
+
next: repairGuidance(
|
|
920
|
+
productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
|
|
921
|
+
),
|
|
666
922
|
failsRun: true
|
|
667
923
|
};
|
|
668
924
|
}
|
|
@@ -678,11 +934,21 @@ function assessReactPlaythroughReport(input) {
|
|
|
678
934
|
}
|
|
679
935
|
function formatReactPlaythroughReport(report) {
|
|
680
936
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
937
|
+
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
681
938
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
939
|
+
for (const [index, related] of (report.related ?? []).entries()) {
|
|
940
|
+
lines.push(`RELATED_${index + 1}_CODE: ${related.code}`);
|
|
941
|
+
lines.push(
|
|
942
|
+
`RELATED_${index + 1}: ${firstLine(related.message) ?? related.message}`
|
|
943
|
+
);
|
|
944
|
+
}
|
|
682
945
|
if (report.waiverReasons?.length) {
|
|
683
946
|
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
684
947
|
}
|
|
685
948
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
949
|
+
if (report.status === "FAILED") {
|
|
950
|
+
lines.push(`REPAIR_CONSTRAINT: ${REPAIR_CONSTRAINT}`);
|
|
951
|
+
}
|
|
686
952
|
return `
|
|
687
953
|
${lines.join("\n")}`;
|
|
688
954
|
}
|
|
@@ -708,6 +974,10 @@ var ReactPlaythroughReporter = class {
|
|
|
708
974
|
}
|
|
709
975
|
/** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
|
|
710
976
|
onTestRunEnd(testModules, unhandledErrors) {
|
|
977
|
+
const pendingProductTests = findPendingProductTests(
|
|
978
|
+
testModules,
|
|
979
|
+
this.projectRoot
|
|
980
|
+
);
|
|
711
981
|
const report = assessReactPlaythroughReport({
|
|
712
982
|
expectedFile: this.expectedFile,
|
|
713
983
|
expectedFileExists: (0, import_node_fs.existsSync)(this.expectedModuleId),
|
|
@@ -716,7 +986,9 @@ var ReactPlaythroughReporter = class {
|
|
|
716
986
|
modules: testModules.map(
|
|
717
987
|
(module2) => toModuleResult(module2, this.projectRoot)
|
|
718
988
|
),
|
|
719
|
-
unhandledErrors: unhandledErrors.map(
|
|
989
|
+
unhandledErrors: extractFailureEntries(unhandledErrors).map(
|
|
990
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
991
|
+
)
|
|
720
992
|
});
|
|
721
993
|
const output = formatReactPlaythroughReport(report);
|
|
722
994
|
if (report.failsRun) {
|
|
@@ -727,10 +999,15 @@ var ReactPlaythroughReporter = class {
|
|
|
727
999
|
} else {
|
|
728
1000
|
console.log(output);
|
|
729
1001
|
}
|
|
1002
|
+
const pendingOutput = formatPendingProductTestReport(pendingProductTests);
|
|
1003
|
+
if (pendingOutput) {
|
|
1004
|
+
console.error(pendingOutput);
|
|
1005
|
+
process.exitCode = 1;
|
|
1006
|
+
}
|
|
730
1007
|
const summary = formatReactFailureSummary(
|
|
731
1008
|
testModules.map((module2) => toModuleResult(module2, this.projectRoot))
|
|
732
1009
|
);
|
|
733
|
-
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
1010
|
+
if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
734
1011
|
summary[0] = "TEST_RESULT: FAIL";
|
|
735
1012
|
}
|
|
736
1013
|
console.log(`
|