miaoda-game-devkit 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,13 +18,21 @@
18
18
  这两次 checkpoint;checkpoint 只负责签到,不携带或验证状态。大型游戏仍只保留一条最短关键流程;
19
19
  分支、关卡规则和恢复清理由专门测试覆盖。不要再次调用 `userEvent.setup()`。
20
20
  时间、帧或自定义 scheduler 流程必须显式传入确定性 `step`;精确物理时间、暂停恢复
21
- 或调度清理测试可使用 `ManualGameClock`。
21
+ 或调度清理测试可使用 `ManualGameClock`。Canvas/Controller 流程在 `playthroughTest` 上声明一次
22
+ `observe`,返回 JSON 可序列化的权威 Telemetry 状态;框架自动比较流程状态并复用为超时诊断。
23
+
24
+ 失败的 production playthrough 会额外输出一条有界 `TRACE:`:记录 entry 后、primary 后和
25
+ 最后一次可观测状态,以及已完成 checkpoint 与推进步数。通过流程不输出 TRACE,避免增加
26
+ 常规 CI 噪声。显式启用 Vitest retry 时,每次尝试会重置证据,最终报告只展示最后一次尝试的
27
+ CAUSE 与 TRACE;若测试主体已完成、随后由 `afterEach` 或运行时错误判失败,reporter 仍会从
28
+ task metadata 恢复这条流程证据。状态优先来自 `observe`,没有 Telemetry 时才使用有界 DOM
29
+ 摘要;不要把 TRACE 当作新的断言或第二份业务状态。
22
30
 
23
31
  Vitest 的 fake timers(包括 `vi.advanceTimersToNextFrame()`)适合在 devkit 自身的
24
32
  专门 scheduler 合约中使用,但不在所有 production playthrough 中全局启用;React
25
33
  Testing Library 的 `asyncWrapper` 和 user-event 内部 timer 会因此互相影响。使用自定义
26
34
  scheduler、Worker 或未通过浏览器 timer 接入的引擎时,应传显式 `step` 并通过
27
- `diagnostics` 暴露权威状态。
35
+ `diagnostics` 暴露权威状态;已声明 `observe` 的流程无需重复提供 `diagnostics`。
28
36
 
29
37
  - React Vitest 在项目声明 Phaser 3 时使用其 browser bundle,并提供仅供模块能力探测的
30
38
  最小 Canvas 2D 边界,使 Phaser 3 代码不会阻断普通 JSDOM 测试;Storage 使用 JSDOM
@@ -84,8 +92,9 @@ Phaser 4 模板对应使用 `miaoda-phaser-game-lint`。命令类型不通过依
84
92
  开发工具包自行维护并精确固定 Vitest 等工具版本,从自身依赖中解析可执行文件,使用方不需要重复声明
85
93
  Biome、Oxlint 或 tsgo。仓库中保留稳定的命令入口,因此 `dist/` 尚未生成时,pnpm
86
94
  也能正确建立命令链接;工作区开发状态下由 Nx 在模板检查前构建开发工具包,发布包则
87
- 直接携带运行时 `dist/`。打包消费者契约会验证固定 Vitest 版本以及 reporter 的通过、聚焦和
88
- 缺失流程行为。开发工具包内部的 `*.test.*` 与构建后的 `*.contract.mjs` 都不会
95
+ 直接携带运行时 `dist/`。打包消费者契约会验证固定 Vitest 版本以及 reporter 的通过、聚焦、
96
+ 缺失流程、retry 失败诊断和 hook 失败诊断行为。开发工具包内部的 `*.test.*` 与构建后的
97
+ `*.contract.mjs` 都不会
89
98
  进入 npm 包;这些契约只由 devkit 自己的测试目标执行。
90
99
 
91
100
  monorepo 中的模板可以放置只包含 `extends: ["miaoda-game-devkit/biome"]` 的薄配置,
