miaoda-game-devkit 0.2.20 → 0.3.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
@@ -8,13 +8,14 @@
8
8
  Vitest 配置和自动收集运行错误的 Testing Library setup,分别从 `miaoda-game-devkit/react`、
9
9
  `miaoda-game-devkit/react/testing` 和 `miaoda-game-devkit/react/vitest-config` 导入;
10
10
  - React 的 `playthroughTest` 与项目级 reporter 自动验证独立的 entry/primary DOM
11
- 输入、有界推进、结果断言及前后权威 snapshot 变化;聚焦运行只提示未检查,
11
+ 输入、有界推进、结果断言及至少两次有序 checkpoint 签到;聚焦运行只提示未检查,
12
12
  完整运行缺少有效流程时失败;
13
13
 
14
14
  `playthroughTest` 的主流程回调从参数接收 `user`、`performInput`、`checkpoint`、
15
- `stepUntil` 和绑定当前测试的 `expect`。按 entry 输入、entered snapshot、primary 输入、
16
- 有界等待、使用回调提供的 `expect` 断言结果、
17
- progress/terminal snapshot 的顺序记录最低证据。大型游戏仍只保留一条最短关键流程;
15
+ `stepUntil` 和绑定当前测试的 `expect`。按 entry 输入、`checkpoint("entered")`、primary 输入、
16
+ 有界等待、使用回调提供的 `expect` 断言权威结果、
17
+ `checkpoint("progress")` 或 `checkpoint("terminal")` 的顺序记录最低证据。每条流程至少需要
18
+ 这两次 checkpoint;checkpoint 只负责签到,不携带或验证状态。大型游戏仍只保留一条最短关键流程;
18
19
  分支、关卡规则和恢复清理由专门测试覆盖。不要再次调用 `userEvent.setup()`。
19
20
  时间、帧或自定义 scheduler 流程必须显式传入确定性 `step`;精确物理时间、暂停恢复
20
21
  或调度清理测试可使用 `ManualGameClock`。
@@ -153,8 +154,8 @@ DOM 到 Phaser 的输入、命中测试和游戏状态变化,不验证 Canvas/
153
154
  给出直接修复步骤;测试文件无法加载或配置要求的测试尚未建立时,不再继续报告 Scene
154
155
  注册等派生问题。先处理第一类错误,再重新运行 `pnpm test`。
155
156
 
156
- 共享 Vitest 配置使用 `minimal` reporter `text-summary` coverage reporter,保留失败
157
- 定位、Scene 覆盖摘要和最终机器标识,同时避免输出完整测试列表和逐文件噪声。
157
+ Phaser Vitest 配置使用 `minimal` reporter;React Vitest 配置使用无 ANSI 的精简 reporter
158
+ 两者都保留失败定位、coverage 摘要和最终机器标识,同时避免输出完整 DOM 快照、测试列表和逐文件噪声。
158
159
 
159
160
  ## 发布顺序
160
161
 
@@ -10,4 +10,22 @@ interface DisposableGameController {
10
10
  */
11
11
  declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
12
12
 
13
- export { type DisposableGameController, useOwnedGameController };
13
+ /** 同时提供订阅与权威快照读取的游戏 Controller,是最小可渲染运行时。 */
14
+ interface SubscribableGameController<TState> extends DisposableGameController {
15
+ subscribe(listener: () => void): () => void;
16
+ snapshot(): TState;
17
+ }
18
+ /**
19
+ * 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
20
+ * useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
21
+ *
22
+ * 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
23
+ * 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
24
+ * 任何报错。检测到这种情况就立刻抛错,并直接告诉你怎么修。
25
+ */
26
+ declare function useGameController<TController extends SubscribableGameController<unknown>>(createController: () => TController): {
27
+ game: TController;
28
+ state: ReturnType<TController["snapshot"]>;
29
+ };
30
+
31
+ export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
@@ -10,4 +10,22 @@ interface DisposableGameController {
10
10
  */
11
11
  declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
12
12
 
13
- export { type DisposableGameController, useOwnedGameController };
13
+ /** 同时提供订阅与权威快照读取的游戏 Controller,是最小可渲染运行时。 */
14
+ interface SubscribableGameController<TState> extends DisposableGameController {
15
+ subscribe(listener: () => void): () => void;
16
+ snapshot(): TState;
17
+ }
18
+ /**
19
+ * 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
20
+ * useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
21
+ *
22
+ * 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
23
+ * 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
24
+ * 任何报错。检测到这种情况就立刻抛错,并直接告诉你怎么修。
25
+ */
26
+ declare function useGameController<TController extends SubscribableGameController<unknown>>(createController: () => TController): {
27
+ game: TController;
28
+ state: ReturnType<TController["snapshot"]>;
29
+ };
30
+
31
+ export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var react_exports = {};
22
22
  __export(react_exports, {
23
23
  browserGameClock: () => browserGameClock,
24
+ useGameController: () => useGameController,
24
25
  useOwnedGameController: () => useOwnedGameController
25
26
  });
26
27
  module.exports = __toCommonJS(react_exports);
@@ -50,8 +51,57 @@ function useOwnedGameController(createController) {
50
51
  }, [controller]);
51
52
  return controller;
52
53
  }
54
+
55
+ // src/react/use-game-controller.ts
56
+ var import_react2 = require("react");
57
+ function useGameController(createController) {
58
+ const game = useOwnedGameController(createController);
59
+ useSnapshotIntegrityCheck(game);
60
+ const state = (0, import_react2.useSyncExternalStore)(
61
+ game.subscribe,
62
+ game.snapshot,
63
+ game.snapshot
64
+ );
65
+ return { game, state };
66
+ }
67
+ function useSnapshotIntegrityCheck(game) {
68
+ const reported = (0, import_react2.useRef)(false);
69
+ (0, import_react2.useEffect)(() => {
70
+ if (process.env.NODE_ENV === "production") return void 0;
71
+ let previous = game.snapshot();
72
+ let previousJson;
73
+ try {
74
+ previousJson = JSON.stringify(previous);
75
+ } catch {
76
+ previousJson = void 0;
77
+ }
78
+ return game.subscribe(() => {
79
+ if (reported.current) return;
80
+ const next = game.snapshot();
81
+ if (!Object.is(previous, next)) {
82
+ previous = next;
83
+ previousJson = void 0;
84
+ return;
85
+ }
86
+ let nextJson;
87
+ try {
88
+ nextJson = JSON.stringify(next);
89
+ } catch {
90
+ return;
91
+ }
92
+ if (previousJson !== void 0 && nextJson !== previousJson) {
93
+ reported.current = true;
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"
96
+ );
97
+ }
98
+ previousJson = nextJson;
99
+ });
100
+ }, [game]);
101
+ }
53
102
  // Annotate the CommonJS export names for ESM import in node:
