miaoda-game-devkit 0.3.0 → 0.5.0
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/README.md +13 -4
- package/dist/react/index.js +1 -1
- package/dist/react/index.mjs +1 -1
- package/dist/react/testing.d.mts +10 -11
- package/dist/react/testing.d.ts +10 -11
- package/dist/react/testing.js +174 -33
- package/dist/react/testing.mjs +174 -33
- package/dist/react/vitest-config.js +236 -52
- package/dist/react/vitest-config.mjs +236 -52
- package/dist/rules/react-test-boundary-plugin.js +97 -0
- package/oxlint-config.json +4 -2
- package/package.json +1 -1
|
@@ -69,47 +69,123 @@ var INPUT_EVENTS = [
|
|
|
69
69
|
"touchend"
|
|
70
70
|
];
|
|
71
71
|
var MIN_CHECKPOINTS = 2;
|
|
72
|
+
var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
|
|
73
|
+
var MAX_TRACE_VALUE_LENGTH = 180;
|
|
74
|
+
var MAX_TRACE_LENGTH = 720;
|
|
75
|
+
function truncateTraceValue(value, limit) {
|
|
76
|
+
const compact = value.replace(/\s+/g, " ").trim();
|
|
77
|
+
if (compact.length <= limit) return compact;
|
|
78
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
79
|
+
}
|
|
80
|
+
function formatReactPlaythroughFailureTrace(trace) {
|
|
81
|
+
const stages = [
|
|
82
|
+
["entered", trace.entered],
|
|
83
|
+
["after-primary", trace.afterPrimary],
|
|
84
|
+
["last", trace.last]
|
|
85
|
+
].filter((stage) => stage[1] !== void 0).map(
|
|
86
|
+
([stage, value]) => `${stage}=${truncateTraceValue(value, MAX_TRACE_VALUE_LENGTH)}`
|
|
87
|
+
);
|
|
88
|
+
const details = [
|
|
89
|
+
trace.checkpoints.length > 0 ? `checkpoints=${trace.checkpoints.join(",")}` : "checkpoints=none",
|
|
90
|
+
trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0
|
|
91
|
+
].filter((detail) => Boolean(detail));
|
|
92
|
+
const formatted = `${stages.join(" -> ")}${stages.length ? "; " : ""}${details.join("; ")}`;
|
|
93
|
+
return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
|
|
94
|
+
}
|
|
95
|
+
var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
|
|
96
|
+
function formatObservation(fingerprint) {
|
|
97
|
+
if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
|
|
98
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
99
|
+
}
|
|
100
|
+
function sampleObservation(observe, stage) {
|
|
101
|
+
let value;
|
|
102
|
+
try {
|
|
103
|
+
value = observe();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`observe() threw at ${stage}: ${String(error)}`);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const fingerprint = JSON.stringify(value);
|
|
109
|
+
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
110
|
+
return { fingerprint, formatted: formatObservation(fingerprint) };
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function formatObservationTimeline(entered, afterPrimary, outcome) {
|
|
118
|
+
return [
|
|
119
|
+
`entered=${entered.formatted}`,
|
|
120
|
+
`after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
|
|
121
|
+
`outcome=${outcome.formatted}`
|
|
122
|
+
].join(", ");
|
|
123
|
+
}
|
|
72
124
|
function describeMissingEvidence(evidence) {
|
|
73
|
-
if (!evidence || evidence.entryInputs === 0) return "entry
|
|
74
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
125
|
+
if (!evidence || evidence.entryInputs === 0) return "an entry input";
|
|
126
|
+
if (evidence.primaryInputs === 0) return "a primary gameplay input";
|
|
75
127
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
76
|
-
if (evidence.boundedRuns === 0) return "
|
|
77
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
128
|
+
if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
|
|
129
|
+
if (evidence.assertionsAfterOutcome === 0)
|
|
130
|
+
return "an outcome assertion after stepUntil";
|
|
78
131
|
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
79
|
-
return
|
|
132
|
+
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
80
133
|
if (!evidence.checkpoints.some(
|
|
81
134
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
82
135
|
)) {
|
|
83
136
|
return "progress/terminal checkpoint";
|
|
84
137
|
}
|
|
85
|
-
return "
|
|
138
|
+
return "a complete playthrough verification marker";
|
|
86
139
|
}
|
|
87
|
-
function
|
|
140
|
+
function createEvidence() {
|
|
88
141
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
assertionsAfterOutcome: 0,
|
|
97
|
-
checkpoints: [],
|
|
98
|
-
verified: false
|
|
99
|
-
}
|
|
142
|
+
domInputEvents: 0,
|
|
143
|
+
entryInputs: 0,
|
|
144
|
+
primaryInputs: 0,
|
|
145
|
+
boundedRuns: 0,
|
|
146
|
+
assertionsAfterOutcome: 0,
|
|
147
|
+
checkpoints: [],
|
|
148
|
+
verified: false
|
|
100
149
|
};
|
|
101
150
|
}
|
|
102
|
-
function
|
|
151
|
+
function createMetadata(waiverReason) {
|
|
152
|
+
return { version: 3, waiverReason, evidence: createEvidence() };
|
|
153
|
+
}
|
|
154
|
+
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
103
155
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
104
156
|
const metadata = createMetadata(reason);
|
|
105
157
|
test("production game completes a bounded playthrough", {
|
|
106
158
|
skip: Boolean(reason),
|
|
107
159
|
meta: { reactPlaythrough: metadata }
|
|
108
|
-
}, async ({ expect }) => {
|
|
160
|
+
}, async ({ annotate, expect }) => {
|
|
161
|
+
metadata.evidence = createEvidence();
|
|
162
|
+
metadata.trace = void 0;
|
|
109
163
|
const evidence = metadata.evidence;
|
|
110
164
|
let assertionsAtOutcome;
|
|
111
165
|
let enteredRecorded = false;
|
|
112
166
|
let domTextAtEntered;
|
|
167
|
+
let enteredObservation;
|
|
168
|
+
let afterPrimaryObservation;
|
|
169
|
+
let enteredTrace;
|
|
170
|
+
let afterPrimaryTrace;
|
|
171
|
+
let outcomeTrace;
|
|
172
|
+
let stepTrace;
|
|
173
|
+
const sampleDomTrace = () => JSON.stringify((document.body.textContent ?? "").replace(/\s+/g, " ").trim());
|
|
174
|
+
const sampleLastTrace = () => {
|
|
175
|
+
if (!playthroughOptions?.observe) return sampleDomTrace();
|
|
176
|
+
try {
|
|
177
|
+
return sampleObservation(playthroughOptions.observe, "outcome").formatted;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
return `<observe unavailable: ${String(error)}>`;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const createFailureTrace = () => formatReactPlaythroughFailureTrace({
|
|
183
|
+
entered: enteredTrace,
|
|
184
|
+
afterPrimary: afterPrimaryTrace,
|
|
185
|
+
last: outcomeTrace ?? sampleLastTrace(),
|
|
186
|
+
checkpoints: [...evidence.checkpoints],
|
|
187
|
+
step: stepTrace
|
|
188
|
+
});
|
|
113
189
|
const recordInput = () => {
|
|
114
190
|
evidence.domInputEvents += 1;
|
|
115
191
|
};
|
|
@@ -147,7 +223,18 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
147
223
|
);
|
|
148
224
|
}
|
|
149
225
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
150
|
-
else
|
|
226
|
+
else {
|
|
227
|
+
evidence.primaryInputs += 1;
|
|
228
|
+
if (playthroughOptions?.observe) {
|
|
229
|
+
afterPrimaryObservation = sampleObservation(
|
|
230
|
+
playthroughOptions.observe,
|
|
231
|
+
"after-primary"
|
|
232
|
+
);
|
|
233
|
+
afterPrimaryTrace = afterPrimaryObservation.formatted;
|
|
234
|
+
} else {
|
|
235
|
+
afterPrimaryTrace = sampleDomTrace();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
151
238
|
},
|
|
152
239
|
checkpoint(kind) {
|
|
153
240
|
if (kind === "entered") {
|
|
@@ -162,7 +249,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
162
249
|
);
|
|
163
250
|
}
|
|
164
251
|
enteredRecorded = true;
|
|
165
|
-
|
|
252
|
+
if (playthroughOptions?.observe) {
|
|
253
|
+
enteredObservation = sampleObservation(
|
|
254
|
+
playthroughOptions.observe,
|
|
255
|
+
"entered"
|
|
256
|
+
);
|
|
257
|
+
enteredTrace = enteredObservation.formatted;
|
|
258
|
+
} else {
|
|
259
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
260
|
+
enteredTrace = sampleDomTrace();
|
|
261
|
+
}
|
|
166
262
|
evidence.checkpoints.push(kind);
|
|
167
263
|
return;
|
|
168
264
|
}
|
|
@@ -183,17 +279,43 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
183
279
|
}
|
|
184
280
|
evidence.checkpoints.push(kind);
|
|
185
281
|
},
|
|
186
|
-
async stepUntil(condition,
|
|
187
|
-
const
|
|
282
|
+
async stepUntil(condition, stepOptions = {}) {
|
|
283
|
+
const stepBound = stepOptions.maxSteps ?? 120;
|
|
284
|
+
stepTrace = { bound: stepBound };
|
|
285
|
+
const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
|
|
286
|
+
...stepOptions,
|
|
287
|
+
diagnostics: playthroughOptions.observe
|
|
288
|
+
};
|
|
289
|
+
const steps = await runBoundedUntil(condition, boundedOptions);
|
|
290
|
+
stepTrace = { bound: stepBound, completed: steps };
|
|
188
291
|
if (evidence.primaryInputs === 0) {
|
|
189
292
|
throw new Error(
|
|
190
293
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
191
294
|
);
|
|
192
295
|
}
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
296
|
+
if (playthroughOptions?.observe) {
|
|
297
|
+
if (!enteredObservation) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
'observe requires checkpoint("entered") before primary gameplay input.'
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const outcomeObservation = sampleObservation(
|
|
303
|
+
playthroughOptions.observe,
|
|
304
|
+
"outcome"
|
|
196
305
|
);
|
|
306
|
+
outcomeTrace = outcomeObservation.formatted;
|
|
307
|
+
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
outcomeTrace = sampleDomTrace();
|
|
314
|
+
if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
|
|
317
|
+
);
|
|
318
|
+
}
|
|
197
319
|
}
|
|
198
320
|
evidence.boundedRuns += 1;
|
|
199
321
|
assertionsAtOutcome = expect.getState().assertionCalls;
|
|
@@ -228,7 +350,15 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
228
350
|
);
|
|
229
351
|
}
|
|
230
352
|
evidence.verified = true;
|
|
353
|
+
} catch (error) {
|
|
354
|
+
metadata.trace = createFailureTrace();
|
|
355
|
+
try {
|
|
356
|
+
await annotate(metadata.trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
231
360
|
} finally {
|
|
361
|
+
metadata.trace ??= createFailureTrace();
|
|
232
362
|
for (const event of INPUT_EVENTS) {
|
|
233
363
|
document.removeEventListener(event, recordInput, true);
|
|
234
364
|
}
|
|
@@ -236,9 +366,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
236
366
|
});
|
|
237
367
|
}
|
|
238
368
|
var playthroughTest = Object.assign(
|
|
239
|
-
(element,
|
|
369
|
+
(element, optionsOrRun, maybeRun) => {
|
|
370
|
+
if (typeof optionsOrRun === "function") {
|
|
371
|
+
definePlaythrough(element, optionsOrRun);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
|
|
375
|
+
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
376
|
+
},
|
|
240
377
|
{
|
|
241
|
-
skip: (reason, element, run) => definePlaythrough(element, run, reason)
|
|
378
|
+
skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
|
|
242
379
|
}
|
|
243
380
|
);
|
|
244
381
|
function auditReactPlaythroughRun(tests) {
|
|
@@ -255,7 +392,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
255
392
|
const issues = [];
|
|
256
393
|
if (declared.length === 0) {
|
|
257
394
|
issues.push(
|
|
258
|
-
'
|
|
395
|
+
'No production gameplay verification was declared. Use playthroughTest to render <App />, then run performInput("entry"), checkpoint("entered"), performInput("primary"), and a bounded stepUntil. Assert the authoritative outcome with the expect provided by playthroughTest, then record checkpoint("progress") or checkpoint("terminal").'
|
|
259
396
|
);
|
|
260
397
|
} else {
|
|
261
398
|
for (const candidate of declared) {
|
|
@@ -264,13 +401,17 @@ function auditReactPlaythroughRun(tests) {
|
|
|
264
401
|
if (isValid || isWaived) continue;
|
|
265
402
|
if (candidate.state === "skipped") {
|
|
266
403
|
issues.push(
|
|
267
|
-
|
|
404
|
+
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
268
405
|
);
|
|
269
406
|
} else if (candidate.state !== "passed") {
|
|
270
|
-
issues.push(
|
|
407
|
+
issues.push(
|
|
408
|
+
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
409
|
+
);
|
|
271
410
|
} else {
|
|
272
411
|
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
273
|
-
issues.push(
|
|
412
|
+
issues.push(
|
|
413
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
414
|
+
);
|
|
274
415
|
}
|
|
275
416
|
}
|
|
276
417
|
}
|
|
@@ -310,21 +451,41 @@ function errorLocation(value) {
|
|
|
310
451
|
const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
|
|
311
452
|
return match?.[1];
|
|
312
453
|
}
|
|
454
|
+
function failureTrace(test2) {
|
|
455
|
+
const annotations = test2.annotations();
|
|
456
|
+
let annotationTrace;
|
|
457
|
+
for (let index = annotations.length - 1; index >= 0; index -= 1) {
|
|
458
|
+
if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
|
|
459
|
+
annotationTrace = annotations[index].message;
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const metadata = test2.meta().reactPlaythrough;
|
|
464
|
+
const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
|
|
465
|
+
return trace ? truncateReporterLine(trace, 720) : void 0;
|
|
466
|
+
}
|
|
467
|
+
function truncateReporterLine(value, limit) {
|
|
468
|
+
const compact = stripVTControlCharacters(value).replace(/\s+/g, " ").trim();
|
|
469
|
+
if (compact.length <= limit) return compact;
|
|
470
|
+
return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
|
|
471
|
+
}
|
|
313
472
|
function toModuleResult(module, projectRoot) {
|
|
314
473
|
const tests = [...module.children.allTests()];
|
|
474
|
+
const moduleErrors = module.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
|
|
315
475
|
const errors = [
|
|
316
|
-
...
|
|
476
|
+
...moduleErrors,
|
|
317
477
|
...tests.flatMap(
|
|
318
478
|
(test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
|
|
319
479
|
)
|
|
320
480
|
].filter((message) => Boolean(message));
|
|
321
481
|
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
322
|
-
const raw = test2.result().errors?.
|
|
482
|
+
const raw = test2.result().errors?.at(-1)?.message ?? module.errors()[0]?.message ?? "Unknown failure";
|
|
323
483
|
return {
|
|
324
484
|
test: test2.fullName,
|
|
325
485
|
cause: firstLine(raw) ?? "Unknown failure",
|
|
326
486
|
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
|
|
327
|
-
hint: failureHint(raw)
|
|
487
|
+
hint: failureHint(raw),
|
|
488
|
+
trace: failureTrace(test2)
|
|
328
489
|
};
|
|
329
490
|
});
|
|
330
491
|
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
@@ -340,11 +501,12 @@ function toModuleResult(module, projectRoot) {
|
|
|
340
501
|
file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
341
502
|
state: module.state(),
|
|
342
503
|
errors,
|
|
504
|
+
primaryError: moduleErrors[0] ?? failures[0]?.cause,
|
|
343
505
|
tests: tests.map(toAuditInput),
|
|
344
506
|
failures
|
|
345
507
|
};
|
|
346
508
|
}
|
|
347
|
-
function
|
|
509
|
+
function formatReactFailureSummary(modules) {
|
|
348
510
|
const failures = modules.flatMap(
|
|
349
511
|
(module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
|
|
350
512
|
);
|
|
@@ -354,12 +516,36 @@ function formatFailureSummary(modules) {
|
|
|
354
516
|
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
355
517
|
lines.push(`TEST: ${failure.test}`);
|
|
356
518
|
lines.push(`CAUSE: ${failure.cause}`);
|
|
519
|
+
if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
|
|
357
520
|
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
358
521
|
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
359
522
|
}
|
|
360
523
|
lines.push("TEST_RESULT: FAIL");
|
|
361
524
|
return lines;
|
|
362
525
|
}
|
|
526
|
+
function repairGuidance(cause) {
|
|
527
|
+
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
528
|
+
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.";
|
|
529
|
+
}
|
|
530
|
+
if (/(?:already true|found the outcome) at step 0.*DOM has not changed/i.test(
|
|
531
|
+
cause
|
|
532
|
+
)) {
|
|
533
|
+
return 'Make the stepUntil condition false at checkpoint("entered"). Verify that the primary input reaches the production control, then wait for a post-input outcome such as a changed score, a removed entry overlay, a completed turn, or a result screen. For Canvas or Controller state outside the DOM, declare observe once on playthroughTest and return the read-only production Telemetry snapshot.';
|
|
534
|
+
}
|
|
535
|
+
if (/authoritative observation did not change/i.test(cause)) {
|
|
536
|
+
return "The flow reached its condition while observe still returned the same authoritative state. Make observe read the same production Controller that React renders, verify the primary input changes that Controller, and wait for a post-input result. Inspect the Timeline values to locate the disconnected stage.";
|
|
537
|
+
}
|
|
538
|
+
if (/No step callback was provided/i.test(cause)) {
|
|
539
|
+
return "This flow is driven by time or frames, but stepUntil 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.";
|
|
540
|
+
}
|
|
541
|
+
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
542
|
+
return "The real input was dispatched, but gameplay did not reach the outcome within the bound. Confirm that the production control received the input, then inspect Last diagnostics to determine whether the game loop, rule state, or UI synchronization failed to advance.";
|
|
543
|
+
}
|
|
544
|
+
if (/performInput|checkpoint/.test(cause)) {
|
|
545
|
+
return 'Complete the evidence sequence in order: performInput("entry"), checkpoint("entered"), performInput("primary"), bounded stepUntil, an authoritative result assertion using the provided expect, then checkpoint("progress") or checkpoint("terminal").';
|
|
546
|
+
}
|
|
547
|
+
return "Start from the production entry, dispatch real DOM input, and use stepUntil to reach a bounded player-visible or authoritative game outcome before asserting it. Do not jump to an internal level or mutate gameplay state.";
|
|
548
|
+
}
|
|
363
549
|
function assessReactPlaythroughReport(input) {
|
|
364
550
|
const base = { file: input.expectedFile };
|
|
365
551
|
if (!input.expectedFileScheduled) {
|
|
@@ -367,15 +553,15 @@ function assessReactPlaythroughReport(input) {
|
|
|
367
553
|
return {
|
|
368
554
|
...base,
|
|
369
555
|
status: "NOT_CHECKED",
|
|
370
|
-
next:
|
|
556
|
+
next: `This focused run did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
371
557
|
failsRun: false
|
|
372
558
|
};
|
|
373
559
|
}
|
|
374
560
|
return {
|
|
375
561
|
...base,
|
|
376
562
|
status: "FAILED",
|
|
377
|
-
cause: "
|
|
378
|
-
next: '
|
|
563
|
+
cause: "The required production playthrough test file does not exist.",
|
|
564
|
+
next: 'Create the file and render <App />. Drive a legal entry with performInput("entry"), record checkpoint("entered"), perform a core game action with performInput("primary"), and use a bounded stepUntil. Assert the authoritative result with the provided expect, then record checkpoint("progress") or checkpoint("terminal").',
|
|
379
565
|
failsRun: true
|
|
380
566
|
};
|
|
381
567
|
}
|
|
@@ -389,7 +575,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
389
575
|
return {
|
|
390
576
|
...base,
|
|
391
577
|
status: "NOT_CHECKED",
|
|
392
|
-
next:
|
|
578
|
+
next: `The name or line filter did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
393
579
|
failsRun: false
|
|
394
580
|
};
|
|
395
581
|
}
|
|
@@ -397,25 +583,20 @@ function assessReactPlaythroughReport(input) {
|
|
|
397
583
|
return {
|
|
398
584
|
...base,
|
|
399
585
|
status: "NOT_RUN",
|
|
400
|
-
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "
|
|
401
|
-
next: "
|
|
586
|
+
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
587
|
+
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.",
|
|
402
588
|
failsRun: true
|
|
403
589
|
};
|
|
404
590
|
}
|
|
405
591
|
const tests = input.modules.flatMap((module) => module.tests);
|
|
406
592
|
const audit = auditReactPlaythroughRun(tests);
|
|
407
593
|
if (!audit.passed) {
|
|
408
|
-
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "
|
|
409
|
-
const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
|
|
410
|
-
const missingStep = /No step callback was provided/i.test(cause);
|
|
411
|
-
const staticOutcome = /already true at step 0 and the DOM has not changed/i.test(cause);
|
|
412
|
-
const staleSnapshot = /snapshot\(\) returned the same reference/i.test(cause);
|
|
413
|
-
const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
|
|
594
|
+
const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
414
595
|
return {
|
|
415
596
|
...base,
|
|
416
597
|
status: "FAILED",
|
|
417
598
|
cause,
|
|
418
|
-
next:
|
|
599
|
+
next: repairGuidance(cause),
|
|
419
600
|
failsRun: true
|
|
420
601
|
};
|
|
421
602
|
}
|
|
@@ -433,7 +614,7 @@ function formatReactPlaythroughReport(report) {
|
|
|
433
614
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
434
615
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
435
616
|
if (report.waiverReasons?.length) {
|
|
436
|
-
lines.push(`REASON: ${report.waiverReasons.join("
|
|
617
|
+
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
437
618
|
}
|
|
438
619
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
439
620
|
return `
|
|
@@ -480,7 +661,7 @@ var ReactPlaythroughReporter = class {
|
|
|
480
661
|
} else {
|
|
481
662
|
console.log(output);
|
|
482
663
|
}
|
|
483
|
-
const summary =
|
|
664
|
+
const summary = formatReactFailureSummary(
|
|
484
665
|
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
485
666
|
);
|
|
486
667
|
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
@@ -516,6 +697,9 @@ function resolvePhaser3BrowserEntry(projectRoot) {
|
|
|
516
697
|
function defineReactGameVitestConfig(options) {
|
|
517
698
|
const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
|
|
518
699
|
return defineConfig({
|
|
700
|
+
// Keep discovery and dependency resolution anchored to the generated app even
|
|
701
|
+
// when an external runner invokes Vitest from a parent workspace directory.
|
|
702
|
+
root: options.projectRoot,
|
|
519
703
|
resolve: {
|
|
520
704
|
alias: {
|
|
521
705
|
...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/rules/react-test-boundary-plugin.ts
|
|
21
|
+
var react_test_boundary_plugin_exports = {};
|
|
22
|
+
__export(react_test_boundary_plugin_exports, {
|
|
23
|
+
default: () => react_test_boundary_plugin_default
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(react_test_boundary_plugin_exports);
|
|
26
|
+
var PRODUCTION_PLAYTHROUGH = "/tests/production-playthrough.test.tsx";
|
|
27
|
+
function normalizedFilename(filename) {
|
|
28
|
+
return filename.replaceAll("\\", "/");
|
|
29
|
+
}
|
|
30
|
+
function importedName(specifier) {
|
|
31
|
+
const imported = specifier.imported;
|
|
32
|
+
return imported.type === "Identifier" ? imported.name : String(imported.value);
|
|
33
|
+
}
|
|
34
|
+
function internalGameImports(node) {
|
|
35
|
+
if (typeof node.source.value !== "string") return [];
|
|
36
|
+
if (!node.source.value.startsWith("@/game/")) return [];
|
|
37
|
+
if (node.importKind === "type") return [];
|
|
38
|
+
return node.specifiers.flatMap((specifier) => {
|
|
39
|
+
const importKind = specifier.importKind;
|
|
40
|
+
return importKind === "type" ? [] : [specifier.local.name];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
var rule = {
|
|
44
|
+
meta: {
|
|
45
|
+
type: "problem",
|
|
46
|
+
docs: {
|
|
47
|
+
description: "Protect production React game and playthrough boundaries"
|
|
48
|
+
},
|
|
49
|
+
messages: {
|
|
50
|
+
boundExpect: "Use the expect provided by playthroughTest. An imported Vitest expect may belong to a different module instance and cannot provide reliable assertion evidence.",
|
|
51
|
+
providedUser: "Use the user provided by playthroughTest; do not import or create another userEvent instance in the production playthrough.",
|
|
52
|
+
productionTestingImport: "Production source must not import miaoda-game-devkit/react/testing. Inject test clocks and observers through the production App factory boundary.",
|
|
53
|
+
productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly."
|
|
54
|
+
},
|
|
55
|
+
schema: []
|
|
56
|
+
},
|
|
57
|
+
create(context) {
|
|
58
|
+
const filename = normalizedFilename(context.filename);
|
|
59
|
+
const isProductionPlaythrough = filename.endsWith(PRODUCTION_PLAYTHROUGH);
|
|
60
|
+
const isProductionSource = filename.includes("/src/");
|
|
61
|
+
const internalGameBindings = /* @__PURE__ */ new Set();
|
|
62
|
+
return {
|
|
63
|
+
ImportDeclaration(node) {
|
|
64
|
+
const source = node.source.value;
|
|
65
|
+
if (typeof source !== "string") return;
|
|
66
|
+
if (isProductionSource && source === "miaoda-game-devkit/react/testing") {
|
|
67
|
+
context.report({ node: node.source, messageId: "productionTestingImport" });
|
|
68
|
+
}
|
|
69
|
+
if (!isProductionPlaythrough) return;
|
|
70
|
+
if (source === "vitest" && node.specifiers.some(
|
|
71
|
+
(specifier) => specifier.type === "ImportSpecifier" && importedName(specifier) === "expect"
|
|
72
|
+
)) {
|
|
73
|
+
context.report({ node: node.source, messageId: "boundExpect" });
|
|
74
|
+
}
|
|
75
|
+
if (source === "@testing-library/user-event") {
|
|
76
|
+
context.report({ node: node.source, messageId: "providedUser" });
|
|
77
|
+
}
|
|
78
|
+
for (const name of internalGameImports(node)) {
|
|
79
|
+
internalGameBindings.add(name);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
JSXOpeningElement(node) {
|
|
83
|
+
if (!isProductionPlaythrough) return;
|
|
84
|
+
const opening = node;
|
|
85
|
+
if (opening.name?.type === "JSXIdentifier" && opening.name.name && internalGameBindings.has(opening.name.name)) {
|
|
86
|
+
context.report({ node, messageId: "productionEntry" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
var plugin = {
|
|
93
|
+
meta: { name: "react-game-boundaries" },
|
|
94
|
+
rules: { "no-test-bypass": rule }
|
|
95
|
+
};
|
|
96
|
+
var react_test_boundary_plugin_default = plugin;
|
|
97
|
+
module.exports = module.exports.default;
|
package/oxlint-config.json
CHANGED
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
},
|
|
6
6
|
"jsPlugins": [
|
|
7
7
|
"./dist/rules/check-image-import-plugin.js",
|
|
8
|
-
"./dist/rules/check-style-import-plugin.js"
|
|
8
|
+
"./dist/rules/check-style-import-plugin.js",
|
|
9
|
+
"./dist/rules/react-test-boundary-plugin.js"
|
|
9
10
|
],
|
|
10
11
|
"rules": {
|
|
11
12
|
"check-image-exists/no-missing-image": "error",
|
|
12
|
-
"check-style-exists/no-missing-style": "error"
|
|
13
|
+
"check-style-exists/no-missing-style": "error",
|
|
14
|
+
"react-game-boundaries/no-test-bypass": "error"
|
|
13
15
|
}
|
|
14
16
|
}
|