@@ -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 主流程测试留下的可序列化运行期证据。 */
@@ -68,6 +58,8 @@ interface ReactPlaythroughMetadata {
68
58
  version: 3;
69
59
  /** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
70
60
  waiverReason?: string;
61
+ /** 仅供失败 reporter 兜底读取;成功运行不会输出。 */
62
+ trace?: string;
71
63
  /** 测试执行期间持续更新的客观证据。 */
72
64
  evidence: ReactPlaythroughEvidence;
73
65
  }
@@ -76,6 +68,11 @@ interface ReactPlaythroughMetadata {
76
68
  type ReactPlaythroughInputKind = "entry" | "primary";
77
69
  /** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
78
70
  type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
71
+ /** Canvas/Controller flows declare one authoritative read; simple DOM flows omit it. */
72
+ interface ReactPlaythroughOptions {
73
+ /** Stable, JSON-serializable production state sampled across the audited flow. */
74
+ observe: () => unknown;
75
+ }
79
76
  /** 主流程回调唯一需要学习的测试工具。 */
80
77
  interface ReactPlaythroughArguments {
81
78
  /** Testing Library 对生产游戏入口的渲染结果。 */
@@ -107,6 +104,8 @@ type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Pro
107
104
  interface ReactPlaythroughTest {
108
105
  /** 渲染生产入口并声明一条最低可玩主流程。 */
109
106
  (element: ReactNode, run: ReactPlaythroughRun): void;
107
+ /** Use one authoritative observer when gameplay results live outside the DOM. */
108
+ (element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
110
109
  /** 仅允许带充分理由地豁免确实没有玩家输入的项目。 */
111
110
  skip(reason: string, element: ReactNode, run: ReactPlaythroughRun): void;
112
111
  }
@@ -144,4 +143,4 @@ declare module "vitest" {
144
143
  }
145
144
  }
146
145
 
147
- export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
146
+ 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 主流程测试留下的可序列化运行期证据。 */
@@ -68,6 +58,8 @@ interface ReactPlaythroughMetadata {
68
58
  version: 3;
69
59
  /** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
70
60
  waiverReason?: string;
61
+ /** 仅供失败 reporter 兜底读取;成功运行不会输出。 */
62
+ trace?: string;
71
63
  /** 测试执行期间持续更新的客观证据。 */
72
64
  evidence: ReactPlaythroughEvidence;
73
65
  }
@@ -76,6 +68,11 @@ interface ReactPlaythroughMetadata {
76
68
  type ReactPlaythroughInputKind = "entry" | "primary";
77
69
  /** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
78
70
  type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
71
+ /** Canvas/Controller flows declare one authoritative read; simple DOM flows omit it. */
72
+ interface ReactPlaythroughOptions {
73
+ /** Stable, JSON-serializable production state sampled across the audited flow. */
74
+ observe: () => unknown;
75
+ }
79
76
  /** 主流程回调唯一需要学习的测试工具。 */
80
77
  interface ReactPlaythroughArguments {
81
78
  /** Testing Library 对生产游戏入口的渲染结果。 */
@@ -107,6 +104,8 @@ type ReactPlaythroughRun = (arguments_: ReactPlaythroughArguments) => void | Pro
107
104
  interface ReactPlaythroughTest {
108
105
  /** 渲染生产入口并声明一条最低可玩主流程。 */
109
106
  (element: ReactNode, run: ReactPlaythroughRun): void;
107
+ /** Use one authoritative observer when gameplay results live outside the DOM. */
108
+ (element: ReactNode, options: ReactPlaythroughOptions, run: ReactPlaythroughRun): void;
110
109
  /** 仅允许带充分理由地豁免确实没有玩家输入的项目。 */
111
110
  skip(reason: string, element: ReactNode, run: ReactPlaythroughRun): void;
112
111
  }
@@ -144,4 +143,4 @@ declare module "vitest" {
144
143
  }
145
144
  }
146
145
 
147
- export { ManualGameClock, type ReactPlaythroughArguments, type ReactPlaythroughAuditInput, type ReactPlaythroughAuditResult, type ReactPlaythroughCheckpointKind, type ReactPlaythroughEvidence, type ReactPlaythroughInputKind, type ReactPlaythroughMetadata, type ReactPlaythroughTest, type StepUntilOptions, auditReactPlaythroughRun, playthroughTest };
146
+ 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,47 +152,123 @@ var INPUT_EVENTS = [
152
152
  "touchend"
153
153
  ];
154
154
  var MIN_CHECKPOINTS = 2;
155
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
156
+ var MAX_TRACE_VALUE_LENGTH = 180;
157
+ var MAX_TRACE_LENGTH = 720;
158
+ function truncateTraceValue(value, limit) {
159
+ const compact = value.replace(/\s+/g, " ").trim();
160
+ if (compact.length <= limit) return compact;
161
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
162
+ }
163
+ function formatReactPlaythroughFailureTrace(trace) {
164
+ const stages = [
165
+ ["entered", trace.entered],
166
+ ["after-primary", trace.afterPrimary],
167
+ ["last", trace.last]
168
+ ].filter((stage) => stage[1] !== void 0).map(
169
+ ([stage, value]) => `${stage}=${truncateTraceValue(value, MAX_TRACE_VALUE_LENGTH)}`
170
+ );
171
+ const details = [
172
+ trace.checkpoints.length > 0 ? `checkpoints=${trace.checkpoints.join(",")}` : "checkpoints=none",
173
+ trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0
174
+ ].filter((detail) => Boolean(detail));
175
+ const formatted = `${stages.join(" -> ")}${stages.length ? "; " : ""}${details.join("; ")}`;
176
+ return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
177
+ }
178
+ var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
179
+ function formatObservation(fingerprint) {
180
+ if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
181
+ return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
182
+ }
183
+ function sampleObservation(observe, stage) {
184
+ let value;
185
+ try {
186
+ value = observe();
187
+ } catch (error) {
188
+ throw new Error(`observe() threw at ${stage}: ${String(error)}`);
189
+ }
190
+ try {
191
+ const fingerprint = JSON.stringify(value);
192
+ if (fingerprint === void 0) throw new Error("unsupported value");
193
+ return { fingerprint, formatted: formatObservation(fingerprint) };
194
+ } catch {
195
+ throw new Error(
196
+ `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
197
+ );
198
+ }
199
+ }
200
+ function formatObservationTimeline(entered, afterPrimary, outcome) {
201
+ return [
202
+ `entered=${entered.formatted}`,
203
+ `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
204
+ `outcome=${outcome.formatted}`
205
+ ].join(", ");
206
+ }
155
207
  function describeMissingEvidence(evidence) {
156
- if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
157
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
208
+ if (!evidence || evidence.entryInputs === 0) return "an entry input";
209
+ if (evidence.primaryInputs === 0) return "a primary gameplay input";
158
210
  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";
211
+ if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
212
+ if (evidence.assertionsAfterOutcome === 0)
213
+ return "an outcome assertion after stepUntil";
161
214
  if (evidence.checkpoints.length < MIN_CHECKPOINTS)
162
- return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
215
+ return `at least ${MIN_CHECKPOINTS} checkpoints`;
163
216
  if (!evidence.checkpoints.some(
164
217
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
165
218
  )) {
166
219
  return "progress/terminal checkpoint";
167
220
  }
168
- return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
221
+ return "a complete playthrough verification marker";
169
222
  }
170
- function createMetadata(waiverReason) {
223
+ function createEvidence() {
171
224
  return {
172
- version: 3,
173
- waiverReason,
174
- evidence: {
175
- domInputEvents: 0,
176
- entryInputs: 0,
177
- primaryInputs: 0,
178
- boundedRuns: 0,
179
- assertionsAfterOutcome: 0,
180
- checkpoints: [],
181
- verified: false
182
- }
225
+ domInputEvents: 0,
226
+ entryInputs: 0,
227
+ primaryInputs: 0,
228
+ boundedRuns: 0,
229
+ assertionsAfterOutcome: 0,
230
+ checkpoints: [],
231
+ verified: false
183
232
  };
184
233
  }
185
- function definePlaythrough(element, run, waiverReason) {
234
+ function createMetadata(waiverReason) {
235
+ return { version: 3, waiverReason, evidence: createEvidence() };
236
+ }
237
+ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
186
238
  const reason = normalizePlaythroughWaiverReason(waiverReason);
187
239
  const metadata = createMetadata(reason);
188
240
  (0, import_vitest.test)("production game completes a bounded playthrough", {
189
241
  skip: Boolean(reason),
190
242
  meta: { reactPlaythrough: metadata }
191
- }, async ({ expect }) => {
243
+ }, async ({ annotate, expect }) => {
244
+ metadata.evidence = createEvidence();
245
+ metadata.trace = void 0;
192
246
  const evidence = metadata.evidence;
193
247
  let assertionsAtOutcome;
194
248
  let enteredRecorded = false;
195
249
  let domTextAtEntered;
250
+ let enteredObservation;
251
+ let afterPrimaryObservation;
252
+ let enteredTrace;
253
+ let afterPrimaryTrace;
254
+ let outcomeTrace;
255
+ let stepTrace;
256
+ const sampleDomTrace = () => JSON.stringify((document.body.textContent ?? "").replace(/\s+/g, " ").trim());
257
+ const sampleLastTrace = () => {
258
+ if (!playthroughOptions?.observe) return sampleDomTrace();
259
+ try {
260
+ return sampleObservation(playthroughOptions.observe, "outcome").formatted;
261
+ } catch (error) {
262
+ return `<observe unavailable: ${String(error)}>`;
263
+ }
264
+ };
265
+ const createFailureTrace = () => formatReactPlaythroughFailureTrace({
266
+ entered: enteredTrace,
267
+ afterPrimary: afterPrimaryTrace,
268
+ last: outcomeTrace ?? sampleLastTrace(),
269
+ checkpoints: [...evidence.checkpoints],
270
+ step: stepTrace
271
+ });
196
272
  const recordInput = () => {
197
273
  evidence.domInputEvents += 1;
198
274
  };
@@ -230,7 +306,18 @@ function definePlaythrough(element, run, waiverReason) {
230
306
  );
231
307
  }
232
308
  if (kind === "entry") evidence.entryInputs += 1;
233
- else evidence.primaryInputs += 1;
309
+ else {
310
+ evidence.primaryInputs += 1;
311
+ if (playthroughOptions?.observe) {
312
+ afterPrimaryObservation = sampleObservation(
313
+ playthroughOptions.observe,
314
+ "after-primary"
315
+ );
316
+ afterPrimaryTrace = afterPrimaryObservation.formatted;
317
+ } else {
318
+ afterPrimaryTrace = sampleDomTrace();
319
+ }
320
+ }
234
321
  },
235
322
  checkpoint(kind) {
236
323
  if (kind === "entered") {
@@ -245,7 +332,16 @@ function definePlaythrough(element, run, waiverReason) {
245
332
  );
246
333
  }
247
334
  enteredRecorded = true;
248
- domTextAtEntered = document.body.textContent ?? "";
335
+ if (playthroughOptions?.observe) {
336
+ enteredObservation = sampleObservation(
337
+ playthroughOptions.observe,
338
+ "entered"
339
+ );
340
+ enteredTrace = enteredObservation.formatted;
341
+ } else {
342
+ domTextAtEntered = document.body.textContent ?? "";
343
+ enteredTrace = sampleDomTrace();
344
+ }
249
345
  evidence.checkpoints.push(kind);
250
346
  return;
251
347
  }
@@ -266,17 +362,43 @@ function definePlaythrough(element, run, waiverReason) {
266
362
  }
267
363
  evidence.checkpoints.push(kind);
268
364
  },
269
- async stepUntil(condition, options = {}) {
270
- const steps = await runBoundedUntil(condition, options);
365
+ async stepUntil(condition, stepOptions = {}) {
366
+ const stepBound = stepOptions.maxSteps ?? 120;
367
+ stepTrace = { bound: stepBound };
368
+ const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
369
+ ...stepOptions,
370
+ diagnostics: playthroughOptions.observe
371
+ };
372
+ const steps = await runBoundedUntil(condition, boundedOptions);
373
+ stepTrace = { bound: stepBound, completed: steps };
271
374
  if (evidence.primaryInputs === 0) {
272
375
  throw new Error(
273
376
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
274
377
  );
275
378
  }
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'
379
+ if (playthroughOptions?.observe) {
380
+ if (!enteredObservation) {
381
+ throw new Error(
382
+ 'observe requires checkpoint("entered") before primary gameplay input.'
383
+ );
384
+ }
385
+ const outcomeObservation = sampleObservation(
386
+ playthroughOptions.observe,
387
+ "outcome"
279
388
  );
389
+ outcomeTrace = outcomeObservation.formatted;
390
+ if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
391
+ throw new Error(
392
+ `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
393
+ );
394
+ }
395
+ } else {
396
+ outcomeTrace = sampleDomTrace();
397
+ if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
398
+ throw new Error(
399
+ '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.'
400
+ );
401
+ }
280
402
  }
281
403
  evidence.boundedRuns += 1;
282
404
  assertionsAtOutcome = expect.getState().assertionCalls;
@@ -311,7 +433,15 @@ function definePlaythrough(element, run, waiverReason) {
311
433
  );
312
434
  }
313
435
  evidence.verified = true;
436
+ } catch (error) {
437
+ metadata.trace = createFailureTrace();
438
+ try {
439
+ await annotate(metadata.trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
440
+ } catch {
441
+ }
442
+ throw error;
314
443
  } finally {
444
+ metadata.trace ??= createFailureTrace();
315
445
  for (const event of INPUT_EVENTS) {
316
446
  document.removeEventListener(event, recordInput, true);
317
447
  }
@@ -319,9 +449,16 @@ function definePlaythrough(element, run, waiverReason) {
319
449
  });
320
450
  }
321
451
  var playthroughTest = Object.assign(
322
- (element, run) => definePlaythrough(element, run),
452
+ (element, optionsOrRun, maybeRun) => {
453
+ if (typeof optionsOrRun === "function") {
454
+ definePlaythrough(element, optionsOrRun);
455
+ return;
456
+ }
457
+ if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
458
+ definePlaythrough(element, maybeRun, optionsOrRun);
459
+ },
323
460
  {
324
- skip: (reason, element, run) => definePlaythrough(element, run, reason)
461
+ skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
325
462
  }
326
463
  );
327
464
  function auditReactPlaythroughRun(tests) {
@@ -338,7 +475,7 @@ function auditReactPlaythroughRun(tests) {
338
475
  const issues = [];
339
476
  if (declared.length === 0) {
340
477
  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'
478
+ '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
479
  );
343
480
  } else {
344
481
  for (const candidate of declared) {
@@ -347,13 +484,17 @@ function auditReactPlaythroughRun(tests) {
347
484
  if (isValid || isWaived) continue;
348
485
  if (candidate.state === "skipped") {
349
486
  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`
487
+ `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
351
488
  );
352
489
  } else if (candidate.state !== "passed") {
353
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
490
+ issues.push(
491
+ `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
492
+ );
354
493
  } else {
355
494
  const missing = describeMissingEvidence(candidate.metadata?.evidence);
356
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
495
+ issues.push(
496
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
497
+ );
357
498
  }
358
499
  }
359
500
  }