54
103
  0 && (module.exports = {
55
104
  browserGameClock,
105
+ useGameController,
56
106
  useOwnedGameController
57
107
  });
@@ -23,7 +23,56 @@ function useOwnedGameController(createController) {
23
23
  }, [controller]);
24
24
  return controller;
25
25
  }
26
+
27
+ // src/react/use-game-controller.ts
28
+ import { useEffect as useEffect2, useRef as useRef2, useSyncExternalStore } from "react";
29
+ function useGameController(createController) {
30
+ const game = useOwnedGameController(createController);
31
+ useSnapshotIntegrityCheck(game);
32
+ const state = useSyncExternalStore(
33
+ game.subscribe,
34
+ game.snapshot,
35
+ game.snapshot
36
+ );
37
+ return { game, state };
38
+ }
39
+ function useSnapshotIntegrityCheck(game) {
40
+ const reported = useRef2(false);
41
+ useEffect2(() => {
42
+ if (process.env.NODE_ENV === "production") return void 0;
43
+ let previous = game.snapshot();
44
+ let previousJson;
45
+ try {
46
+ previousJson = JSON.stringify(previous);
47
+ } catch {
48
+ previousJson = void 0;
49
+ }
50
+ return game.subscribe(() => {
51
+ if (reported.current) return;
52
+ const next = game.snapshot();
53
+ if (!Object.is(previous, next)) {
54
+ previous = next;
55
+ previousJson = void 0;
56
+ return;
57
+ }
58
+ let nextJson;
59
+ try {
60
+ nextJson = JSON.stringify(next);
61
+ } catch {
62
+ return;
63
+ }
64
+ if (previousJson !== void 0 && nextJson !== previousJson) {
65
+ reported.current = true;
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"
68
+ );
69
+ }
70
+ previousJson = nextJson;
71
+ });
72
+ }, [game]);
73
+ }
26
74
  export {
27
75
  browserGameClock,
76
+ useGameController,
28
77
  useOwnedGameController
29
78
  };
@@ -33,6 +33,16 @@ 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;
36
46
  }
37
47
 
38
48
  /** 单条 React 主流程测试留下的可序列化运行期证据。 */
