miaoda-game-devkit 0.3.0 → 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 +3 -2
- package/dist/react/index.js +1 -1
- package/dist/react/index.mjs +1 -1
- package/dist/react/testing.d.mts +8 -11
- package/dist/react/testing.d.ts +8 -11
- package/dist/react/testing.js +96 -19
- package/dist/react/testing.mjs +96 -19
- package/dist/react/vitest-config.js +131 -33
- package/dist/react/vitest-config.mjs +131 -33
- package/dist/rules/react-test-boundary-plugin.js +97 -0
- package/oxlint-config.json +4 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,13 +18,14 @@
|
|
|
18
18
|
这两次 checkpoint;checkpoint 只负责签到,不携带或验证状态。大型游戏仍只保留一条最短关键流程;
|
|
19
19
|
分支、关卡规则和恢复清理由专门测试覆盖。不要再次调用 `userEvent.setup()`。
|
|
20
20
|
时间、帧或自定义 scheduler 流程必须显式传入确定性 `step`;精确物理时间、暂停恢复
|
|
21
|
-
或调度清理测试可使用 `ManualGameClock`。
|
|
21
|
+
或调度清理测试可使用 `ManualGameClock`。Canvas/Controller 流程在 `playthroughTest` 上声明一次
|
|
22
|
+
`observe`,返回 JSON 可序列化的权威 Telemetry 状态;框架自动比较流程状态并复用为超时诊断。
|
|
22
23
|
|
|
23
24
|
Vitest 的 fake timers(包括 `vi.advanceTimersToNextFrame()`)适合在 devkit 自身的
|
|
24
25
|
专门 scheduler 合约中使用,但不在所有 production playthrough 中全局启用;React
|
|
25
26
|
Testing Library 的 `asyncWrapper` 和 user-event 内部 timer 会因此互相影响。使用自定义
|
|
26
27
|
scheduler、Worker 或未通过浏览器 timer 接入的引擎时,应传显式 `step` 并通过
|
|
27
|
-
`diagnostics`
|
|
28
|
+
`diagnostics` 暴露权威状态;已声明 `observe` 的流程无需重复提供 `diagnostics`。
|
|
28
29
|
|
|
29
30
|
- React Vitest 在项目声明 Phaser 3 时使用其 browser bundle,并提供仅供模块能力探测的
|
|
30
31
|
最小 Canvas 2D 边界,使 Phaser 3 代码不会阻断普通 JSDOM 测试;Storage 使用 JSDOM
|
package/dist/react/index.js
CHANGED
|
@@ -92,7 +92,7 @@ function useSnapshotIntegrityCheck(game) {
|
|
|
92
92
|
if (previousJson !== void 0 && nextJson !== previousJson) {
|
|
93
93
|
reported.current = true;
|
|
94
94
|
throw new Error(
|
|
95
|
-
"Game state changed while snapshot() returned the same reference
|
|
95
|
+
"Game state changed while snapshot() returned the same reference. React compares snapshots by reference and skips the render when Object.is(previous, next) is true, so the UI freezes. Publish a new top-level snapshot before each notification, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot."
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
previousJson = nextJson;
|
package/dist/react/index.mjs
CHANGED
|
@@ -64,7 +64,7 @@ function useSnapshotIntegrityCheck(game) {
|
|
|
64
64
|
if (previousJson !== void 0 && nextJson !== previousJson) {
|
|
65
65
|
reported.current = true;
|
|
66
66
|
throw new Error(
|
|
67
|
-
"Game state changed while snapshot() returned the same reference
|
|
67
|
+
"Game state changed while snapshot() returned the same reference. React compares snapshots by reference and skips the render when Object.is(previous, next) is true, so the UI freezes. Publish a new top-level snapshot before each notification, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot."
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
previousJson = nextJson;
|
package/dist/react/testing.d.mts
CHANGED
|
@@ -33,16 +33,6 @@ interface StepUntilOptions {
|
|
|
33
33
|
* The value is sampled only when the bound is exhausted.
|
|
34
34
|
*/
|
|
35
35
|
diagnostics?: () => unknown;
|
|
36
|
-
/**
|
|
37
|
-
* 默认有一道检查:如果等待的结果"不用玩就已经成立"(第 0 步即成立,
|
|
38
|
-
* 且页面文本从进入游戏起一个字都没变),说明这个结果跟游戏过程无关,
|
|
39
|
-
* 判为失败——它能抓住输入没接上、界面卡死、断言了静态标题这类假通过。
|
|
40
|
-
*
|
|
41
|
-
* 例外:Canvas 游戏的结果画在画布上,页面文本本来就不会变。这类游戏
|
|
42
|
-
* 观察的是 Telemetry 等页面外的权威状态,此时置 true 跳过该检查。
|
|
43
|
-
* 结果通过 DOM 呈现的游戏不要传。
|
|
44
|
-
*/
|
|
45
|
-
allowStaticDom?: boolean;
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
/** 单条 React 主流程测试留下的可序列化运行期证据。 */
|
|
@@ -76,6 +66,11 @@ interface ReactPlaythroughMetadata {
|
|
|
76
66
|
type ReactPlaythroughInputKind = "entry" | "primary";
|
|
77
67
|
/** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
|
|
78
68
|
type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
|
|
69
|
+
/** Canvas/Controller flows declare one authoritative read; simple DOM flows omit it. */
|
|
70
|
+
interface ReactPlaythroughOptions {
|
|
71
|
+
/** Stable, JSON-serializable production state sampled across the audited flow. */
|
|
72
|
+
observe: () => unknown;
|
|
73
|
+
}
|
|
79
74
|
/** 主流程回调唯一需要学习的测试工具。 */
|
|
80
75
|
interface ReactPlaythroughArguments {
|
|
81
76
|
/** Testing Library 对生产游戏入口的渲染结果。 */
|
|
@@ -107,6 +102,8 @@ type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Pro
|
|
|
107
102
|
interface ReactPlaythroughTest {
|
|
108
103
|
/** 渲染生产入口并声明一条最低可玩主流程。 */
|
|
109
104
|
(element: ReactNode, run: ReactPlaythroughRun): void;
|
|
105
|
+
/** Use one authoritative observer when gameplay results live outside the DOM. */
|
|
106
|
+
(element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
|
|
110
107
|
/** 仅允许带充分理由地豁免确实没有玩家输入的项目。 */
|
|
111
108
|
skip(reason: string, element: ReactNode, run: ReactPlaythroughRun): void;
|
|
112
109
|
}
|
|
@@ -144,4 +141,4 @@ declare module "vitest" {
|
|
|
144
141
|
}
|
|
145
142
|
}
|
|
146
143
|
|
|
147
|
-
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
|
|
144
|
+
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
|
package/dist/react/testing.d.ts
CHANGED
|
@@ -33,16 +33,6 @@ interface StepUntilOptions {
|
|
|
33
33
|
* The value is sampled only when the bound is exhausted.
|
|
34
34
|
*/
|
|
35
35
|
diagnostics?: () => unknown;
|
|
36
|
-
/**
|
|
37
|
-
* 默认有一道检查:如果等待的结果"不用玩就已经成立"(第 0 步即成立,
|
|
38
|
-
* 且页面文本从进入游戏起一个字都没变),说明这个结果跟游戏过程无关,
|
|
39
|
-
* 判为失败——它能抓住输入没接上、界面卡死、断言了静态标题这类假通过。
|
|
40
|
-
*
|
|
41
|
-
* 例外:Canvas 游戏的结果画在画布上,页面文本本来就不会变。这类游戏
|
|
42
|
-
* 观察的是 Telemetry 等页面外的权威状态,此时置 true 跳过该检查。
|
|
43
|
-
* 结果通过 DOM 呈现的游戏不要传。
|
|
44
|
-
*/
|
|
45
|
-
allowStaticDom?: boolean;
|
|
46
36
|
}
|
|
47
37
|
|
|
48
38
|
/** 单条 React 主流程测试留下的可序列化运行期证据。 */
|
|
@@ -76,6 +66,11 @@ interface ReactPlaythroughMetadata {
|
|
|
76
66
|
type ReactPlaythroughInputKind = "entry" | "primary";
|
|
77
67
|
/** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
|
|
78
68
|
type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
|
|
69
|
+
/** Canvas/Controller flows declare one authoritative read; simple DOM flows omit it. */
|
|
70
|
+
interface ReactPlaythroughOptions {
|
|
71
|
+
/** Stable, JSON-serializable production state sampled across the audited flow. */
|
|
72
|
+
observe: () => unknown;
|
|
73
|
+
}
|
|
79
74
|
/** 主流程回调唯一需要学习的测试工具。 */
|
|
80
75
|
interface ReactPlaythroughArguments {
|
|
81
76
|
/** Testing Library 对生产游戏入口的渲染结果。 */
|
|
@@ -107,6 +102,8 @@ type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Pro
|
|
|
107
102
|
interface ReactPlaythroughTest {
|
|
108
103
|
/** 渲染生产入口并声明一条最低可玩主流程。 */
|
|
109
104
|
(element: ReactNode, run: ReactPlaythroughRun): void;
|
|
105
|
+
/** Use one authoritative observer when gameplay results live outside the DOM. */
|
|
106
|
+
(element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
|
|
110
107
|
/** 仅允许带充分理由地豁免确实没有玩家输入的项目。 */
|
|
111
108
|
skip(reason: string, element: ReactNode, run: ReactPlaythroughRun): void;
|
|
112
109
|
}
|
|
@@ -144,4 +141,4 @@ declare module "vitest" {
|
|
|
144
141
|
}
|
|
145
142
|
}
|
|
146
143
|
|
|
147
|
-
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
|
|
144
|
+
export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughOptions, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
|
package/dist/react/testing.js
CHANGED
|
@@ -152,20 +152,50 @@ var INPUT_EVENTS = [
|
|
|
152
152
|
"touchend"
|
|
153
153
|
];
|
|
154
154
|
var MIN_CHECKPOINTS = 2;
|
|
155
|
+
var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
|
|
156
|
+
function formatObservation(fingerprint) {
|
|
157
|
+
if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
|
|
158
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
159
|
+
}
|
|
160
|
+
function sampleObservation(observe, stage) {
|
|
161
|
+
let value;
|
|
162
|
+
try {
|
|
163
|
+
value = observe();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new Error(`observe() threw at ${stage}: ${String(error)}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const fingerprint = JSON.stringify(value);
|
|
169
|
+
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
170
|
+
return { fingerprint, formatted: formatObservation(fingerprint) };
|
|
171
|
+
} catch {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function formatObservationTimeline(entered, afterPrimary, outcome) {
|
|
178
|
+
return [
|
|
179
|
+
`entered=${entered.formatted}`,
|
|
180
|
+
`after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
|
|
181
|
+
`outcome=${outcome.formatted}`
|
|
182
|
+
].join(", ");
|
|
183
|
+
}
|
|
155
184
|
function describeMissingEvidence(evidence) {
|
|
156
|
-
if (!evidence || evidence.entryInputs === 0) return "entry
|
|
157
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
185
|
+
if (!evidence || evidence.entryInputs === 0) return "an entry input";
|
|
186
|
+
if (evidence.primaryInputs === 0) return "a primary gameplay input";
|
|
158
187
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
159
|
-
if (evidence.boundedRuns === 0) return "
|
|
160
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
188
|
+
if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
|
|
189
|
+
if (evidence.assertionsAfterOutcome === 0)
|
|
190
|
+
return "an outcome assertion after stepUntil";
|
|
161
191
|
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
162
|
-
return
|
|
192
|
+
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
163
193
|
if (!evidence.checkpoints.some(
|
|
164
194
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
165
195
|
)) {
|
|
166
196
|
return "progress/terminal checkpoint";
|
|
167
197
|
}
|
|
168
|
-
return "
|
|
198
|
+
return "a complete playthrough verification marker";
|
|
169
199
|
}
|
|
170
200
|
function createMetadata(waiverReason) {
|
|
171
201
|
return {
|
|
@@ -182,7 +212,7 @@ function createMetadata(waiverReason) {
|
|
|
182
212
|
}
|
|
183
213
|
};
|
|
184
214
|
}
|
|
185
|
-
function definePlaythrough(element, run, waiverReason) {
|
|
215
|
+
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
186
216
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
187
217
|
const metadata = createMetadata(reason);
|
|
188
218
|
(0, import_vitest.test)("production game completes a bounded playthrough", {
|
|
@@ -193,6 +223,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
193
223
|
let assertionsAtOutcome;
|
|
194
224
|
let enteredRecorded = false;
|
|
195
225
|
let domTextAtEntered;
|
|
226
|
+
let enteredObservation;
|
|
227
|
+
let afterPrimaryObservation;
|
|
196
228
|
const recordInput = () => {
|
|
197
229
|
evidence.domInputEvents += 1;
|
|
198
230
|
};
|
|
@@ -230,7 +262,15 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
230
262
|
);
|
|
231
263
|
}
|
|
232
264
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
233
|
-
else
|
|
265
|
+
else {
|
|
266
|
+
evidence.primaryInputs += 1;
|
|
267
|
+
if (playthroughOptions?.observe) {
|
|
268
|
+
afterPrimaryObservation = sampleObservation(
|
|
269
|
+
playthroughOptions.observe,
|
|
270
|
+
"after-primary"
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
234
274
|
},
|
|
235
275
|
checkpoint(kind) {
|
|
236
276
|
if (kind === "entered") {
|
|
@@ -245,7 +285,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
245
285
|
);
|
|
246
286
|
}
|
|
247
287
|
enteredRecorded = true;
|
|
248
|
-
|
|
288
|
+
if (playthroughOptions?.observe) {
|
|
289
|
+
enteredObservation = sampleObservation(
|
|
290
|
+
playthroughOptions.observe,
|
|
291
|
+
"entered"
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
295
|
+
}
|
|
249
296
|
evidence.checkpoints.push(kind);
|
|
250
297
|
return;
|
|
251
298
|
}
|
|
@@ -266,16 +313,35 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
266
313
|
}
|
|
267
314
|
evidence.checkpoints.push(kind);
|
|
268
315
|
},
|
|
269
|
-
async stepUntil(condition,
|
|
270
|
-
const
|
|
316
|
+
async stepUntil(condition, stepOptions = {}) {
|
|
317
|
+
const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
|
|
318
|
+
...stepOptions,
|
|
319
|
+
diagnostics: playthroughOptions.observe
|
|
320
|
+
};
|
|
321
|
+
const steps = await runBoundedUntil(condition, boundedOptions);
|
|
271
322
|
if (evidence.primaryInputs === 0) {
|
|
272
323
|
throw new Error(
|
|
273
324
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
274
325
|
);
|
|
275
326
|
}
|
|
276
|
-
if (
|
|
327
|
+
if (playthroughOptions?.observe) {
|
|
328
|
+
if (!enteredObservation) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
'observe requires checkpoint("entered") before primary gameplay input.'
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
const outcomeObservation = sampleObservation(
|
|
334
|
+
playthroughOptions.observe,
|
|
335
|
+
"outcome"
|
|
336
|
+
);
|
|
337
|
+
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
} else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
277
343
|
throw new Error(
|
|
278
|
-
'stepUntil
|
|
344
|
+
'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.'
|
|
279
345
|
);
|
|
280
346
|
}
|
|
281
347
|
evidence.boundedRuns += 1;
|
|
@@ -319,9 +385,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
319
385
|
});
|
|
320
386
|
}
|
|
321
387
|
var playthroughTest = Object.assign(
|
|
322
|
-
(element,
|
|
388
|
+
(element, optionsOrRun, maybeRun) => {
|
|
389
|
+
if (typeof optionsOrRun === "function") {
|
|
390
|
+
definePlaythrough(element, optionsOrRun);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
|
|
394
|
+
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
395
|
+
},
|
|
323
396
|
{
|
|
324
|
-
skip: (reason, element, run) => definePlaythrough(element, run, reason)
|
|
397
|
+
skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
|
|
325
398
|
}
|
|
326
399
|
);
|
|
327
400
|
function auditReactPlaythroughRun(tests) {
|
|
@@ -338,7 +411,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
338
411
|
const issues = [];
|
|
339
412
|
if (declared.length === 0) {
|
|
340
413
|
issues.push(
|
|
341
|
-
'
|
|
414
|
+
'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").'
|
|
342
415
|
);
|
|
343
416
|
} else {
|
|
344
417
|
for (const candidate of declared) {
|
|
@@ -347,13 +420,17 @@ function auditReactPlaythroughRun(tests) {
|
|
|
347
420
|
if (isValid || isWaived) continue;
|
|
348
421
|
if (candidate.state === "skipped") {
|
|
349
422
|
issues.push(
|
|
350
|
-
|
|
423
|
+
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
351
424
|
);
|
|
352
425
|
} else if (candidate.state !== "passed") {
|
|
353
|
-
issues.push(
|
|
426
|
+
issues.push(
|
|
427
|
+
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
428
|
+
);
|
|
354
429
|
} else {
|
|
355
430
|
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
356
|
-
issues.push(
|
|
431
|
+
issues.push(
|
|
432
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
433
|
+
);
|
|
357
434
|
}
|
|
358
435
|
}
|
|
359
436
|
}
|
package/dist/react/testing.mjs
CHANGED
|
@@ -114,20 +114,50 @@ var INPUT_EVENTS = [
|
|
|
114
114
|
"touchend"
|
|
115
115
|
];
|
|
116
116
|
var MIN_CHECKPOINTS = 2;
|
|
117
|
+
var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
|
|
118
|
+
function formatObservation(fingerprint) {
|
|
119
|
+
if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
|
|
120
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
121
|
+
}
|
|
122
|
+
function sampleObservation(observe, stage) {
|
|
123
|
+
let value;
|
|
124
|
+
try {
|
|
125
|
+
value = observe();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
throw new Error(`observe() threw at ${stage}: ${String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const fingerprint = JSON.stringify(value);
|
|
131
|
+
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
132
|
+
return { fingerprint, formatted: formatObservation(fingerprint) };
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function formatObservationTimeline(entered, afterPrimary, outcome) {
|
|
140
|
+
return [
|
|
141
|
+
`entered=${entered.formatted}`,
|
|
142
|
+
`after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
|
|
143
|
+
`outcome=${outcome.formatted}`
|
|
144
|
+
].join(", ");
|
|
145
|
+
}
|
|
117
146
|
function describeMissingEvidence(evidence) {
|
|
118
|
-
if (!evidence || evidence.entryInputs === 0) return "entry
|
|
119
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
147
|
+
if (!evidence || evidence.entryInputs === 0) return "an entry input";
|
|
148
|
+
if (evidence.primaryInputs === 0) return "a primary gameplay input";
|
|
120
149
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
121
|
-
if (evidence.boundedRuns === 0) return "
|
|
122
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
150
|
+
if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
|
|
151
|
+
if (evidence.assertionsAfterOutcome === 0)
|
|
152
|
+
return "an outcome assertion after stepUntil";
|
|
123
153
|
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
124
|
-
return
|
|
154
|
+
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
125
155
|
if (!evidence.checkpoints.some(
|
|
126
156
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
127
157
|
)) {
|
|
128
158
|
return "progress/terminal checkpoint";
|
|
129
159
|
}
|
|
130
|
-
return "
|
|
160
|
+
return "a complete playthrough verification marker";
|
|
131
161
|
}
|
|
132
162
|
function createMetadata(waiverReason) {
|
|
133
163
|
return {
|
|
@@ -144,7 +174,7 @@ function createMetadata(waiverReason) {
|
|
|
144
174
|
}
|
|
145
175
|
};
|
|
146
176
|
}
|
|
147
|
-
function definePlaythrough(element, run, waiverReason) {
|
|
177
|
+
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
148
178
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
149
179
|
const metadata = createMetadata(reason);
|
|
150
180
|
test("production game completes a bounded playthrough", {
|
|
@@ -155,6 +185,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
155
185
|
let assertionsAtOutcome;
|
|
156
186
|
let enteredRecorded = false;
|
|
157
187
|
let domTextAtEntered;
|
|
188
|
+
let enteredObservation;
|
|
189
|
+
let afterPrimaryObservation;
|
|
158
190
|
const recordInput = () => {
|
|
159
191
|
evidence.domInputEvents += 1;
|
|
160
192
|
};
|
|
@@ -192,7 +224,15 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
192
224
|
);
|
|
193
225
|
}
|
|
194
226
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
195
|
-
else
|
|
227
|
+
else {
|
|
228
|
+
evidence.primaryInputs += 1;
|
|
229
|
+
if (playthroughOptions?.observe) {
|
|
230
|
+
afterPrimaryObservation = sampleObservation(
|
|
231
|
+
playthroughOptions.observe,
|
|
232
|
+
"after-primary"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
196
236
|
},
|
|
197
237
|
checkpoint(kind) {
|
|
198
238
|
if (kind === "entered") {
|
|
@@ -207,7 +247,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
207
247
|
);
|
|
208
248
|
}
|
|
209
249
|
enteredRecorded = true;
|
|
210
|
-
|
|
250
|
+
if (playthroughOptions?.observe) {
|
|
251
|
+
enteredObservation = sampleObservation(
|
|
252
|
+
playthroughOptions.observe,
|
|
253
|
+
"entered"
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
257
|
+
}
|
|
211
258
|
evidence.checkpoints.push(kind);
|
|
212
259
|
return;
|
|
213
260
|
}
|
|
@@ -228,16 +275,35 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
228
275
|
}
|
|
229
276
|
evidence.checkpoints.push(kind);
|
|
230
277
|
},
|
|
231
|
-
async stepUntil(condition,
|
|
232
|
-
const
|
|
278
|
+
async stepUntil(condition, stepOptions = {}) {
|
|
279
|
+
const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
|
|
280
|
+
...stepOptions,
|
|
281
|
+
diagnostics: playthroughOptions.observe
|
|
282
|
+
};
|
|
283
|
+
const steps = await runBoundedUntil(condition, boundedOptions);
|
|
233
284
|
if (evidence.primaryInputs === 0) {
|
|
234
285
|
throw new Error(
|
|
235
286
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
236
287
|
);
|
|
237
288
|
}
|
|
238
|
-
if (
|
|
289
|
+
if (playthroughOptions?.observe) {
|
|
290
|
+
if (!enteredObservation) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'observe requires checkpoint("entered") before primary gameplay input.'
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const outcomeObservation = sampleObservation(
|
|
296
|
+
playthroughOptions.observe,
|
|
297
|
+
"outcome"
|
|
298
|
+
);
|
|
299
|
+
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
} else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
239
305
|
throw new Error(
|
|
240
|
-
'stepUntil
|
|
306
|
+
'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.'
|
|
241
307
|
);
|
|
242
308
|
}
|
|
243
309
|
evidence.boundedRuns += 1;
|
|
@@ -281,9 +347,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
281
347
|
});
|
|
282
348
|
}
|
|
283
349
|
var playthroughTest = Object.assign(
|
|
284
|
-
(element,
|
|
350
|
+
(element, optionsOrRun, maybeRun) => {
|
|
351
|
+
if (typeof optionsOrRun === "function") {
|
|
352
|
+
definePlaythrough(element, optionsOrRun);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
|
|
356
|
+
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
357
|
+
},
|
|
285
358
|
{
|
|
286
|
-
skip: (reason, element, run) => definePlaythrough(element, run, reason)
|
|
359
|
+
skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
|
|
287
360
|
}
|
|
288
361
|
);
|
|
289
362
|
function auditReactPlaythroughRun(tests) {
|
|
@@ -300,7 +373,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
300
373
|
const issues = [];
|
|
301
374
|
if (declared.length === 0) {
|
|
302
375
|
issues.push(
|
|
303
|
-
'
|
|
376
|
+
'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").'
|
|
304
377
|
);
|
|
305
378
|
} else {
|
|
306
379
|
for (const candidate of declared) {
|
|
@@ -309,13 +382,17 @@ function auditReactPlaythroughRun(tests) {
|
|
|
309
382
|
if (isValid || isWaived) continue;
|
|
310
383
|
if (candidate.state === "skipped") {
|
|
311
384
|
issues.push(
|
|
312
|
-
|
|
385
|
+
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
313
386
|
);
|
|
314
387
|
} else if (candidate.state !== "passed") {
|
|
315
|
-
issues.push(
|
|
388
|
+
issues.push(
|
|
389
|
+
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
390
|
+
);
|
|
316
391
|
} else {
|
|
317
392
|
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
318
|
-
issues.push(
|
|
393
|
+
issues.push(
|
|
394
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
395
|
+
);
|
|
319
396
|
}
|
|
320
397
|
}
|
|
321
398
|
}
|
|
@@ -103,20 +103,50 @@ var INPUT_EVENTS = [
|
|
|
103
103
|
"touchend"
|
|
104
104
|
];
|
|
105
105
|
var MIN_CHECKPOINTS = 2;
|
|
106
|
+
var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
|
|
107
|
+
function formatObservation(fingerprint) {
|
|
108
|
+
if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
|
|
109
|
+
return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
|
|
110
|
+
}
|
|
111
|
+
function sampleObservation(observe, stage) {
|
|
112
|
+
let value;
|
|
113
|
+
try {
|
|
114
|
+
value = observe();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw new Error(`observe() threw at ${stage}: ${String(error)}`);
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const fingerprint = JSON.stringify(value);
|
|
120
|
+
if (fingerprint === void 0) throw new Error("unsupported value");
|
|
121
|
+
return { fingerprint, formatted: formatObservation(fingerprint) };
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function formatObservationTimeline(entered, afterPrimary, outcome) {
|
|
129
|
+
return [
|
|
130
|
+
`entered=${entered.formatted}`,
|
|
131
|
+
`after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
|
|
132
|
+
`outcome=${outcome.formatted}`
|
|
133
|
+
].join(", ");
|
|
134
|
+
}
|
|
106
135
|
function describeMissingEvidence(evidence) {
|
|
107
|
-
if (!evidence || evidence.entryInputs === 0) return "entry
|
|
108
|
-
if (evidence.primaryInputs === 0) return "primary
|
|
136
|
+
if (!evidence || evidence.entryInputs === 0) return "an entry input";
|
|
137
|
+
if (evidence.primaryInputs === 0) return "a primary gameplay input";
|
|
109
138
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
110
|
-
if (evidence.boundedRuns === 0) return "
|
|
111
|
-
if (evidence.assertionsAfterOutcome === 0)
|
|
139
|
+
if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
|
|
140
|
+
if (evidence.assertionsAfterOutcome === 0)
|
|
141
|
+
return "an outcome assertion after stepUntil";
|
|
112
142
|
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
113
|
-
return
|
|
143
|
+
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
114
144
|
if (!evidence.checkpoints.some(
|
|
115
145
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
116
146
|
)) {
|
|
117
147
|
return "progress/terminal checkpoint";
|
|
118
148
|
}
|
|
119
|
-
return "
|
|
149
|
+
return "a complete playthrough verification marker";
|
|
120
150
|
}
|
|
121
151
|
function createMetadata(waiverReason) {
|
|
122
152
|
return {
|
|
@@ -133,7 +163,7 @@ function createMetadata(waiverReason) {
|
|
|
133
163
|
}
|
|
134
164
|
};
|
|
135
165
|
}
|
|
136
|
-
function definePlaythrough(element, run, waiverReason) {
|
|
166
|
+
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
137
167
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
138
168
|
const metadata = createMetadata(reason);
|
|
139
169
|
(0, import_vitest.test)("production game completes a bounded playthrough", {
|
|
@@ -144,6 +174,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
144
174
|
let assertionsAtOutcome;
|
|
145
175
|
let enteredRecorded = false;
|
|
146
176
|
let domTextAtEntered;
|
|
177
|
+
let enteredObservation;
|
|
178
|
+
let afterPrimaryObservation;
|
|
147
179
|
const recordInput = () => {
|
|
148
180
|
evidence.domInputEvents += 1;
|
|
149
181
|
};
|
|
@@ -181,7 +213,15 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
181
213
|
);
|
|
182
214
|
}
|
|
183
215
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
184
|
-
else
|
|
216
|
+
else {
|
|
217
|
+
evidence.primaryInputs += 1;
|
|
218
|
+
if (playthroughOptions?.observe) {
|
|
219
|
+
afterPrimaryObservation = sampleObservation(
|
|
220
|
+
playthroughOptions.observe,
|
|
221
|
+
"after-primary"
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
185
225
|
},
|
|
186
226
|
checkpoint(kind) {
|
|
187
227
|
if (kind === "entered") {
|
|
@@ -196,7 +236,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
196
236
|
);
|
|
197
237
|
}
|
|
198
238
|
enteredRecorded = true;
|
|
199
|
-
|
|
239
|
+
if (playthroughOptions?.observe) {
|
|
240
|
+
enteredObservation = sampleObservation(
|
|
241
|
+
playthroughOptions.observe,
|
|
242
|
+
"entered"
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
246
|
+
}
|
|
200
247
|
evidence.checkpoints.push(kind);
|
|
201
248
|
return;
|
|
202
249
|
}
|
|
@@ -217,16 +264,35 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
217
264
|
}
|
|
218
265
|
evidence.checkpoints.push(kind);
|
|
219
266
|
},
|
|
220
|
-
async stepUntil(condition,
|
|
221
|
-
const
|
|
267
|
+
async stepUntil(condition, stepOptions = {}) {
|
|
268
|
+
const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
|
|
269
|
+
...stepOptions,
|
|
270
|
+
diagnostics: playthroughOptions.observe
|
|
271
|
+
};
|
|
272
|
+
const steps = await runBoundedUntil(condition, boundedOptions);
|
|
222
273
|
if (evidence.primaryInputs === 0) {
|
|
223
274
|
throw new Error(
|
|
224
275
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
225
276
|
);
|
|
226
277
|
}
|
|
227
|
-
if (
|
|
278
|
+
if (playthroughOptions?.observe) {
|
|
279
|
+
if (!enteredObservation) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
'observe requires checkpoint("entered") before primary gameplay input.'
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
const outcomeObservation = sampleObservation(
|
|
285
|
+
playthroughOptions.observe,
|
|
286
|
+
"outcome"
|
|
287
|
+
);
|
|
288
|
+
if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
} else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
228
294
|
throw new Error(
|
|
229
|
-
'stepUntil
|
|
295
|
+
'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.'
|
|
230
296
|
);
|
|
231
297
|
}
|
|
232
298
|
evidence.boundedRuns += 1;
|
|
@@ -270,9 +336,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
270
336
|
});
|
|
271
337
|
}
|
|
272
338
|
var playthroughTest = Object.assign(
|
|
273
|
-
(element,
|
|
339
|
+
(element, optionsOrRun, maybeRun) => {
|
|
340
|
+
if (typeof optionsOrRun === "function") {
|
|
341
|
+
definePlaythrough(element, optionsOrRun);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
|
|
345
|
+
definePlaythrough(element, maybeRun, optionsOrRun);
|
|
346
|
+
},
|
|
274
347
|
{
|
|
275
|
-
skip: (reason, element, run) => definePlaythrough(element, run, reason)
|
|
348
|
+
skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
|
|
276
349
|
}
|
|
277
350
|
);
|
|
278
351
|
function auditReactPlaythroughRun(tests) {
|
|
@@ -289,7 +362,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
289
362
|
const issues = [];
|
|
290
363
|
if (declared.length === 0) {
|
|
291
364
|
issues.push(
|
|
292
|
-
'
|
|
365
|
+
'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").'
|
|
293
366
|
);
|
|
294
367
|
} else {
|
|
295
368
|
for (const candidate of declared) {
|
|
@@ -298,13 +371,17 @@ function auditReactPlaythroughRun(tests) {
|
|
|
298
371
|
if (isValid || isWaived) continue;
|
|
299
372
|
if (candidate.state === "skipped") {
|
|
300
373
|
issues.push(
|
|
301
|
-
|
|
374
|
+
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
302
375
|
);
|
|
303
376
|
} else if (candidate.state !== "passed") {
|
|
304
|
-
issues.push(
|
|
377
|
+
issues.push(
|
|
378
|
+
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
379
|
+
);
|
|
305
380
|
} else {
|
|
306
381
|
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
307
|
-
issues.push(
|
|
382
|
+
issues.push(
|
|
383
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
384
|
+
);
|
|
308
385
|
}
|
|
309
386
|
}
|
|
310
387
|
}
|
|
@@ -394,6 +471,29 @@ function formatFailureSummary(modules) {
|
|
|
394
471
|
lines.push("TEST_RESULT: FAIL");
|
|
395
472
|
return lines;
|
|
396
473
|
}
|
|
474
|
+
function repairGuidance(cause) {
|
|
475
|
+
if (/snapshot\(\) returned the same reference/i.test(cause)) {
|
|
476
|
+
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.";
|
|
477
|
+
}
|
|
478
|
+
if (/(?:already true|found the outcome) at step 0.*DOM has not changed/i.test(
|
|
479
|
+
cause
|
|
480
|
+
)) {
|
|
481
|
+
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.';
|
|
482
|
+
}
|
|
483
|
+
if (/authoritative observation did not change/i.test(cause)) {
|
|
484
|
+
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.";
|
|
485
|
+
}
|
|
486
|
+
if (/No step callback was provided/i.test(cause)) {
|
|
487
|
+
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.";
|
|
488
|
+
}
|
|
489
|
+
if (/outcome was not reached within \d+ steps/i.test(cause)) {
|
|
490
|
+
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.";
|
|
491
|
+
}
|
|
492
|
+
if (/performInput|checkpoint/.test(cause)) {
|
|
493
|
+
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").';
|
|
494
|
+
}
|
|
495
|
+
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.";
|
|
496
|
+
}
|
|
397
497
|
function assessReactPlaythroughReport(input) {
|
|
398
498
|
const base = { file: input.expectedFile };
|
|
399
499
|
if (!input.expectedFileScheduled) {
|
|
@@ -401,15 +501,15 @@ function assessReactPlaythroughReport(input) {
|
|
|
401
501
|
return {
|
|
402
502
|
...base,
|
|
403
503
|
status: "NOT_CHECKED",
|
|
404
|
-
next:
|
|
504
|
+
next: `This focused run did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
405
505
|
failsRun: false
|
|
406
506
|
};
|
|
407
507
|
}
|
|
408
508
|
return {
|
|
409
509
|
...base,
|
|
410
510
|
status: "FAILED",
|
|
411
|
-
cause: "
|
|
412
|
-
next: '
|
|
511
|
+
cause: "The required production playthrough test file does not exist.",
|
|
512
|
+
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").',
|
|
413
513
|
failsRun: true
|
|
414
514
|
};
|
|
415
515
|
}
|
|
@@ -423,7 +523,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
423
523
|
return {
|
|
424
524
|
...base,
|
|
425
525
|
status: "NOT_CHECKED",
|
|
426
|
-
next:
|
|
526
|
+
next: `The name or line filter did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
427
527
|
failsRun: false
|
|
428
528
|
};
|
|
429
529
|
}
|
|
@@ -431,25 +531,20 @@ function assessReactPlaythroughReport(input) {
|
|
|
431
531
|
return {
|
|
432
532
|
...base,
|
|
433
533
|
status: "NOT_RUN",
|
|
434
|
-
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "
|
|
435
|
-
next: "
|
|
534
|
+
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
|
|
535
|
+
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.",
|
|
436
536
|
failsRun: true
|
|
437
537
|
};
|
|
438
538
|
}
|
|
439
539
|
const tests = input.modules.flatMap((module2) => module2.tests);
|
|
440
540
|
const audit = auditReactPlaythroughRun(tests);
|
|
441
541
|
if (!audit.passed) {
|
|
442
|
-
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "
|
|
443
|
-
const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
|
|
444
|
-
const missingStep = /No step callback was provided/i.test(cause);
|
|
445
|
-
const staticOutcome = /already true at step 0 and the DOM has not changed/i.test(cause);
|
|
446
|
-
const staleSnapshot = /snapshot\(\) returned the same reference/i.test(cause);
|
|
447
|
-
const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
|
|
542
|
+
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
448
543
|
return {
|
|
449
544
|
...base,
|
|
450
545
|
status: "FAILED",
|
|
451
546
|
cause,
|
|
452
|
-
next:
|
|
547
|
+
next: repairGuidance(cause),
|
|
453
548
|
failsRun: true
|
|
454
549
|
};
|
|
455
550
|
}
|
|
@@ -467,7 +562,7 @@ function formatReactPlaythroughReport(report) {
|
|
|
467
562
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
468
563
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
469
564
|
if (report.waiverReasons?.length) {
|
|
470
|
-
lines.push(`REASON: ${report.waiverReasons.join("
|
|
565
|
+
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
471
566
|
}
|
|
472
567
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
473
568
|
return `
|
|
@@ -550,6 +645,9 @@ function resolvePhaser3BrowserEntry(projectRoot) {
|
|
|
550
645
|
function defineReactGameVitestConfig(options) {
|
|
551
646
|
const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
|
|
552
647
|
return (0, import_config.defineConfig)({
|
|
648
|
+
// Keep discovery and dependency resolution anchored to the generated app even
|
|
649
|
+
// when an external runner invokes Vitest from a parent workspace directory.
|
|
650
|
+
root: options.projectRoot,
|
|
553
651
|
resolve: {
|
|
554
652
|
alias: {
|
|
555
653
|
...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
|
|
@@ -69,20 +69,50 @@ var INPUT_EVENTS = [
|
|
|
69
69
|
"touchend"
|
|
70
70
|
];
|
|
71
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
|
+
}
|
|
72
101
|
function describeMissingEvidence(evidence) {
|
|
73
|
-
if (!evidence || evidence.entryInputs === 0) return "entry
|
|
74
|
-
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";
|
|
75
104
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
76
|
-
if (evidence.boundedRuns === 0) return "
|
|
77
|
-
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";
|
|
78
108
|
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
79
|
-
return
|
|
109
|
+
return `at least ${MIN_CHECKPOINTS} checkpoints`;
|
|
80
110
|
if (!evidence.checkpoints.some(
|
|
81
111
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
82
112
|
)) {
|
|
83
113
|
return "progress/terminal checkpoint";
|
|
84
114
|
}
|
|
85
|
-
return "
|
|
115
|
+
return "a complete playthrough verification marker";
|
|
86
116
|
}
|
|
87
117
|
function createMetadata(waiverReason) {
|
|
88
118
|
return {
|
|
@@ -99,7 +129,7 @@ function createMetadata(waiverReason) {
|
|
|
99
129
|
}
|
|
100
130
|
};
|
|
101
131
|
}
|
|
102
|
-
function definePlaythrough(element, run, waiverReason) {
|
|
132
|
+
function definePlaythrough(element, run, playthroughOptions, waiverReason) {
|
|
103
133
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
104
134
|
const metadata = createMetadata(reason);
|
|
105
135
|
test("production game completes a bounded playthrough", {
|
|
@@ -110,6 +140,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
110
140
|
let assertionsAtOutcome;
|
|
111
141
|
let enteredRecorded = false;
|
|
112
142
|
let domTextAtEntered;
|
|
143
|
+
let enteredObservation;
|
|
144
|
+
let afterPrimaryObservation;
|
|
113
145
|
const recordInput = () => {
|
|
114
146
|
evidence.domInputEvents += 1;
|
|
115
147
|
};
|
|
@@ -147,7 +179,15 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
147
179
|
);
|
|
148
180
|
}
|
|
149
181
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
150
|
-
else
|
|
182
|
+
else {
|
|
183
|
+
evidence.primaryInputs += 1;
|
|
184
|
+
if (playthroughOptions?.observe) {
|
|
185
|
+
afterPrimaryObservation = sampleObservation(
|
|
186
|
+
playthroughOptions.observe,
|
|
187
|
+
"after-primary"
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
151
191
|
},
|
|
152
192
|
checkpoint(kind) {
|
|
153
193
|
if (kind === "entered") {
|
|
@@ -162,7 +202,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
162
202
|
);
|
|
163
203
|
}
|
|
164
204
|
enteredRecorded = true;
|
|
165
|
-
|
|
205
|
+
if (playthroughOptions?.observe) {
|
|
206
|
+
enteredObservation = sampleObservation(
|
|
207
|
+
playthroughOptions.observe,
|
|
208
|
+
"entered"
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
212
|
+
}
|
|
166
213
|
evidence.checkpoints.push(kind);
|
|
167
214
|
return;
|
|
168
215
|
}
|
|
@@ -183,16 +230,35 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
183
230
|
}
|
|
184
231
|
evidence.checkpoints.push(kind);
|
|
185
232
|
},
|
|
186
|
-
async stepUntil(condition,
|
|
187
|
-
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);
|
|
188
239
|
if (evidence.primaryInputs === 0) {
|
|
189
240
|
throw new Error(
|
|
190
241
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
191
242
|
);
|
|
192
243
|
}
|
|
193
|
-
if (
|
|
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) {
|
|
194
260
|
throw new Error(
|
|
195
|
-
'stepUntil
|
|
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.'
|
|
196
262
|
);
|
|
197
263
|
}
|
|
198
264
|
evidence.boundedRuns += 1;
|
|
@@ -236,9 +302,16 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
236
302
|
});
|
|
237
303
|
}
|
|
238
304
|
var playthroughTest = Object.assign(
|
|
239
|
-
(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
|
+
},
|
|
240
313
|
{
|
|
241
|
-
skip: (reason, element, run) => definePlaythrough(element, run, reason)
|
|
314
|
+
skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
|
|
242
315
|
}
|
|
243
316
|
);
|
|
244
317
|
function auditReactPlaythroughRun(tests) {
|
|
@@ -255,7 +328,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
255
328
|
const issues = [];
|
|
256
329
|
if (declared.length === 0) {
|
|
257
330
|
issues.push(
|
|
258
|
-
'
|
|
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").'
|
|
259
332
|
);
|
|
260
333
|
} else {
|
|
261
334
|
for (const candidate of declared) {
|
|
@@ -264,13 +337,17 @@ function auditReactPlaythroughRun(tests) {
|
|
|
264
337
|
if (isValid || isWaived) continue;
|
|
265
338
|
if (candidate.state === "skipped") {
|
|
266
339
|
issues.push(
|
|
267
|
-
|
|
340
|
+
`Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
|
|
268
341
|
);
|
|
269
342
|
} else if (candidate.state !== "passed") {
|
|
270
|
-
issues.push(
|
|
343
|
+
issues.push(
|
|
344
|
+
`Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
|
|
345
|
+
);
|
|
271
346
|
} else {
|
|
272
347
|
const missing = describeMissingEvidence(candidate.metadata?.evidence);
|
|
273
|
-
issues.push(
|
|
348
|
+
issues.push(
|
|
349
|
+
`Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
|
|
350
|
+
);
|
|
274
351
|
}
|
|
275
352
|
}
|
|
276
353
|
}
|
|
@@ -360,6 +437,29 @@ function formatFailureSummary(modules) {
|
|
|
360
437
|
lines.push("TEST_RESULT: FAIL");
|
|
361
438
|
return lines;
|
|
362
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
|
+
}
|
|
363
463
|
function assessReactPlaythroughReport(input) {
|
|
364
464
|
const base = { file: input.expectedFile };
|
|
365
465
|
if (!input.expectedFileScheduled) {
|
|
@@ -367,15 +467,15 @@ function assessReactPlaythroughReport(input) {
|
|
|
367
467
|
return {
|
|
368
468
|
...base,
|
|
369
469
|
status: "NOT_CHECKED",
|
|
370
|
-
next:
|
|
470
|
+
next: `This focused run did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
|
|
371
471
|
failsRun: false
|
|
372
472
|
};
|
|
373
473
|
}
|
|
374
474
|
return {
|
|
375
475
|
...base,
|
|
376
476
|
status: "FAILED",
|
|
377
|
-
cause: "
|
|
378
|
-
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").',
|
|
379
479
|
failsRun: true
|
|
380
480
|
};
|
|
381
481
|
}
|
|
@@ -389,7 +489,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
389
489
|
return {
|
|
390
490
|
...base,
|
|
391
491
|
status: "NOT_CHECKED",
|
|
392
|
-
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.`,
|
|
393
493
|
failsRun: false
|
|
394
494
|
};
|
|
395
495
|
}
|
|
@@ -397,25 +497,20 @@ function assessReactPlaythroughReport(input) {
|
|
|
397
497
|
return {
|
|
398
498
|
...base,
|
|
399
499
|
status: "NOT_RUN",
|
|
400
|
-
cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "
|
|
401
|
-
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.",
|
|
402
502
|
failsRun: true
|
|
403
503
|
};
|
|
404
504
|
}
|
|
405
505
|
const tests = input.modules.flatMap((module) => module.tests);
|
|
406
506
|
const audit = auditReactPlaythroughRun(tests);
|
|
407
507
|
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);
|
|
508
|
+
const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
|
|
414
509
|
return {
|
|
415
510
|
...base,
|
|
416
511
|
status: "FAILED",
|
|
417
512
|
cause,
|
|
418
|
-
next:
|
|
513
|
+
next: repairGuidance(cause),
|
|
419
514
|
failsRun: true
|
|
420
515
|
};
|
|
421
516
|
}
|
|
@@ -433,7 +528,7 @@ function formatReactPlaythroughReport(report) {
|
|
|
433
528
|
const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
|
|
434
529
|
if (report.cause) lines.push(`CAUSE: ${report.cause}`);
|
|
435
530
|
if (report.waiverReasons?.length) {
|
|
436
|
-
lines.push(`REASON: ${report.waiverReasons.join("
|
|
531
|
+
lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
|
|
437
532
|
}
|
|
438
533
|
if (report.next) lines.push(`NEXT: ${report.next}`);
|
|
439
534
|
return `
|
|
@@ -516,6 +611,9 @@ function resolvePhaser3BrowserEntry(projectRoot) {
|
|
|
516
611
|
function defineReactGameVitestConfig(options) {
|
|
517
612
|
const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
|
|
518
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,
|
|
519
617
|
resolve: {
|
|
520
618
|
alias: {
|
|
521
619
|
...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
|
}
|