miaoda-game-devkit 0.2.21 → 0.4.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 +5 -4
- package/dist/react/index.d.mts +19 -1
- package/dist/react/index.d.ts +19 -1
- package/dist/react/index.js +50 -0
- package/dist/react/index.mjs +49 -0
- package/dist/react/testing.d.mts +8 -1
- package/dist/react/testing.d.ts +8 -1
- package/dist/react/testing.js +100 -16
- package/dist/react/testing.mjs +100 -16
- package/dist/react/vitest-config.js +195 -32
- package/dist/react/vitest-config.mjs +195 -32
- package/dist/rules/react-test-boundary-plugin.js +97 -0
- package/oxlint-config.json +4 -2
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { defineConfig } from "vitest/config";
|
|
|
6
6
|
// src/react/react-playthrough-reporter.ts
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
8
|
import { relative, resolve } from "path";
|
|
9
|
+
import { stripVTControlCharacters } from "util";
|
|
9
10
|
|
|
10
11
|
// src/react/react-playthrough.ts
|
|
11
12
|
import { render } from "@testing-library/react";
|
|
@@ -68,20 +69,50 @@ var INPUT_EVENTS = [
|
|
|
68
69
|
"touchend"
|
|
69
70
|
];
|
|
70
71
|
var MIN_CHECKPOINTS = 2;
|
|
72
|
+
var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
|
|
73
|
+
function formatObservation(fingerprint) {
|
|
74
|
+
if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
|
|
75
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
76
|
+
}
|
|
77
|
+
function sampleObservation(observe, stage) {
|
|
78
|
+
let value;
|
|
79
|
+
try {
|
|
80
|
+
value = observe();
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new Error(`observe() threw at ${stage}: ${String(error)}`);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const fingerprint = JSON.stringify(value);
|
|
86
|
+
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
87
|
+
return { fingerprint, formatted: formatObservation(fingerprint) };
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function formatObservationTimeline(entered, afterPrimary, outcome) {
|
|
95
|
+
return [
|
|
96
|
+
`entered=${entered.formatted}`,
|
|
97
|
+
`after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
|
|
98
|
+
`outcome=${outcome.formatted}`
|
|
99
|
+
].join(", ");
|
|
100
|
+
}
|
|
71
101
|
function describeMissingEvidence(evidence) {
|
|
72
|
-
if (!evidence || evidence.entryInputs === 0) return "entry
|
|
73
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
102
|
+
if (!evidence || evidence.entryInputs === 0) return "an entry input";
|
|
103
|
+
if (evidence.primaryInputs === 0) return "a primary gameplay input";
|
|
74
104
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
75
|
-
if (evidence.boundedRuns === 0) return "
|
|
76
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
105
|
+
if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
|
|
106
|
+
if (evidence.assertionsAfterOutcome === 0)
|
|
107
|
+
return "an outcome assertion after stepUntil";
|
|
77
108
|
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
78
|
-
return
|
|
109
|
+
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
79
110
|
if (!evidence.checkpoints.some(
|
|
80
111
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
81
112
|
)) {
|
|
82
113
|
return "progress/terminal checkpoint";
|
|
83
114
|
}
|
|
84
|
-
return "
|
|
115
|
+
return "a complete playthrough verification marker";
|
|
85
116
|
}
|
|
86
117
|
function createMetadata(waiverReason) {
|
|
87
118
|
return {
|
|
@@ -98,7 +129,7 @@ function createMetadata(waiverReason) {
|
|
|
98
129
|
}
|
|
99
130
|
};
|
|
100
131
|
}
|
|
101
|
-
function definePlaythrough(element, run, waiverReason) {
|
|
132
|
+
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
102
133
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
103
134
|
const metadata = createMetadata(reason);
|
|
104
135
|
test("production game completes a bounded playthrough", {
|
|
@@ -108,6 +139,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
108
139
|
const evidence = metadata.evidence;
|
|
109
140
|
let assertionsAtOutcome;
|
|
110
141
|
let enteredRecorded = false;
|
|
142
|
+
let domTextAtEntered;
|
|
143
|
+
let enteredObservation;
|
|
144
|
+
let afterPrimaryObservation;
|
|
111
145
|
const recordInput = () => {
|
|
112
146
|
evidence.domInputEvents += 1;
|
|
113
147
|
};
|
|
@@ -145,7 +179,15 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
145
179
|
);
|
|
146
180
|
}
|
|
147
181
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
148
|
-
else
|
|
182
|
+
else {
|
|
183
|
+
evidence.primaryInputs += 1;
|
|
184
|
+
if (playthroughOptions?.observe) {
|
|
185
|
+
afterPrimaryObservation = sampleObservation(
|
|
186
|
+
playthroughOptions.observe,
|
|
187
|
+
"after-primary"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
149
191
|
},
|
|
150
192
|
checkpoint(kind) {
|
|
151
193
|
if (kind === "entered") {
|
|
@@ -160,6 +202,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
160
202
|
);
|
|
161
203
|
}
|
|
162
204
|
enteredRecorded = true;
|
|
205
|
+
if (playthroughOptions?.observe) {
|
|
206
|
+
enteredObservation = sampleObservation(
|
|
207
|
+
playthroughOptions.observe,
|
|
208
|
+
"entered"
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
212
|
+
}
|
|
163
213
|
evidence.checkpoints.push(kind);
|
|
164
214
|
return;
|
|
165
215
|
}
|
|
@@ -180,13 +230,37 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
180
230
|
}
|
|
181
231
|
evidence.checkpoints.push(kind);
|
|
182
232
|
},
|
|
183
|
-
async stepUntil(condition,
|
|
184
|
-
const
|
|
233
|
+
async stepUntil(condition, stepOptions = {}) {
|
|
234
|
+
const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
|
|
235
|
+
...stepOptions,
|
|
236
|
+
diagnostics: playthroughOptions.observe
|
|
237
|
+
};
|
|
238
|
+
const steps = await runBoundedUntil(condition, boundedOptions);
|
|
185
239
|
if (evidence.primaryInputs === 0) {
|
|
186
240
|
throw new Error(
|
|
187
241
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
188
242
|
);
|
|
189
243
|
}
|
|
244
|
+
if (playthroughOptions?.observe) {
|
|
245
|
+
if (!enteredObservation) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
'observe requires checkpoint("entered") before primary gameplay input.'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
const outcomeObservation = sampleObservation(
|
|
251
|
+
playthroughOptions.observe,
|
|
252
|
+
"outcome"
|
|
253
|
+
);
|
|
254
|
+
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
} else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
'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.'
|
|
262
|
+
);
|
|
263
|
+
}
|
|
190
264
|
evidence.boundedRuns += 1;
|
|
191
265
|
assertionsAtOutcome = expect.getState().assertionCalls;
|
|
192
266
|
return steps;
|
|
@@ -228,9 +302,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
228
302
|
});
|
|
229
303
|
}
|
|
230
304
|
var playthroughTest = Object.assign(
|
|
231
|
-
(element,
|
|
305
|
+
(element, optionsOrRun, maybeRun) => {
|
|
306
|
+
if (typeof optionsOrRun === "function") {
|
|
307
|
+
definePlaythrough(element, optionsOrRun);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
|
|
311
|
+
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
312
|
+
},
|
|
232
313
|
{
|
|
233
|
-
skip: (reason, element, run) => definePlaythrough(element, run, reason)
|
|
314
|
+
skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
|
|
234
315
|
}
|
|
235
316
|
);
|
|
236
317
|
function auditReactPlaythroughRun(tests) {
|
|
@@ -247,7 +328,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
247
328
|
const issues = [];
|
|
248
329
|
if (declared.length === 0) {
|
|
249
330
|
issues.push(
|
|
250
|
-
'
|
|
331
|
+
'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").'
|
|
251
332
|
);
|
|
252
333
|
} else {
|
|
253
334
|
for (const candidate of declared) {
|
|
@@ -256,13 +337,17 @@ function auditReactPlaythroughRun(tests) {
|
|
|
256
337
|
if (isValid || isWaived) continue;
|
|
257
338
|
if (candidate.state === "skipped") {
|
|
258
339
|
issues.push(
|
|
259
|
-
|
|
340
|
+
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
260
341
|
);
|
|
261
342
|
} else if (candidate.state !== "passed") {
|
|
262
|
-
issues.push(
|
|
343
|
+
issues.push(
|
|
344
|
+
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
345
|
+
);
|
|
263
346
|
} else {
|
|
264
347
|
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
265
|
-
issues.push(
|
|
348
|
+
issues.push(
|
|
349
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
350
|
+
);
|
|
266
351
|
}
|
|
267
352
|
}
|
|
268
353
|
}
|
|
@@ -289,7 +374,18 @@ function toAuditInput(test2) {
|
|
|
289
374
|
}
|
|
290
375
|
function firstLine(value) {
|
|
291
376
|
if (typeof value !== "string") return void 0;
|
|
292
|
-
return value.split("\n").map((line) => line.trim()).find(Boolean);
|
|
377
|
+
return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
378
|
+
}
|
|
379
|
+
function failureHint(value) {
|
|
380
|
+
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
381
|
+
(match) => match[1]
|
|
382
|
+
);
|
|
383
|
+
if (names.length === 0) return void 0;
|
|
384
|
+
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
385
|
+
}
|
|
386
|
+
function errorLocation(value) {
|
|
387
|
+
const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
|
|
388
|
+
return match?.[1];
|
|
293
389
|
}
|
|
294
390
|
function toModuleResult(module, projectRoot) {
|
|
295
391
|
const tests = [...module.children.allTests()];
|
|
@@ -299,13 +395,71 @@ function toModuleResult(module, projectRoot) {
|
|
|
299
395
|
(test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
|
|
300
396
|
)
|
|
301
397
|
].filter((message) => Boolean(message));
|
|
398
|
+
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
399
|
+
const raw = test2.result().errors?.[0]?.message ?? module.errors()[0]?.message ?? "Unknown failure";
|
|
400
|
+
return {
|
|
401
|
+
test: test2.fullName,
|
|
402
|
+
cause: firstLine(raw) ?? "Unknown failure",
|
|
403
|
+
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
|
|
404
|
+
hint: failureHint(raw)
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
408
|
+
const raw = module.errors()[0]?.message ?? "Module failed to load";
|
|
409
|
+
failures.push({
|
|
410
|
+
test: "<collection>",
|
|
411
|
+
cause: firstLine(raw) ?? "Module failed to load",
|
|
412
|
+
location: errorLocation(raw),
|
|
413
|
+
hint: failureHint(raw)
|
|
414
|
+
});
|
|
415
|
+
}
|
|
302
416
|
return {
|
|
303
417
|
file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
304
418
|
state: module.state(),
|
|
305
419
|
errors,
|
|
306
|
-
tests: tests.map(toAuditInput)
|
|
420
|
+
tests: tests.map(toAuditInput),
|
|
421
|
+
failures
|
|
307
422
|
};
|
|
308
423
|
}
|
|
424
|
+
function formatFailureSummary(modules) {
|
|
425
|
+
const failures = modules.flatMap(
|
|
426
|
+
(module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
|
|
427
|
+
);
|
|
428
|
+
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
429
|
+
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
430
|
+
for (const [index, failure] of failures.entries()) {
|
|
431
|
+
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
432
|
+
lines.push(`TEST: ${failure.test}`);
|
|
433
|
+
lines.push(`CAUSE: ${failure.cause}`);
|
|
434
|
+
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
435
|
+
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
436
|
+
}
|
|
437
|
+
lines.push("TEST_RESULT: FAIL");
|
|
438
|
+
return lines;
|
|
439
|
+
}
|
|
440
|
+
function repairGuidance(cause) {
|
|
441
|
+
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
442
|
+
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.";
|
|
443
|
+
}
|
|
444
|
+
if (/(?:already true|found the outcome) at step 0.*DOM has not changed/i.test(
|
|
445
|
+
cause
|
|
446
|
+
)) {
|
|
447
|
+
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.';
|
|
448
|
+
}
|
|
449
|
+
if (/authoritative observation did not change/i.test(cause)) {
|
|
450
|
+
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.";
|
|
451
|
+
}
|
|
452
|
+
if (/No step callback was provided/i.test(cause)) {
|
|
453
|
+
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.";
|
|
454
|
+
}
|
|
455
|
+
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
456
|
+
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.";
|
|
457
|
+
}
|
|
458
|
+
if (/performInput|checkpoint/.test(cause)) {
|
|
459
|
+
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").';
|
|
460
|
+
}
|
|
461
|
+
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.";
|
|
462
|
+
}
|
|
309
463
|
function assessReactPlaythroughReport(input) {
|
|
310
464
|
const base = { file: input.expectedFile };
|
|
311
465
|
if (!input.expectedFileScheduled) {
|
|
@@ -313,15 +467,15 @@ function assessReactPlaythroughReport(input) {
|
|
|
313
467
|
return {
|
|
314
468
|
...base,
|
|
315
469
|
status: "NOT_CHECKED",
|
|
316
|
-
next:
|
|
470
|
+
next: `This focused run did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
317
471
|
failsRun: false
|
|
318
472
|
};
|
|
319
473
|
}
|
|
320
474
|
return {
|
|
321
475
|
...base,
|
|
322
476
|
status: "FAILED",
|
|
323
|
-
cause: "
|
|
324
|
-
next: '
|
|
477
|
+
cause: "The required production playthrough test file does not exist.",
|
|
478
|
+
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").',
|
|
325
479
|
failsRun: true
|
|
326
480
|
};
|
|
327
481
|
}
|
|
@@ -335,7 +489,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
335
489
|
return {
|
|
336
490
|
...base,
|
|
337
491
|
status: "NOT_CHECKED",
|
|
338
|
-
next:
|
|
492
|
+
next: `The name or line filter did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
339
493
|
failsRun: false
|
|
340
494
|
};
|
|
341
495
|
}
|
|
@@ -343,23 +497,20 @@ function assessReactPlaythroughReport(input) {
|
|
|
343
497
|
return {
|
|
344
498
|
...base,
|
|
345
499
|
status: "NOT_RUN",
|
|
346
|
-
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "
|
|
347
|
-
next: "
|
|
500
|
+
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
501
|
+
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.",
|
|
348
502
|
failsRun: true
|
|
349
503
|
};
|
|
350
504
|
}
|
|
351
505
|
const tests = input.modules.flatMap((module) => module.tests);
|
|
352
506
|
const audit = auditReactPlaythroughRun(tests);
|
|
353
507
|
if (!audit.passed) {
|
|
354
|
-
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "
|
|
355
|
-
const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
|
|
356
|
-
const missingStep = /No step callback was provided/i.test(cause);
|
|
357
|
-
const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
|
|
508
|
+
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
358
509
|
return {
|
|
359
510
|
...base,
|
|
360
511
|
status: "FAILED",
|
|
361
512
|
cause,
|
|
362
|
-
next:
|
|
513
|
+
next: repairGuidance(cause),
|
|
363
514
|
failsRun: true
|
|
364
515
|
};
|
|
365
516
|
}
|
|
@@ -377,7 +528,7 @@ function formatReactPlaythroughReport(report) {
|
|
|
377
528
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
378
529
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
379
530
|
if (report.waiverReasons?.length) {
|
|
380
|
-
lines.push(`REASON: ${report.waiverReasons.join("
|
|
531
|
+
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
381
532
|
}
|
|
382
533
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
383
534
|
return `
|
|
@@ -424,6 +575,14 @@ var ReactPlaythroughReporter = class {
|
|
|
424
575
|
} else {
|
|
425
576
|
console.log(output);
|
|
426
577
|
}
|
|
578
|
+
const summary = formatFailureSummary(
|
|
579
|
+
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
580
|
+
);
|
|
581
|
+
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
582
|
+
summary[0] = "TEST_RESULT: FAIL";
|
|
583
|
+
}
|
|
584
|
+
console.log(`
|
|
585
|
+
${summary.join("\n")}`);
|
|
427
586
|
}
|
|
428
587
|
};
|
|
429
588
|
|
|
@@ -452,6 +611,9 @@ function resolvePhaser3BrowserEntry(projectRoot) {
|
|
|
452
611
|
function defineReactGameVitestConfig(options) {
|
|
453
612
|
const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
|
|
454
613
|
return defineConfig({
|
|
614
|
+
// Keep discovery and dependency resolution anchored to the generated app even
|
|
615
|
+
// when an external runner invokes Vitest from a parent workspace directory.
|
|
616
|
+
root: options.projectRoot,
|
|
455
617
|
resolve: {
|
|
456
618
|
alias: {
|
|
457
619
|
...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
|
|
@@ -472,6 +634,7 @@ function defineReactGameVitestConfig(options) {
|
|
|
472
634
|
environmentOptions: {
|
|
473
635
|
jsdom: { url: "http://localhost/", pretendToBeVisual: true }
|
|
474
636
|
},
|
|
637
|
+
includeTaskLocation: true,
|
|
475
638
|
setupFiles: [
|
|
476
639
|
"miaoda-game-devkit/react/vitest-setup",
|
|
477
640
|
...options.additionalSetupFiles ?? []
|
|
@@ -479,8 +642,8 @@ function defineReactGameVitestConfig(options) {
|
|
|
479
642
|
sequence: {
|
|
480
643
|
setupFiles: "list"
|
|
481
644
|
},
|
|
482
|
-
//
|
|
483
|
-
reporters: [
|
|
645
|
+
// 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
|
|
646
|
+
reporters: [new ReactPlaythroughReporter(options.projectRoot)],
|
|
484
647
|
restoreMocks: true,
|
|
485
648
|
clearMocks: true,
|
|
486
649
|
testTimeout: options.testTimeout,
|
|
@@ -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
|
}
|