@@ -47,8 +57,6 @@ interface ReactPlaythroughEvidence {
47
57
  boundedRuns: number;
48
58
  /** 最后一次 stepUntil 成功后新增的 Vitest 断言数。 */
49
59
  assertionsAfterOutcome: number;
50
- /** 已验证相对 entered snapshot 发生变化的进展或终局 checkpoint 次数。 */
51
- changedOutcomeCheckpoints: number;
52
60
  /** 已记录的 checkpoint 类型,供 reporter 给出精确提示。 */
53
61
  checkpoints: ReactPlaythroughCheckpointKind[];
54
62
  /** helper 是否已经完成全部证据校验。 */
@@ -57,7 +65,7 @@ interface ReactPlaythroughEvidence {
57
65
  /** 通过 Vitest task metadata 从 worker 传递给主线程 reporter 的数据。 */
58
66
  interface ReactPlaythroughMetadata {
59
67
  /** metadata 结构版本,用于拒绝无法识别的旧数据。 */
60
- version: 2;
68
+ version: 3;
61
69
  /** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
62
70
  waiverReason?: string;
63
71
  /** 测试执行期间持续更新的客观证据。 */
@@ -66,7 +74,7 @@ interface ReactPlaythroughMetadata {
66
74
 
67
75
  /** 玩家输入在最低可玩流程中的语义阶段。 */
68
76
  type ReactPlaythroughInputKind = "entry" | "primary";
69
- /** 可审计状态证据;entered 是基线,progress/terminal 是有效结果。 */
77
+ /** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
70
78
  type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
71
79
  /** 主流程回调唯一需要学习的测试工具。 */
72
80
  interface ReactPlaythroughArguments {
@@ -82,10 +90,10 @@ interface ReactPlaythroughArguments {
82
90
  */
83
91
  performInput(kind: ReactPlaythroughInputKind, input: () => void | Promise<void>): Promise<void>;
84
92
  /**
85
- * 记录只读状态证据。先在 entry 后记录 entered,再在 primary 和 stepUntil
86
- * 后记录 progress 或 terminal;Canvas 游戏应传 Telemetry snapshot
93
+ * 记录流程签到。先在 entry 后记录 entered,再在 primary、stepUntil
94
+ * 结果断言后记录 progress 或 terminal。
87
95
  */
88
- checkpoint(kind: ReactPlaythroughCheckpointKind, snapshot: unknown): void;
96
+ checkpoint(kind: ReactPlaythroughCheckpointKind): void;
89
97
  /**
90
98
  * 在硬性步数上限内等待权威结果。
91
99
  * 返回后必须再断言该结果,进入中间 active/running 状态不算完成。
@@ -33,6 +33,16 @@ 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;
36
46
  }
37
47
 
38
48
  /** 单条 React 主流程测试留下的可序列化运行期证据。 */
@@ -47,8 +57,6 @@ interface ReactPlaythroughEvidence {
47
57
  boundedRuns: number;
48
58
  /** 最后一次 stepUntil 成功后新增的 Vitest 断言数。 */
49
59
  assertionsAfterOutcome: number;
50
- /** 已验证相对 entered snapshot 发生变化的进展或终局 checkpoint 次数。 */
51
- changedOutcomeCheckpoints: number;
52
60
  /** 已记录的 checkpoint 类型,供 reporter 给出精确提示。 */
53
61
  checkpoints: ReactPlaythroughCheckpointKind[];
54
62
  /** helper 是否已经完成全部证据校验。 */
@@ -57,7 +65,7 @@ interface ReactPlaythroughEvidence {
57
65
  /** 通过 Vitest task metadata 从 worker 传递给主线程 reporter 的数据。 */
58
66
  interface ReactPlaythroughMetadata {
59
67
  /** metadata 结构版本,用于拒绝无法识别的旧数据。 */
60
- version: 2;
68
+ version: 3;
61
69
  /** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
62
70
  waiverReason?: string;
63
71
  /** 测试执行期间持续更新的客观证据。 */
@@ -66,7 +74,7 @@ interface ReactPlaythroughMetadata {
66
74
 
67
75
  /** 玩家输入在最低可玩流程中的语义阶段。 */
68
76
  type ReactPlaythroughInputKind = "entry" | "primary";
69
- /** 可审计状态证据;entered 是基线,progress/terminal 是有效结果。 */
77
+ /** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
70
78
  type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
71
79
  /** 主流程回调唯一需要学习的测试工具。 */
72
80
  interface ReactPlaythroughArguments {
@@ -82,10 +90,10 @@ interface ReactPlaythroughArguments {
82
90
  */
83
91
  performInput(kind: ReactPlaythroughInputKind, input: () => void | Promise<void>): Promise<void>;
84
92
  /**
85
- * 记录只读状态证据。先在 entry 后记录 entered,再在 primary 和 stepUntil
86
- * 后记录 progress 或 terminal;Canvas 游戏应传 Telemetry snapshot
93
+ * 记录流程签到。先在 entry 后记录 entered,再在 primary、stepUntil
94
+ * 结果断言后记录 progress 或 terminal。
87
95
  */
88
- checkpoint(kind: ReactPlaythroughCheckpointKind, snapshot: unknown): void;
96
+ checkpoint(kind: ReactPlaythroughCheckpointKind): void;
89
97
  /**
90
98
  * 在硬性步数上限内等待权威结果。
91
99
  * 返回后必须再断言该结果,进入中间 active/running 状态不算完成。
@@ -151,20 +151,25 @@ var INPUT_EVENTS = [
151
151
  "touchstart",
152
152
  "touchend"
153
153
  ];
154
+ var MIN_CHECKPOINTS = 2;
154
155
  function describeMissingEvidence(evidence) {
155
156
  if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
156
157
  if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
157
158
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
158
159
  if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
159
160
  if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
160
- if (evidence.changedOutcomeCheckpoints === 0) {
161
- return "\u53D1\u751F\u72B6\u6001\u53D8\u5316\u7684 progress/terminal checkpoint";
161
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS)
162
+ return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
163
+ if (!evidence.checkpoints.some(
164
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
165
+ )) {
166
+ return "progress/terminal checkpoint";
162
167
  }
163
168
  return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
164
169
  }
165
170
  function createMetadata(waiverReason) {
166
171
  return {
167
- version: 2,
172
+ version: 3,
168
173
  waiverReason,
169
174
  evidence: {
170
175
  domInputEvents: 0,
@@ -172,23 +177,11 @@ function createMetadata(waiverReason) {
172
177
  primaryInputs: 0,
173
178
  boundedRuns: 0,
174
179
  assertionsAfterOutcome: 0,
175
- changedOutcomeCheckpoints: 0,
176
180
  checkpoints: [],
177
181
  verified: false
178
182
  }
179
183
  };
180
184
  }
181
- function snapshotFingerprint(snapshot) {
182
- try {
183
- const serialized = JSON.stringify(snapshot);
184
- if (serialized === void 0) throw new Error("unsupported value");
185
- return serialized;
186
- } catch {
187
- throw new Error(
188
- "checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
189
- );
190
- }
191
- }
192
185
  function definePlaythrough(element, run, waiverReason) {
193
186
  const reason = normalizePlaythroughWaiverReason(waiverReason);
194
187
  const metadata = createMetadata(reason);
@@ -198,7 +191,8 @@ function definePlaythrough(element, run, waiverReason) {
198
191
  }, async ({ expect }) => {
199
192
  const evidence = metadata.evidence;
200
193
  let assertionsAtOutcome;
201
- let enteredSnapshot;
194
+ let enteredRecorded = false;
195
+ let domTextAtEntered;
202
196
  const recordInput = () => {
203
197
  evidence.domInputEvents += 1;
204
198
  };
@@ -218,14 +212,14 @@ function definePlaythrough(element, run, waiverReason) {
218
212
  user,
219
213
  expect,
220
214
  async performInput(kind, input) {
221
- if (kind === "entry" && enteredSnapshot !== void 0) {
215
+ if (kind === "entry" && enteredRecorded) {
222
216
  throw new Error(
223
217
  'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
224
218
  );
225
219
  }
226
- if (kind === "primary" && enteredSnapshot === void 0) {
220
+ if (kind === "primary" && !enteredRecorded) {
227
221
  throw new Error(
228
- 'Before performInput("primary"), run an entry input and checkpoint("entered", snapshot).'
222
+ 'Before performInput("primary"), run an entry input and checkpoint("entered").'
229
223
  );
230
224
  }
231
225
  const inputsBefore = evidence.domInputEvents;
@@ -238,10 +232,9 @@ function definePlaythrough(element, run, waiverReason) {
238
232
  if (kind === "entry") evidence.entryInputs += 1;
239
233
  else evidence.primaryInputs += 1;
240
234
  },
241
- checkpoint(kind, snapshot) {
242
- const fingerprint = snapshotFingerprint(snapshot);
235
+ checkpoint(kind) {
243
236
  if (kind === "entered") {
244
- if (enteredSnapshot !== void 0) {
237
+ if (enteredRecorded) {
245
238
  throw new Error(
246
239
  'checkpoint("entered") may only be recorded once, before the primary input.'
247
240
  );
@@ -251,7 +244,8 @@ function definePlaythrough(element, run, waiverReason) {
251
244
  'checkpoint("entered") must follow performInput("entry", ...).'
252
245
  );
253
246
  }
254
- enteredSnapshot = fingerprint;
247
+ enteredRecorded = true;
248
+ domTextAtEntered = document.body.textContent ?? "";
255
249
  evidence.checkpoints.push(kind);
256
250
  return;
257
251
  }
@@ -267,15 +261,9 @@ function definePlaythrough(element, run, waiverReason) {
267
261
  }
268
262
  if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
269
263
  throw new Error(
270
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}", snapshot).`
264
+ `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
271
265
  );
272
266
  }
273
- if (fingerprint === enteredSnapshot) {
274
- throw new Error(
275
- `checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
276
- );
277
- }
278
- evidence.changedOutcomeCheckpoints += 1;
279
267
  evidence.checkpoints.push(kind);
280
268
  },
281
269
  async stepUntil(condition, options = {}) {
@@ -285,6 +273,11 @@ function definePlaythrough(element, run, waiverReason) {
285
273
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
286
274
  );
287
275
  }
276
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
277
+ 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'
279
+ );
280
+ }
288
281
  evidence.boundedRuns += 1;
289
282
  assertionsAtOutcome = expect.getState().assertionCalls;
290
283
  return steps;
@@ -310,9 +303,11 @@ function definePlaythrough(element, run, waiverReason) {
310
303
  "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
311
304
  );
312
305
  }
313
- if (evidence.changedOutcomeCheckpoints === 0) {
306
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
307
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
308
+ )) {
314
309
  throw new Error(
315
- 'After asserting the result, record checkpoint("progress", snapshot) or checkpoint("terminal", snapshot). The snapshot must differ from checkpoint("entered").'
310
+ `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
316
311
  );
317
312
  }
318
313
  evidence.verified = true;
@@ -333,7 +328,7 @@ function auditReactPlaythroughRun(tests) {
333
328
  const declared = tests.filter((candidate) => candidate.metadata);
334
329
  const valid = declared.filter(({ state, metadata }) => {
335
330
  const evidence = metadata?.evidence;
336
- return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.changedOutcomeCheckpoints > 0 && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
331
+ return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
337
332
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
338
333
  );
339
334
  });
@@ -343,7 +338,7 @@ function auditReactPlaythroughRun(tests) {
343
338
  const issues = [];
344
339
  if (declared.length === 0) {
345
340
  issues.push(
346
- '\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress/terminal checkpoint\u3002'
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'
347
342
  );
348
343
  } else {
349
344
  for (const candidate of declared) {
@@ -113,20 +113,25 @@ var INPUT_EVENTS = [
113
113
  "touchstart",
114
114
  "touchend"
115
115
  ];
116
+ var MIN_CHECKPOINTS = 2;
116
117
  function describeMissingEvidence(evidence) {
117
118
  if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
118
119
  if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
119
120
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
120
121
  if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
121
122
  if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
122
- if (evidence.changedOutcomeCheckpoints === 0) {
123
- return "\u53D1\u751F\u72B6\u6001\u53D8\u5316\u7684 progress/terminal checkpoint";
123
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS)
124
+ return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
125
+ if (!evidence.checkpoints.some(
126
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
127
+ )) {
128
+ return "progress/terminal checkpoint";
124
129
  }
125
130
  return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
126
131
  }
127
132
  function createMetadata(waiverReason) {
128
133
  return {
129
- version: 2,
134
+ version: 3,
130
135
  waiverReason,
131
136
  evidence: {
132
137
  domInputEvents: 0,
@@ -134,23 +139,11 @@ function createMetadata(waiverReason) {
134
139
  primaryInputs: 0,
135
140
  boundedRuns: 0,
136
141
  assertionsAfterOutcome: 0,
137
- changedOutcomeCheckpoints: 0,
138
142
  checkpoints: [],
139
143
  verified: false
140
144
  }
141
145
  };
142
146
  }
143
- function snapshotFingerprint(snapshot) {
144
- try {
145
- const serialized = JSON.stringify(snapshot);
146
- if (serialized === void 0) throw new Error("unsupported value");
147
- return serialized;
148
- } catch {
149
- throw new Error(
150
- "checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
151
- );
152
- }
153
- }
154
147
  function definePlaythrough(element, run, waiverReason) {
155
148
  const reason = normalizePlaythroughWaiverReason(waiverReason);
156
149
  const metadata = createMetadata(reason);
@@ -160,7 +153,8 @@ function definePlaythrough(element, run, waiverReason) {
160
153
  }, async ({ expect }) => {
161
154
  const evidence = metadata.evidence;
162
155
  let assertionsAtOutcome;
163
- let enteredSnapshot;
156
+ let enteredRecorded = false;
157
+ let domTextAtEntered;
164
158
  const recordInput = () => {
165
159
  evidence.domInputEvents += 1;
166
160
  };
@@ -180,14 +174,14 @@ function definePlaythrough(element, run, waiverReason) {
180
174
  user,
181
175
  expect,
182
176
  async performInput(kind, input) {
183
- if (kind === "entry" && enteredSnapshot !== void 0) {
177
+ if (kind === "entry" && enteredRecorded) {
184
178
  throw new Error(
185
179
  'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
186
180
  );
187
181
  }
188
- if (kind === "primary" && enteredSnapshot === void 0) {
182
+ if (kind === "primary" && !enteredRecorded) {
189
183
  throw new Error(
190
- 'Before performInput("primary"), run an entry input and checkpoint("entered", snapshot).'
184
+ 'Before performInput("primary"), run an entry input and checkpoint("entered").'
191
185
  );
192
186
  }
193
187
  const inputsBefore = evidence.domInputEvents;
@@ -200,10 +194,9 @@ function definePlaythrough(element, run, waiverReason) {
200
194
  if (kind === "entry") evidence.entryInputs += 1;
201
195
  else evidence.primaryInputs += 1;
202
196
  },
203
- checkpoint(kind, snapshot) {
204
- const fingerprint = snapshotFingerprint(snapshot);
197
+ checkpoint(kind) {
205
198
  if (kind === "entered") {
206
- if (enteredSnapshot !== void 0) {
199
+ if (enteredRecorded) {
207
200
  throw new Error(
208
201
  'checkpoint("entered") may only be recorded once, before the primary input.'
209
202
  );
@@ -213,7 +206,8 @@ function definePlaythrough(element, run, waiverReason) {
213
206
  'checkpoint("entered") must follow performInput("entry", ...).'
214
207
  );
215
208
  }
216
- enteredSnapshot = fingerprint;
209
+ enteredRecorded = true;
210
+ domTextAtEntered = document.body.textContent ?? "";
217
211
  evidence.checkpoints.push(kind);
218
212
  return;
219
213
  }
@@ -229,15 +223,9 @@ function definePlaythrough(element, run, waiverReason) {
229
223
  }
230
224
  if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
231
225
  throw new Error(
232
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}", snapshot).`
226
+ `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
233
227
  );
234
228
  }
235
- if (fingerprint === enteredSnapshot) {
236
- throw new Error(
237
- `checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
238
- );
239
- }
240
- evidence.changedOutcomeCheckpoints += 1;
241
229
  evidence.checkpoints.push(kind);
242
230
  },
243
231
  async stepUntil(condition, options = {}) {
@@ -247,6 +235,11 @@ function definePlaythrough(element, run, waiverReason) {
247
235
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
248
236
  );
249
237
  }
238
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
239
+ 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'
241
+ );
242
+ }
250
243
  evidence.boundedRuns += 1;
251
244
  assertionsAtOutcome = expect.getState().assertionCalls;
252
245
  return steps;
@@ -272,9 +265,11 @@ function definePlaythrough(element, run, waiverReason) {
272
265
  "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
273
266
  );
274
267
  }
275
- if (evidence.changedOutcomeCheckpoints === 0) {
268
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
269
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
270
+ )) {
276
271
  throw new Error(
277
- 'After asserting the result, record checkpoint("progress", snapshot) or checkpoint("terminal", snapshot). The snapshot must differ from checkpoint("entered").'
272
+ `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
278
273
  );
279
274
  }
280
275
  evidence.verified = true;
@@ -295,7 +290,7 @@ function auditReactPlaythroughRun(tests) {
295
290
  const declared = tests.filter((candidate) => candidate.metadata);
296
291
  const valid = declared.filter(({ state, metadata }) => {
297
292
  const evidence = metadata?.evidence;
298
- return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.changedOutcomeCheckpoints > 0 && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
293
+ return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
299
294
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
300
295
  );
301
296
  });
@@ -305,7 +300,7 @@ function auditReactPlaythroughRun(tests) {
305
300
  const issues = [];
306
301
  if (declared.length === 0) {
307
302
  issues.push(
308
- '\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress/terminal checkpoint\u3002'
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'
309
304
  );
310
305
  } else {
311
306
  for (const candidate of declared) {
@@ -40,6 +40,7 @@ var import_config = require("vitest/config");
40
40
  // src/react/react-playthrough-reporter.ts
41
41
  var import_node_fs = require("fs");
42
42
  var import_node_path = require("path");
43
+ var import_node_util = require("util");
43
44
 
44
45
  // src/react/react-playthrough.ts
45
46
  var import_react2 = require("@testing-library/react");
@@ -101,20 +102,25 @@ var INPUT_EVENTS = [
101
102
  "touchstart",
102
103
  "touchend"
103
104
  ];
105
+ var MIN_CHECKPOINTS = 2;
104
106
  function describeMissingEvidence(evidence) {
105
107
  if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
106
108
  if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
107
109
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
108
110
  if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
109
111
  if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
110
- if (evidence.changedOutcomeCheckpoints === 0) {
111
- return "\u53D1\u751F\u72B6\u6001\u53D8\u5316\u7684 progress/terminal checkpoint";
112
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS)
113
+ return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
114
+ if (!evidence.checkpoints.some(
115
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
116
+ )) {
117
+ return "progress/terminal checkpoint";
112
118
  }
113
119
  return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
114
120
  }
115
121
  function createMetadata(waiverReason) {
116
122
  return {
117
- version: 2,
123
+ version: 3,
118
124
  waiverReason,
119
125
  evidence: {
120
126
  domInputEvents: 0,
@@ -122,23 +128,11 @@ function createMetadata(waiverReason) {
122
128
  primaryInputs: 0,
123
129
  boundedRuns: 0,
124
130
  assertionsAfterOutcome: 0,
125
- changedOutcomeCheckpoints: 0,
126
131
  checkpoints: [],
127
132
  verified: false
128
133
  }
129
134
  };
130
135
  }
131
- function snapshotFingerprint(snapshot) {
132
- try {
133
- const serialized = JSON.stringify(snapshot);
134
- if (serialized === void 0) throw new Error("unsupported value");
135
- return serialized;
136
- } catch {
137
- throw new Error(
138
- "checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
139
- );
140
- }
141
- }
142
136
  function definePlaythrough(element, run, waiverReason) {
143
137
  const reason = normalizePlaythroughWaiverReason(waiverReason);
144
138
  const metadata = createMetadata(reason);
@@ -148,7 +142,8 @@ function definePlaythrough(element, run, waiverReason) {
148
142
  }, async ({ expect }) => {
149
143
  const evidence = metadata.evidence;
150
144
  let assertionsAtOutcome;
151
- let enteredSnapshot;
145
+ let enteredRecorded = false;
146
+ let domTextAtEntered;
152
147
  const recordInput = () => {
153
148
  evidence.domInputEvents += 1;
154
149
  };
@@ -168,14 +163,14 @@ function definePlaythrough(element, run, waiverReason) {
168
163
  user,
169
164
  expect,
170
165
  async performInput(kind, input) {
171
- if (kind === "entry" && enteredSnapshot !== void 0) {
166
+ if (kind === "entry" && enteredRecorded) {
172
167
  throw new Error(
173
168
  'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
174
169
  );
175
170
  }
176
- if (kind === "primary" && enteredSnapshot === void 0) {
171
+ if (kind === "primary" && !enteredRecorded) {
177
172
  throw new Error(
178
- 'Before performInput("primary"), run an entry input and checkpoint("entered", snapshot).'
173
+ 'Before performInput("primary"), run an entry input and checkpoint("entered").'
179
174
  );
180
175
  }
181
176
  const inputsBefore = evidence.domInputEvents;
@@ -188,10 +183,9 @@ function definePlaythrough(element, run, waiverReason) {
188
183
  if (kind === "entry") evidence.entryInputs += 1;
189
184
  else evidence.primaryInputs += 1;
190
185
  },
191
- checkpoint(kind, snapshot) {
192
- const fingerprint = snapshotFingerprint(snapshot);
186
+ checkpoint(kind) {
193
187
  if (kind === "entered") {
194
- if (enteredSnapshot !== void 0) {
188
+ if (enteredRecorded) {
195
189
  throw new Error(
196
190
  'checkpoint("entered") may only be recorded once, before the primary input.'
197
191
  );
@@ -201,7 +195,8 @@ function definePlaythrough(element, run, waiverReason) {
201
195
  'checkpoint("entered") must follow performInput("entry", ...).'
202
196
  );
203
197
  }
204
- enteredSnapshot = fingerprint;
198
+ enteredRecorded = true;
199
+ domTextAtEntered = document.body.textContent ?? "";
205
200
  evidence.checkpoints.push(kind);
206
201
  return;
207
202
  }
@@ -217,15 +212,9 @@ function definePlaythrough(element, run, waiverReason) {
217
212
  }
218
213
  if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
219
214
  throw new Error(
220
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}", snapshot).`
215
+ `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
221
216
  );
222
217
  }
223
- if (fingerprint === enteredSnapshot) {
224
- throw new Error(
225
- `checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
226
- );
227
- }
228
- evidence.changedOutcomeCheckpoints += 1;
229
218
  evidence.checkpoints.push(kind);
230
219
  },
231
220
  async stepUntil(condition, options = {}) {
@@ -235,6 +224,11 @@ function definePlaythrough(element, run, waiverReason) {
235
224
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
236
225
  );
237
226
  }
227
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
228
+ 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'
230
+ );
231
+ }
238
232
  evidence.boundedRuns += 1;
239
233
  assertionsAtOutcome = expect.getState().assertionCalls;
240
234
  return steps;
@@ -260,9 +254,11 @@ function definePlaythrough(element, run, waiverReason) {
260
254
  "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
261
255
  );
262
256
  }
263
- if (evidence.changedOutcomeCheckpoints === 0) {
257
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
258
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
259
+ )) {
264
260
  throw new Error(
265
- 'After asserting the result, record checkpoint("progress", snapshot) or checkpoint("terminal", snapshot). The snapshot must differ from checkpoint("entered").'
261
+ `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
266
262
  );
267
263
  }
268
264
  evidence.verified = true;
@@ -283,7 +279,7 @@ function auditReactPlaythroughRun(tests) {
283
279
  const declared = tests.filter((candidate) => candidate.metadata);
284
280
  const valid = declared.filter(({ state, metadata }) => {
285
281
  const evidence = metadata?.evidence;
286
- return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.changedOutcomeCheckpoints > 0 && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
282
+ return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
287
283
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
288
284
  );
289
285
  });
@@ -293,7 +289,7 @@ function auditReactPlaythroughRun(tests) {
293
289
  const issues = [];
294
290
  if (declared.length === 0) {
295
291
  issues.push(
296
- '\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress/terminal checkpoint\u3002'
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'
297
293
  );
298
294
  } else {
299
295
  for (const candidate of declared) {
@@ -323,7 +319,7 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
323
319
  function isMetadata(value) {
324
320
  if (!value || typeof value !== "object") return false;
325
321
  const metadata = value;
326
- return metadata.version === 2 && Boolean(metadata.evidence);
322
+ return metadata.version === 3 && Boolean(metadata.evidence);
327
323
  }
328
324
  function toAuditInput(test2) {
329
325
  const metadata = test2.meta().reactPlaythrough;
@@ -335,7 +331,18 @@ function toAuditInput(test2) {
335
331
  }
336
332
  function firstLine(value) {
337
333
  if (typeof value !== "string") return void 0;
338
- return value.split("\n").map((line) => line.trim()).find(Boolean);
334
+ return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
335
+ }
336
+ function failureHint(value) {
337
+ const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
338
+ (match) => match[1]
339
+ );
340
+ if (names.length === 0) return void 0;
341
+ return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
342
+ }
343
+ function errorLocation(value) {
344
+ const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
345
+ return match?.[1];
339
346
  }
340
347
  function toModuleResult(module2, projectRoot) {
341
348
  const tests = [...module2.children.allTests()];
@@ -345,13 +352,48 @@ function toModuleResult(module2, projectRoot) {
345
352
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
346
353
  )
347
354
  ].filter((message) => Boolean(message));
355
+ const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
356
+ const raw = test2.result().errors?.[0]?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
357
+ return {
358
+ test: test2.fullName,
359
+ cause: firstLine(raw) ?? "Unknown failure",
360
+ location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
361
+ hint: failureHint(raw)
362
+ };
363
+ });
364
+ if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
365
+ const raw = module2.errors()[0]?.message ?? "Module failed to load";
366
+ failures.push({
367
+ test: "<collection>",
368
+ cause: firstLine(raw) ?? "Module failed to load",
369
+ location: errorLocation(raw),
370
+ hint: failureHint(raw)
371
+ });
372
+ }
348
373
  return {
349
374
  file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
350
375
  state: module2.state(),
351
376
  errors,
352
- tests: tests.map(toAuditInput)
377
+ tests: tests.map(toAuditInput),
378
+ failures
353
379
  };
354
380
  }
381
+ function formatFailureSummary(modules) {
382
+ const failures = modules.flatMap(
383
+ (module2) => (module2.failures ?? []).map((failure) => ({ ...failure, file: module2.file }))
384
+ );
385
+ if (failures.length === 0) return ["TEST_RESULT: PASS"];
386
+ const lines = [`FAILED_TESTS: ${failures.length}`];
387
+ for (const [index, failure] of failures.entries()) {
388
+ lines.push(`FAILURE_${index + 1}: ${failure.file}`);
389
+ lines.push(`TEST: ${failure.test}`);
390
+ lines.push(`CAUSE: ${failure.cause}`);
391
+ if (failure.location) lines.push(`AT: ${failure.location}`);
392
+ if (failure.hint) lines.push(`HINT: ${failure.hint}`);
393
+ }
394
+ lines.push("TEST_RESULT: FAIL");
395
+ return lines;
396
+ }
355
397
  function assessReactPlaythroughReport(input) {
356
398
  const base = { file: input.expectedFile };
357
399
  if (!input.expectedFileScheduled) {
@@ -367,7 +409,7 @@ function assessReactPlaythroughReport(input) {
367
409
  ...base,
368
410
  status: "FAILED",
369
411
  cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
370
- 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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress/terminal checkpoint\u3002Canvas \u7528\u771F\u5B9E DOM \u4E8B\u4EF6\u8F93\u5165\u5E76\u8BFB\u53D6 Telemetry snapshot\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',
371
413
  failsRun: true
372
414
  };
373
415
  }
@@ -400,12 +442,14 @@ function assessReactPlaythroughReport(input) {
400
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";
401
443
  const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
402
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);
403
447
  const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
404
448
  return {
405
449
  ...base,
406
450
  status: "FAILED",
407
451
  cause,
408
- next: 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\uFF1AperformInput("entry") \u540E\u8BB0\u5F55 entered snapshot\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u53D8\u5316\uFF0C\u65AD\u8A00\u540E\u8BB0\u5F55 progress \u6216 terminal snapshot\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\u771F\u5B9E\u72B6\u6001\u7684\u53EA\u8BFB snapshot\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
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",
409
453
  failsRun: true
410
454
  };
411
455
  }
@@ -470,6 +514,14 @@ var ReactPlaythroughReporter = class {
470
514
  } else {
471
515
  console.log(output);
472
516
  }
517
+ const summary = formatFailureSummary(
518
+ testModules.map((module2) => toModuleResult(module2, this.projectRoot))
519
+ );
520
+ if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
521
+ summary[0] = "TEST_RESULT: FAIL";
522
+ }
523
+ console.log(`
524
+ ${summary.join("\n")}`);
473
525
  }
474
526
  };
475
527
 
@@ -518,6 +570,7 @@ function defineReactGameVitestConfig(options) {
518
570
  environmentOptions: {
519
571
  jsdom: { url: "http://localhost/", pretendToBeVisual: true }
520
572
  },
573
+ includeTaskLocation: true,
521
574
  setupFiles: [
522
575
  "miaoda-game-devkit/react/vitest-setup",
523
576
  ...options.additionalSetupFiles ?? []
@@ -525,8 +578,8 @@ function defineReactGameVitestConfig(options) {
525
578
  sequence: {
526
579
  setupFiles: "list"
527
580
  },
528
- // minimal 保留业务失败;附加 reporter 负责项目级最低可玩性门禁。
529
- reporters: ["minimal", new ReactPlaythroughReporter(options.projectRoot)],
581
+ // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
582
+ reporters: [new ReactPlaythroughReporter(options.projectRoot)],
530
583
  restoreMocks: true,
531
584
  clearMocks: true,
532
585
  testTimeout: options.testTimeout,
@@ -6,6 +6,7 @@ import { defineConfig } from "vitest/config";
6
6
  // src/react/react-playthrough-reporter.ts
7
7
  import { existsSync } from "fs";
8
8
  import { relative, resolve } from "path";
9
+ import { stripVTControlCharacters } from "util";
9
10
 
10
11
  // src/react/react-playthrough.ts
11
12
  import { render } from "@testing-library/react";
@@ -67,20 +68,25 @@ var INPUT_EVENTS = [
67
68
  "touchstart",
68
69
  "touchend"
69
70
  ];
71
+ var MIN_CHECKPOINTS = 2;
70
72
  function describeMissingEvidence(evidence) {
71
73
  if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
72
74
  if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
73
75
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
74
76
  if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
75
77
  if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
76
- if (evidence.changedOutcomeCheckpoints === 0) {
77
- return "\u53D1\u751F\u72B6\u6001\u53D8\u5316\u7684 progress/terminal checkpoint";
78
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS)
79
+ return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
80
+ if (!evidence.checkpoints.some(
81
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
82
+ )) {
83
+ return "progress/terminal checkpoint";
78
84
  }
79
85
  return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
80
86
  }
81
87
  function createMetadata(waiverReason) {
82
88
  return {
83
- version: 2,
89
+ version: 3,
84
90
  waiverReason,
85
91
  evidence: {
86
92
  domInputEvents: 0,
@@ -88,23 +94,11 @@ function createMetadata(waiverReason) {
88
94
  primaryInputs: 0,
89
95
  boundedRuns: 0,
90
96
  assertionsAfterOutcome: 0,
91
- changedOutcomeCheckpoints: 0,
92
97
  checkpoints: [],
93
98
  verified: false
94
99
  }
95
100
  };
96
101
  }
97
- function snapshotFingerprint(snapshot) {
98
- try {
99
- const serialized = JSON.stringify(snapshot);
100
- if (serialized === void 0) throw new Error("unsupported value");
101
- return serialized;
102
- } catch {
103
- throw new Error(
104
- "checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
105
- );
106
- }
107
- }
108
102
  function definePlaythrough(element, run, waiverReason) {
109
103
  const reason = normalizePlaythroughWaiverReason(waiverReason);
110
104
  const metadata = createMetadata(reason);
@@ -114,7 +108,8 @@ function definePlaythrough(element, run, waiverReason) {
114
108
  }, async ({ expect }) => {
115
109
  const evidence = metadata.evidence;
116
110
  let assertionsAtOutcome;
117
- let enteredSnapshot;
111
+ let enteredRecorded = false;
112
+ let domTextAtEntered;
118
113
  const recordInput = () => {
119
114
  evidence.domInputEvents += 1;
120
115
  };
@@ -134,14 +129,14 @@ function definePlaythrough(element, run, waiverReason) {
134
129
  user,
135
130
  expect,
136
131
  async performInput(kind, input) {
137
- if (kind === "entry" && enteredSnapshot !== void 0) {
132
+ if (kind === "entry" && enteredRecorded) {
138
133
  throw new Error(
139
134
  'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
140
135
  );
141
136
  }
142
- if (kind === "primary" && enteredSnapshot === void 0) {
137
+ if (kind === "primary" && !enteredRecorded) {
143
138
  throw new Error(
144
- 'Before performInput("primary"), run an entry input and checkpoint("entered", snapshot).'
139
+ 'Before performInput("primary"), run an entry input and checkpoint("entered").'
145
140
  );
146
141
  }
147
142
  const inputsBefore = evidence.domInputEvents;
@@ -154,10 +149,9 @@ function definePlaythrough(element, run, waiverReason) {
154
149
  if (kind === "entry") evidence.entryInputs += 1;
155
150
  else evidence.primaryInputs += 1;
156
151
  },
157
- checkpoint(kind, snapshot) {
158
- const fingerprint = snapshotFingerprint(snapshot);
152
+ checkpoint(kind) {
159
153
  if (kind === "entered") {
160
- if (enteredSnapshot !== void 0) {
154
+ if (enteredRecorded) {
161
155
  throw new Error(
162
156
  'checkpoint("entered") may only be recorded once, before the primary input.'
163
157
  );
@@ -167,7 +161,8 @@ function definePlaythrough(element, run, waiverReason) {
167
161
  'checkpoint("entered") must follow performInput("entry", ...).'
168
162
  );
169
163
  }
170
- enteredSnapshot = fingerprint;
164
+ enteredRecorded = true;
165
+ domTextAtEntered = document.body.textContent ?? "";
171
166
  evidence.checkpoints.push(kind);
172
167
  return;
173
168
  }
@@ -183,15 +178,9 @@ function definePlaythrough(element, run, waiverReason) {
183
178
  }
184
179
  if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
185
180
  throw new Error(
186
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}", snapshot).`
181
+ `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
187
182
  );
188
183
  }
189
- if (fingerprint === enteredSnapshot) {
190
- throw new Error(
191
- `checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
192
- );
193
- }
194
- evidence.changedOutcomeCheckpoints += 1;
195
184
  evidence.checkpoints.push(kind);
196
185
  },
197
186
  async stepUntil(condition, options = {}) {
@@ -201,6 +190,11 @@ function definePlaythrough(element, run, waiverReason) {
201
190
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
202
191
  );
203
192
  }
193
+ if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
194
+ 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'
196
+ );
197
+ }
204
198
  evidence.boundedRuns += 1;
205
199
  assertionsAtOutcome = expect.getState().assertionCalls;
206
200
  return steps;
@@ -226,9 +220,11 @@ function definePlaythrough(element, run, waiverReason) {
226
220
  "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
227
221
  );
228
222
  }
229
- if (evidence.changedOutcomeCheckpoints === 0) {
223
+ if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
224
+ (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
225
+ )) {
230
226
  throw new Error(
231
- 'After asserting the result, record checkpoint("progress", snapshot) or checkpoint("terminal", snapshot). The snapshot must differ from checkpoint("entered").'
227
+ `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
232
228
  );
233
229
  }
234
230
  evidence.verified = true;
@@ -249,7 +245,7 @@ function auditReactPlaythroughRun(tests) {
249
245
  const declared = tests.filter((candidate) => candidate.metadata);
250
246
  const valid = declared.filter(({ state, metadata }) => {
251
247
  const evidence = metadata?.evidence;
252
- return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.changedOutcomeCheckpoints > 0 && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
248
+ return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
253
249
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
254
250
  );
255
251
  });
@@ -259,7 +255,7 @@ function auditReactPlaythroughRun(tests) {
259
255
  const issues = [];
260
256
  if (declared.length === 0) {
261
257
  issues.push(
262
- '\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress/terminal checkpoint\u3002'
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'
263
259
  );
264
260
  } else {
265
261
  for (const candidate of declared) {
@@ -289,7 +285,7 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
289
285
  function isMetadata(value) {
290
286
  if (!value || typeof value !== "object") return false;
291
287
  const metadata = value;
292
- return metadata.version === 2 && Boolean(metadata.evidence);
288
+ return metadata.version === 3 && Boolean(metadata.evidence);
293
289
  }
294
290
  function toAuditInput(test2) {
295
291
  const metadata = test2.meta().reactPlaythrough;
@@ -301,7 +297,18 @@ function toAuditInput(test2) {
301
297
  }
302
298
  function firstLine(value) {
303
299
  if (typeof value !== "string") return void 0;
304
- return value.split("\n").map((line) => line.trim()).find(Boolean);
300
+ return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
301
+ }
302
+ function failureHint(value) {
303
+ const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
304
+ (match) => match[1]
305
+ );
306
+ if (names.length === 0) return void 0;
307
+ return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
308
+ }
309
+ function errorLocation(value) {
310
+ const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
311
+ return match?.[1];
305
312
  }
306
313
  function toModuleResult(module, projectRoot) {
307
314
  const tests = [...module.children.allTests()];
@@ -311,13 +318,48 @@ function toModuleResult(module, projectRoot) {
311
318
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
312
319
  )
313
320
  ].filter((message) => Boolean(message));
321
+ const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
322
+ const raw = test2.result().errors?.[0]?.message ?? module.errors()[0]?.message ?? "Unknown failure";
323
+ return {
324
+ test: test2.fullName,
325
+ cause: firstLine(raw) ?? "Unknown failure",
326
+ location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
327
+ hint: failureHint(raw)
328
+ };
329
+ });
330
+ if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
331
+ const raw = module.errors()[0]?.message ?? "Module failed to load";
332
+ failures.push({
333
+ test: "<collection>",
334
+ cause: firstLine(raw) ?? "Module failed to load",
335
+ location: errorLocation(raw),
336
+ hint: failureHint(raw)
337
+ });
338
+ }
314
339
  return {
315
340
  file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
316
341
  state: module.state(),
317
342
  errors,
318
- tests: tests.map(toAuditInput)
343
+ tests: tests.map(toAuditInput),
344
+ failures
319
345
  };
320
346
  }
347
+ function formatFailureSummary(modules) {
348
+ const failures = modules.flatMap(
349
+ (module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
350
+ );
351
+ if (failures.length === 0) return ["TEST_RESULT: PASS"];
352
+ const lines = [`FAILED_TESTS: ${failures.length}`];
353
+ for (const [index, failure] of failures.entries()) {
354
+ lines.push(`FAILURE_${index + 1}: ${failure.file}`);
355
+ lines.push(`TEST: ${failure.test}`);
356
+ lines.push(`CAUSE: ${failure.cause}`);
357
+ if (failure.location) lines.push(`AT: ${failure.location}`);
358
+ if (failure.hint) lines.push(`HINT: ${failure.hint}`);
359
+ }
360
+ lines.push("TEST_RESULT: FAIL");
361
+ return lines;
362
+ }
321
363
  function assessReactPlaythroughReport(input) {
322
364
  const base = { file: input.expectedFile };
323
365
  if (!input.expectedFileScheduled) {
@@ -333,7 +375,7 @@ function assessReactPlaythroughReport(input) {
333
375
  ...base,
334
376
  status: "FAILED",
335
377
  cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
336
- 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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress/terminal checkpoint\u3002Canvas \u7528\u771F\u5B9E DOM \u4E8B\u4EF6\u8F93\u5165\u5E76\u8BFB\u53D6 Telemetry snapshot\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',
337
379
  failsRun: true
338
380
  };
339
381
  }
@@ -366,12 +408,14 @@ function assessReactPlaythroughReport(input) {
366
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";
367
409
  const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
368
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);
369
413
  const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
370
414
  return {
371
415
  ...base,
372
416
  status: "FAILED",
373
417
  cause,
374
- next: 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\uFF1AperformInput("entry") \u540E\u8BB0\u5F55 entered snapshot\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u53D8\u5316\uFF0C\u65AD\u8A00\u540E\u8BB0\u5F55 progress \u6216 terminal snapshot\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\u771F\u5B9E\u72B6\u6001\u7684\u53EA\u8BFB snapshot\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
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",
375
419
  failsRun: true
376
420
  };
377
421
  }
@@ -436,6 +480,14 @@ var ReactPlaythroughReporter = class {
436
480
  } else {
437
481
  console.log(output);
438
482
  }
483
+ const summary = formatFailureSummary(
484
+ testModules.map((module) => toModuleResult(module, this.projectRoot))
485
+ );
486
+ if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
487
+ summary[0] = "TEST_RESULT: FAIL";
488
+ }
489
+ console.log(`
490
+ ${summary.join("\n")}`);
439
491
  }
440
492
  };
441
493
 
@@ -484,6 +536,7 @@ function defineReactGameVitestConfig(options) {
484
536
  environmentOptions: {
485
537
  jsdom: { url: "http://localhost/", pretendToBeVisual: true }
486
538
  },
539
+ includeTaskLocation: true,
487
540
  setupFiles: [
488
541
  "miaoda-game-devkit/react/vitest-setup",
489
542
  ...options.additionalSetupFiles ?? []
@@ -491,8 +544,8 @@ function defineReactGameVitestConfig(options) {
491
544
  sequence: {
492
545
  setupFiles: "list"
493
546
  },
494
- // minimal 保留业务失败;附加 reporter 负责项目级最低可玩性门禁。
495
- reporters: ["minimal", new ReactPlaythroughReporter(options.projectRoot)],
547
+ // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
548
+ reporters: [new ReactPlaythroughReporter(options.projectRoot)],
496
549
  restoreMocks: true,
497
550
  clearMocks: true,
498
551
  testTimeout: options.testTimeout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.2.20",
3
+ "version": "0.3.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",