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
|
@@ -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
|
}
|
|
@@ -149,14 +250,18 @@ function sampleObservedState(observe, stage) {
|
|
|
149
250
|
try {
|
|
150
251
|
value = observe();
|
|
151
252
|
} catch (error) {
|
|
152
|
-
throw
|
|
253
|
+
throw codedError(
|
|
254
|
+
"OBSERVE_FAILED",
|
|
255
|
+
`observe() threw at ${stage}: ${String(error)}`
|
|
256
|
+
);
|
|
153
257
|
}
|
|
154
258
|
try {
|
|
155
259
|
const fingerprint = JSON.stringify(value);
|
|
156
260
|
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
157
261
|
return { fingerprint, formatted: formatState(fingerprint) };
|
|
158
262
|
} catch {
|
|
159
|
-
throw
|
|
263
|
+
throw codedError(
|
|
264
|
+
"OBSERVE_NOT_SERIALIZABLE",
|
|
160
265
|
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
161
266
|
);
|
|
162
267
|
}
|
|
@@ -169,7 +274,7 @@ function createEvidence() {
|
|
|
169
274
|
return { domInputEvents: 0, stages: [], verified: false };
|
|
170
275
|
}
|
|
171
276
|
function createMetadata(waiverReason) {
|
|
172
|
-
return { version:
|
|
277
|
+
return { version: 5, waiverReason, evidence: createEvidence() };
|
|
173
278
|
}
|
|
174
279
|
function stageLabel(kind, name) {
|
|
175
280
|
return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
|
|
@@ -205,6 +310,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
205
310
|
async ({ annotate, expect, onTestFailed, signal }) => {
|
|
206
311
|
metadata.evidence = createEvidence();
|
|
207
312
|
metadata.trace = void 0;
|
|
313
|
+
metadata.failure = void 0;
|
|
208
314
|
const evidence = metadata.evidence;
|
|
209
315
|
let entered = false;
|
|
210
316
|
let finished = false;
|
|
@@ -235,8 +341,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
235
341
|
return "stages=none";
|
|
236
342
|
}
|
|
237
343
|
};
|
|
238
|
-
onTestFailed(() => {
|
|
344
|
+
onTestFailed(({ task }) => {
|
|
239
345
|
metadata.trace ??= captureFailureTrace();
|
|
346
|
+
metadata.failure = appendCurrentAttemptFailures(
|
|
347
|
+
metadata.failure,
|
|
348
|
+
task.result?.errors ?? []
|
|
349
|
+
);
|
|
240
350
|
});
|
|
241
351
|
for (const event of INPUT_EVENTS) {
|
|
242
352
|
document.addEventListener(event, recordInput, true);
|
|
@@ -246,7 +356,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
246
356
|
try {
|
|
247
357
|
const view = (0, import_react2.render)(element);
|
|
248
358
|
if (view.container.childNodes.length === 0) {
|
|
249
|
-
throw
|
|
359
|
+
throw codedError(
|
|
360
|
+
"PRODUCTION_ENTRY_NOT_RENDERED",
|
|
250
361
|
"playthroughTest must render the production game entry."
|
|
251
362
|
);
|
|
252
363
|
}
|
|
@@ -275,17 +386,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
275
386
|
const executeStage = async (name, kind, stage) => {
|
|
276
387
|
const normalizedName = name.trim();
|
|
277
388
|
if (normalizedName.length === 0) {
|
|
278
|
-
throw
|
|
389
|
+
throw codedError(
|
|
390
|
+
"INVALID_STAGE_NAME",
|
|
391
|
+
"playthrough stage names must be non-empty strings."
|
|
392
|
+
);
|
|
279
393
|
}
|
|
280
394
|
if (evidence.stages.some(
|
|
281
395
|
(completed) => completed.name === normalizedName
|
|
282
396
|
)) {
|
|
283
|
-
throw
|
|
397
|
+
throw codedError(
|
|
398
|
+
"DUPLICATE_STAGE_NAME",
|
|
284
399
|
`playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
|
|
285
400
|
);
|
|
286
401
|
}
|
|
287
402
|
if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
|
|
288
|
-
throw
|
|
403
|
+
throw codedError(
|
|
404
|
+
"RESERVED_STAGE_NAME",
|
|
289
405
|
`milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
|
|
290
406
|
);
|
|
291
407
|
}
|
|
@@ -293,12 +409,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
293
409
|
activeStage = { name: normalizedName, kind, before };
|
|
294
410
|
stepTrace = { bound: stage.maxSteps ?? 120 };
|
|
295
411
|
if (stage.step && !playthroughOptions?.observe) {
|
|
296
|
-
throw
|
|
412
|
+
throw codedError(
|
|
413
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
|
|
297
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.`
|
|
298
415
|
);
|
|
299
416
|
}
|
|
300
417
|
if (stage.until()) {
|
|
301
|
-
throw
|
|
418
|
+
throw codedError(
|
|
419
|
+
"STAGE_OUTCOME_ALREADY_REACHED",
|
|
302
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.`
|
|
303
421
|
);
|
|
304
422
|
}
|
|
@@ -312,12 +430,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
312
430
|
acceptingStageInput = false;
|
|
313
431
|
}
|
|
314
432
|
if (evidence.domInputEvents === inputsBefore) {
|
|
315
|
-
throw
|
|
433
|
+
throw codedError(
|
|
434
|
+
"PRODUCTION_INPUT_NOT_DISPATCHED",
|
|
316
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.`
|
|
317
436
|
);
|
|
318
437
|
}
|
|
319
438
|
if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
|
|
320
|
-
throw
|
|
439
|
+
throw codedError(
|
|
440
|
+
"AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
|
|
321
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.`
|
|
322
442
|
);
|
|
323
443
|
}
|
|
@@ -336,7 +456,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
336
456
|
});
|
|
337
457
|
stepTrace = { bound: stepBound, completed: steps };
|
|
338
458
|
if (!stage.act && advancedSteps === 0) {
|
|
339
|
-
throw
|
|
459
|
+
throw codedError(
|
|
460
|
+
"AUTONOMOUS_STAGE_NOT_ADVANCED",
|
|
340
461
|
`${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
|
|
341
462
|
);
|
|
342
463
|
}
|
|
@@ -344,14 +465,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
344
465
|
await stage.assert({ expect, user, view });
|
|
345
466
|
const assertions = expect.getState().assertionCalls - assertionsBefore;
|
|
346
467
|
if (assertions === 0) {
|
|
347
|
-
throw
|
|
468
|
+
throw codedError(
|
|
469
|
+
"STAGE_ASSERTION_MISSING",
|
|
348
470
|
`${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
|
|
349
471
|
);
|
|
350
472
|
}
|
|
351
473
|
const after = sampleState(`after ${normalizedName}`);
|
|
352
474
|
if (after.fingerprint === before.fingerprint) {
|
|
353
475
|
const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
|
|
354
|
-
throw
|
|
476
|
+
throw codedError(
|
|
477
|
+
"STAGE_STATE_UNCHANGED",
|
|
355
478
|
`${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
|
|
356
479
|
);
|
|
357
480
|
}
|
|
@@ -373,27 +496,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
373
496
|
user,
|
|
374
497
|
async enter(stage) {
|
|
375
498
|
if (entered) {
|
|
376
|
-
throw
|
|
499
|
+
throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
|
|
377
500
|
}
|
|
378
501
|
if (evidence.stages.length > 0) {
|
|
379
|
-
throw
|
|
502
|
+
throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
|
|
380
503
|
}
|
|
381
504
|
await executeStage("entered", "entered", stage);
|
|
382
505
|
entered = true;
|
|
383
506
|
},
|
|
384
507
|
async milestone(name, stage) {
|
|
385
508
|
if (!entered) {
|
|
386
|
-
throw
|
|
509
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
|
|
387
510
|
}
|
|
388
511
|
if (finished) {
|
|
389
|
-
throw
|
|
512
|
+
throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
|
|
390
513
|
}
|
|
391
514
|
await executeStage(name, "milestone", stage);
|
|
392
515
|
},
|
|
393
516
|
async finish(name, stage) {
|
|
394
|
-
if (!entered) throw
|
|
517
|
+
if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
|
|
395
518
|
if (finished) {
|
|
396
|
-
throw
|
|
519
|
+
throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
|
|
397
520
|
}
|
|
398
521
|
await executeStage(name, stage.kind, stage);
|
|
399
522
|
finished = true;
|
|
@@ -402,19 +525,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
402
525
|
const milestones = evidence.stages.filter(
|
|
403
526
|
(stage) => stage.kind === "milestone"
|
|
404
527
|
);
|
|
405
|
-
if (!entered) throw
|
|
528
|
+
if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
|
|
406
529
|
if (milestones.length < MIN_MILESTONES) {
|
|
407
|
-
throw
|
|
530
|
+
throw codedError(
|
|
531
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
408
532
|
`playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
|
|
409
533
|
);
|
|
410
534
|
}
|
|
411
535
|
if (!finished) {
|
|
412
|
-
throw
|
|
536
|
+
throw codedError(
|
|
537
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
413
538
|
'playthroughTest must call finish with kind "progress" or "terminal".'
|
|
414
539
|
);
|
|
415
540
|
}
|
|
416
541
|
if (evidence.stages.length < MIN_STAGES) {
|
|
417
|
-
throw
|
|
542
|
+
throw codedError(
|
|
543
|
+
"INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
418
544
|
`playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
|
|
419
545
|
);
|
|
420
546
|
}
|
|
@@ -422,6 +548,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
|
422
548
|
} catch (error) {
|
|
423
549
|
const trace = captureFailureTrace();
|
|
424
550
|
metadata.trace = trace;
|
|
551
|
+
metadata.failure = createFailureDiagnostic("playthrough", error);
|
|
425
552
|
try {
|
|
426
553
|
await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
427
554
|
} catch {
|
|
@@ -504,7 +631,14 @@ var REPAIR_CONSTRAINT = "Preserve the intended gameplay outcome. Fix the product
|
|
|
504
631
|
function isMetadata(value) {
|
|
505
632
|
if (!value || typeof value !== "object") return false;
|
|
506
633
|
const metadata = value;
|
|
507
|
-
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
|
+
}
|
|
508
642
|
const evidence = metadata.evidence;
|
|
509
643
|
if (!evidence || typeof evidence !== "object") return false;
|
|
510
644
|
return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
|
|
@@ -519,10 +653,72 @@ function toAuditInput(test2) {
|
|
|
519
653
|
metadata: isMetadata(metadata) ? metadata : void 0
|
|
520
654
|
};
|
|
521
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
|
+
}
|
|
522
703
|
function firstLine(value) {
|
|
523
704
|
if (typeof value !== "string") return void 0;
|
|
524
705
|
return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
525
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
|
+
}
|
|
526
722
|
function failureHint(value) {
|
|
527
723
|
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
528
724
|
(match) => match[1]
|
|
@@ -556,30 +752,45 @@ function truncateReporterLine(value, limit) {
|
|
|
556
752
|
}
|
|
557
753
|
function toModuleResult(module2, projectRoot) {
|
|
558
754
|
const tests = [...module2.children.allTests()];
|
|
559
|
-
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
|
+
);
|
|
560
759
|
const errors = [
|
|
561
760
|
...moduleErrors,
|
|
562
761
|
...tests.flatMap(
|
|
563
|
-
(test2) => (test2.result().errors ?? []).map(
|
|
762
|
+
(test2) => extractFailureEntries(test2.result().errors ?? []).map(
|
|
763
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
764
|
+
)
|
|
564
765
|
)
|
|
565
|
-
]
|
|
766
|
+
];
|
|
566
767
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
567
|
-
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
|
+
);
|
|
568
776
|
return {
|
|
569
777
|
test: test2.fullName,
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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),
|
|
573
783
|
trace: failureTrace(test2)
|
|
574
784
|
};
|
|
575
785
|
});
|
|
576
786
|
if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
|
|
577
|
-
const raw = module2.errors()[0]?.message ?? "Module failed to load";
|
|
578
787
|
failures.push({
|
|
579
788
|
test: "<collection>",
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
789
|
+
causeCode: moduleFailure.code,
|
|
790
|
+
cause: moduleFailure.cause,
|
|
791
|
+
related: moduleFailure.related,
|
|
792
|
+
location: errorLocation(moduleFailure.rawCause),
|
|
793
|
+
hint: failureHint(moduleFailure.rawCause)
|
|
583
794
|
});
|
|
584
795
|
}
|
|
585
796
|
return {
|
|
@@ -587,6 +798,8 @@ function toModuleResult(module2, projectRoot) {
|
|
|
587
798
|
state: module2.state(),
|
|
588
799
|
errors,
|
|
589
800
|
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
801
|
+
primaryCauseCode: failures[0]?.causeCode,
|
|
802
|
+
relatedErrors: failures[0]?.related,
|
|
590
803
|
tests: tests.map(toAuditInput),
|
|
591
804
|
failures
|
|
592
805
|
};
|
|
@@ -603,7 +816,14 @@ function formatReactFailureSummary(modules) {
|
|
|
603
816
|
for (const [index, failure] of failures.entries()) {
|
|
604
817
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
605
818
|
lines.push(`TEST: ${failure.test}`);
|
|
819
|
+
lines.push(`CAUSE_CODE: ${failure.causeCode ?? "TEST_FAILURE"}`);
|
|
606
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
|
+
}
|
|
607
827
|
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
608
828
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
609
829
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
@@ -611,32 +831,36 @@ function formatReactFailureSummary(modules) {
|
|
|
611
831
|
lines.push("TEST_RESULT: FAIL");
|
|
612
832
|
return lines;
|
|
613
833
|
}
|
|
614
|
-
function repairGuidance(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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.";
|
|
632
863
|
}
|
|
633
|
-
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
634
|
-
return "Keep the intended outcome unchanged. The stage driver ran, but gameplay did not reach it within the bound. Inspect TRACE and Last diagnostics, then confirm that each deterministic step advances the same production Controller rendered by <App />. If state remains unchanged, inject ManualGameClock through the production App factory and observe that Controller through Telemetry. Do not replace the outcome with navigation, an intermediate phase, a no-op step, or a weaker assertion.";
|
|
635
|
-
}
|
|
636
|
-
if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
|
|
637
|
-
return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
|
|
638
|
-
}
|
|
639
|
-
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.";
|
|
640
864
|
}
|
|
641
865
|
function assessReactPlaythroughReport(input) {
|
|
642
866
|
const base = { file: input.expectedFile };
|
|
@@ -652,6 +876,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
652
876
|
return {
|
|
653
877
|
...base,
|
|
654
878
|
status: "FAILED",
|
|
879
|
+
causeCode: "MISSING_PRODUCTION_PLAYTHROUGH",
|
|
655
880
|
cause: "The required production playthrough test file does not exist.",
|
|
656
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.",
|
|
657
882
|
failsRun: true
|
|
@@ -675,6 +900,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
675
900
|
return {
|
|
676
901
|
...base,
|
|
677
902
|
status: "NOT_RUN",
|
|
903
|
+
causeCode: productionModule?.primaryCauseCode ?? "TEST_NOT_RUN",
|
|
678
904
|
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
679
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.",
|
|
680
906
|
failsRun: true
|
|
@@ -687,8 +913,12 @@ function assessReactPlaythroughReport(input) {
|
|
|
687
913
|
return {
|
|
688
914
|
...base,
|
|
689
915
|
status: "FAILED",
|
|
916
|
+
causeCode: productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
|
|
690
917
|
cause,
|
|
691
|
-
|
|
918
|
+
related: productionModule.relatedErrors,
|
|
919
|
+
next: repairGuidance(
|
|
920
|
+
productionModule.primaryCauseCode ?? "INCOMPLETE_PLAYTHROUGH_EVIDENCE"
|
|
921
|
+
),
|
|
692
922
|
failsRun: true
|
|
693
923
|
};
|
|
694
924
|
}
|
|
@@ -704,7 +934,14 @@ function assessReactPlaythroughReport(input) {
|
|
|
704
934
|
}
|
|
705
935
|
function formatReactPlaythroughReport(report) {
|
|
706
936
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
937
|
+
if (report.causeCode) lines.push(`CAUSE_CODE: ${report.causeCode}`);
|
|
707
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
|
+
}
|
|
708
945
|
if (report.waiverReasons?.length) {
|
|
709
946
|
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
710
947
|
}
|
|
@@ -737,6 +974,10 @@ var ReactPlaythroughReporter = class {
|
|
|
737
974
|
}
|
|
738
975
|
/** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
|
|
739
976
|
onTestRunEnd(testModules, unhandledErrors) {
|
|
977
|
+
const pendingProductTests = findPendingProductTests(
|
|
978
|
+
testModules,
|
|
979
|
+
this.projectRoot
|
|
980
|
+
);
|
|
740
981
|
const report = assessReactPlaythroughReport({
|
|
741
982
|
expectedFile: this.expectedFile,
|
|
742
983
|
expectedFileExists: (0, import_node_fs.existsSync)(this.expectedModuleId),
|
|
@@ -745,7 +986,9 @@ var ReactPlaythroughReporter = class {
|
|
|
745
986
|
modules: testModules.map(
|
|
746
987
|
(module2) => toModuleResult(module2, this.projectRoot)
|
|
747
988
|
),
|
|
748
|
-
unhandledErrors: unhandledErrors.map(
|
|
989
|
+
unhandledErrors: extractFailureEntries(unhandledErrors).map(
|
|
990
|
+
(entry) => firstLine(entry.message) ?? entry.message
|
|
991
|
+
)
|
|
749
992
|
});
|
|
750
993
|
const output = formatReactPlaythroughReport(report);
|
|
751
994
|
if (report.failsRun) {
|
|
@@ -756,10 +999,15 @@ var ReactPlaythroughReporter = class {
|
|
|
756
999
|
} else {
|
|
757
1000
|
console.log(output);
|
|
758
1001
|
}
|
|
1002
|
+
const pendingOutput = formatPendingProductTestReport(pendingProductTests);
|
|
1003
|
+
if (pendingOutput) {
|
|
1004
|
+
console.error(pendingOutput);
|
|
1005
|
+
process.exitCode = 1;
|
|
1006
|
+
}
|
|
759
1007
|
const summary = formatReactFailureSummary(
|
|
760
1008
|
testModules.map((module2) => toModuleResult(module2, this.projectRoot))
|
|
761
1009
|
);
|
|
762
|
-
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
1010
|
+
if ((report.failsRun || pendingProductTests.length > 0) && summary[0] === "TEST_RESULT: PASS") {
|
|
763
1011
|
summary[0] = "TEST_RESULT: FAIL";
|
|
764
1012
|
}
|
|
765
1013
|
console.log(`
|