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 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
@@ -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 \u2014 React compares snapshots by reference and skips the re-render, so the UI freezes. \u539F\u56E0\uFF1AReact \u9760\u6BD4\u8F83\u5F15\u7528\u5224\u65AD\u72B6\u6001\u53D8\u6CA1\u53D8\uFF0C\u5F15\u7528\u76F8\u540C\u5C31\u5F53\u4F5C\u6CA1\u53D8\u5316\u3002\u4FEE\u590D\uFF1A\u6BCF\u6B21 notify \u524D\u6362\u4E00\u4E2A\u65B0\u5BF9\u8C61\uFF0C\u4F8B\u5982\u5728 controller \u91CC cachedSnapshot = { ...state }\uFF1B\u4E0D\u8981\u628A\u88AB\u539F\u5730\u4FEE\u6539\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u7ED9 React\u3002"
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;
@@ -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 \u2014 React compares snapshots by reference and skips the re-render, so the UI freezes. \u539F\u56E0\uFF1AReact \u9760\u6BD4\u8F83\u5F15\u7528\u5224\u65AD\u72B6\u6001\u53D8\u6CA1\u53D8\uFF0C\u5F15\u7528\u76F8\u540C\u5C31\u5F53\u4F5C\u6CA1\u53D8\u5316\u3002\u4FEE\u590D\uFF1A\u6BCF\u6B21 notify \u524D\u6362\u4E00\u4E2A\u65B0\u5BF9\u8C61\uFF0C\u4F8B\u5982\u5728 controller \u91CC cachedSnapshot = { ...state }\uFF1B\u4E0D\u8981\u628A\u88AB\u539F\u5730\u4FEE\u6539\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u7ED9 React\u3002"
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;
@@ -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 };
@@ -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 };
@@ -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 \u8F93\u5165";
157
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
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 "\u6709\u754C stepUntil";
160
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
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 `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
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 "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
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 evidence.primaryInputs += 1;
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
- domTextAtEntered = document.body.textContent ?? "";
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, options = {}) {
270
- const steps = await runBoundedUntil(condition, options);
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 (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
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 outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
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, run) => definePlaythrough(element, run),
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
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
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
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
431
+ issues.push(
432
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
433
+ );
357
434
  }
358
435
  }
359
436
  }
@@ -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 \u8F93\u5165";
119
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
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 "\u6709\u754C stepUntil";
122
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
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 `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
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 "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
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 evidence.primaryInputs += 1;
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
- domTextAtEntered = document.body.textContent ?? "";
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, options = {}) {
232
- const steps = await runBoundedUntil(condition, options);
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 (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
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 outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
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, run) => definePlaythrough(element, run),
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
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
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
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
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 \u8F93\u5165";
108
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
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 "\u6709\u754C stepUntil";
111
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
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 `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
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 "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
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 evidence.primaryInputs += 1;
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
- domTextAtEntered = document.body.textContent ?? "";
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, options = {}) {
221
- const steps = await runBoundedUntil(condition, options);
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 (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
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 outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
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, run) => definePlaythrough(element, run),
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
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
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
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
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: `\u672C\u6B21\u662F\u805A\u7126\u8FD0\u884C\uFF0C\u672A\u68C0\u67E5\u6700\u4F4E\u53EF\u73A9\u6D41\u7A0B\uFF1B\u63D0\u4EA4\u524D\u8FD0\u884C pnpm test\uFF0C\u786E\u4FDD ${input.expectedFile} \u901A\u8FC7\u3002`,
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: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
412
- next: '\u521B\u5EFA\u8BE5\u6587\u4EF6\uFF1A\u4ECE <App /> \u901A\u8FC7\u771F\u5B9E DOM \u8F93\u5165\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\u3002\u6BCF\u6761\u4E3B\u6D41\u7A0B\u81F3\u5C11\u9700\u8981\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002',
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: `\u672C\u6B21\u540D\u79F0\u6216\u884C\u53F7\u8FC7\u6EE4\u6CA1\u6709\u6267\u884C\u6700\u4F4E\u53EF\u73A9\u6D41\u7A0B\uFF1B\u63D0\u4EA4\u524D\u8FD0\u884C pnpm test\uFF0C\u786E\u4FDD ${input.expectedFile} \u901A\u8FC7\u3002`,
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] ?? "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u672A\u5B8C\u6210\u6536\u96C6\u6216\u6267\u884C\u3002",
435
- next: "\u5148\u4FEE\u590D Vitest \u4E0A\u65B9\u9996\u4E2A\u8BED\u6CD5\u3001\u5BFC\u5165\u6216\u6536\u96C6\u9519\u8BEF\uFF0C\u518D\u8FD0\u884C pnpm test\uFF1B\u4E0D\u8981\u7528 skip \u63A9\u76D6\u52A0\u8F7D\u5931\u8D25\u3002",
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] ?? "\u751F\u4EA7\u53EF\u73A9\u6D41\u7A0B\u6CA1\u6709\u7559\u4E0B\u5B8C\u6574\u8BC1\u636E\u3002";
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: staleSnapshot ? "\u6E38\u620F\u539F\u5730\u4FEE\u6539\u72B6\u6001\u540E\u6CA1\u6709\u53D1\u5E03\u65B0\u7684\u5FEB\u7167\u5F15\u7528\uFF0CReact \u56E0 Object.is \u6BD4\u8F83\u76F8\u540C\u800C\u8DF3\u8FC7\u91CD\u6E32\u3002\u5728 controller \u7684 notify \u8DEF\u5F84\u4E0A\u53D1\u5E03\u65B0\u9876\u5C42\u5BF9\u8C61\uFF08cachedSnapshot = { ...state }\uFF09\uFF0C\u4E0D\u8981\u628A\u53EF\u53D8\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u4F5C\u4E3A\u5FEB\u7167\u66B4\u9732\u3002" : staticOutcome ? "\u7ED3\u679C\u5728\u63A8\u8FDB\u524D\u5DF2\u6210\u7ACB\u4E14 DOM \u81EA entered \u4EE5\u6765\u65E0\u53D8\u5316\uFF1A\u8F93\u5165\u53EF\u80FD\u672A\u63A5\u5230\u751F\u4EA7\u63A7\u5236\uFF0CUI \u53EF\u80FD\u51BB\u7ED3\uFF0C\u4E5F\u53EF\u80FD\u65AD\u8A00\u4E86\u6E38\u620F\u5F00\u59CB\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u6539\u4E3A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u6CD5\u63A8\u8FDB\u540E\u624D\u51FA\u73B0\u7684\u72B6\u6001\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u51FA\u73B0\uFF09\uFF1B\u7ECF Canvas/Telemetry \u7B49 DOM \u5916\u6743\u5A01\u72B6\u6001\u89C2\u5BDF\u7684\u6D41\u7A0B\u4F20 { allowStaticDom: true }\u3002" : missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
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("\uFF1B")}`);
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 \u8F93\u5165";
74
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
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 "\u6709\u754C stepUntil";
77
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
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 `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
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 "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
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 evidence.primaryInputs += 1;
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
- domTextAtEntered = document.body.textContent ?? "";
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, options = {}) {
187
- const steps = await runBoundedUntil(condition, options);
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 (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
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 outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
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, run) => definePlaythrough(element, run),
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
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
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
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
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(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
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: `\u672C\u6B21\u662F\u805A\u7126\u8FD0\u884C\uFF0C\u672A\u68C0\u67E5\u6700\u4F4E\u53EF\u73A9\u6D41\u7A0B\uFF1B\u63D0\u4EA4\u524D\u8FD0\u884C pnpm test\uFF0C\u786E\u4FDD ${input.expectedFile} \u901A\u8FC7\u3002`,
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: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
378
- next: '\u521B\u5EFA\u8BE5\u6587\u4EF6\uFF1A\u4ECE <App /> \u901A\u8FC7\u771F\u5B9E DOM \u8F93\u5165\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\u3002\u6BCF\u6761\u4E3B\u6D41\u7A0B\u81F3\u5C11\u9700\u8981\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002',
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: `\u672C\u6B21\u540D\u79F0\u6216\u884C\u53F7\u8FC7\u6EE4\u6CA1\u6709\u6267\u884C\u6700\u4F4E\u53EF\u73A9\u6D41\u7A0B\uFF1B\u63D0\u4EA4\u524D\u8FD0\u884C pnpm test\uFF0C\u786E\u4FDD ${input.expectedFile} \u901A\u8FC7\u3002`,
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] ?? "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u672A\u5B8C\u6210\u6536\u96C6\u6216\u6267\u884C\u3002",
401
- next: "\u5148\u4FEE\u590D Vitest \u4E0A\u65B9\u9996\u4E2A\u8BED\u6CD5\u3001\u5BFC\u5165\u6216\u6536\u96C6\u9519\u8BEF\uFF0C\u518D\u8FD0\u884C pnpm test\uFF1B\u4E0D\u8981\u7528 skip \u63A9\u76D6\u52A0\u8F7D\u5931\u8D25\u3002",
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] ?? "\u751F\u4EA7\u53EF\u73A9\u6D41\u7A0B\u6CA1\u6709\u7559\u4E0B\u5B8C\u6574\u8BC1\u636E\u3002";
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: staleSnapshot ? "\u6E38\u620F\u539F\u5730\u4FEE\u6539\u72B6\u6001\u540E\u6CA1\u6709\u53D1\u5E03\u65B0\u7684\u5FEB\u7167\u5F15\u7528\uFF0CReact \u56E0 Object.is \u6BD4\u8F83\u76F8\u540C\u800C\u8DF3\u8FC7\u91CD\u6E32\u3002\u5728 controller \u7684 notify \u8DEF\u5F84\u4E0A\u53D1\u5E03\u65B0\u9876\u5C42\u5BF9\u8C61\uFF08cachedSnapshot = { ...state }\uFF09\uFF0C\u4E0D\u8981\u628A\u53EF\u53D8\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u4F5C\u4E3A\u5FEB\u7167\u66B4\u9732\u3002" : staticOutcome ? "\u7ED3\u679C\u5728\u63A8\u8FDB\u524D\u5DF2\u6210\u7ACB\u4E14 DOM \u81EA entered \u4EE5\u6765\u65E0\u53D8\u5316\uFF1A\u8F93\u5165\u53EF\u80FD\u672A\u63A5\u5230\u751F\u4EA7\u63A7\u5236\uFF0CUI \u53EF\u80FD\u51BB\u7ED3\uFF0C\u4E5F\u53EF\u80FD\u65AD\u8A00\u4E86\u6E38\u620F\u5F00\u59CB\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u6539\u4E3A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u6CD5\u63A8\u8FDB\u540E\u624D\u51FA\u73B0\u7684\u72B6\u6001\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u51FA\u73B0\uFF09\uFF1B\u7ECF Canvas/Telemetry \u7B49 DOM \u5916\u6743\u5A01\u72B6\u6001\u89C2\u5BDF\u7684\u6D41\u7A0B\u4F20 { allowStaticDom: true }\u3002" : missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
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("\uFF1B")}`);
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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",