miaoda-game-devkit 0.4.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
@@ -21,6 +21,13 @@
21
21
  或调度清理测试可使用 `ManualGameClock`。Canvas/Controller 流程在 `playthroughTest` 上声明一次
22
22
  `observe`,返回 JSON 可序列化的权威 Telemetry 状态;框架自动比较流程状态并复用为超时诊断。
23
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 当作新的断言或第二份业务状态。
30
+
24
31
  Vitest 的 fake timers(包括 `vi.advanceTimersToNextFrame()`)适合在 devkit 自身的
25
32
  专门 scheduler 合约中使用,但不在所有 production playthrough 中全局启用;React
26
33
  Testing Library 的 `asyncWrapper` 和 user-event 内部 timer 会因此互相影响。使用自定义
@@ -85,8 +92,9 @@ Phaser 4 模板对应使用 `miaoda-phaser-game-lint`。命令类型不通过依
85
92
  开发工具包自行维护并精确固定 Vitest 等工具版本,从自身依赖中解析可执行文件,使用方不需要重复声明
86
93
  Biome、Oxlint 或 tsgo。仓库中保留稳定的命令入口,因此 `dist/` 尚未生成时,pnpm
87
94
  也能正确建立命令链接;工作区开发状态下由 Nx 在模板检查前构建开发工具包,发布包则
88
- 直接携带运行时 `dist/`。打包消费者契约会验证固定 Vitest 版本以及 reporter 的通过、聚焦和
89
- 缺失流程行为。开发工具包内部的 `*.test.*` 与构建后的 `*.contract.mjs` 都不会
95
+ 直接携带运行时 `dist/`。打包消费者契约会验证固定 Vitest 版本以及 reporter 的通过、聚焦、
96
+ 缺失流程、retry 失败诊断和 hook 失败诊断行为。开发工具包内部的 `*.test.*` 与构建后的
97
+ `*.contract.mjs` 都不会
90
98
  进入 npm 包;这些契约只由 devkit 自己的测试目标执行。
91
99
 
92
100
  monorepo 中的模板可以放置只包含 `extends: ["miaoda-game-devkit/biome"]` 的薄配置,
@@ -58,6 +58,8 @@ interface ReactPlaythroughMetadata {
58
58
  version: 3;
59
59
  /** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
60
60
  waiverReason?: string;
61
+ /** 仅供失败 reporter 兜底读取;成功运行不会输出。 */
62
+ trace?: string;
61
63
  /** 测试执行期间持续更新的客观证据。 */
62
64
  evidence: ReactPlaythroughEvidence;
63
65
  }
@@ -58,6 +58,8 @@ interface ReactPlaythroughMetadata {
58
58
  version: 3;
59
59
  /** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
60
60
  waiverReason?: string;
61
+ /** 仅供失败 reporter 兜底读取;成功运行不会输出。 */
62
+ trace?: string;
61
63
  /** 测试执行期间持续更新的客观证据。 */
62
64
  evidence: ReactPlaythroughEvidence;
63
65
  }
@@ -152,6 +152,29 @@ 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
+ }
155
178
  var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
156
179
  function formatObservation(fingerprint) {
157
180
  if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
@@ -197,34 +220,55 @@ function describeMissingEvidence(evidence) {
197
220
  }
198
221
  return "a complete playthrough verification marker";
199
222
  }
200
- function createMetadata(waiverReason) {
223
+ function createEvidence() {
201
224
  return {
202
- version: 3,
203
- waiverReason,
204
- evidence: {
205
- domInputEvents: 0,
206
- entryInputs: 0,
207
- primaryInputs: 0,
208
- boundedRuns: 0,
209
- assertionsAfterOutcome: 0,
210
- checkpoints: [],
211
- verified: false
212
- }
225
+ domInputEvents: 0,
226
+ entryInputs: 0,
227
+ primaryInputs: 0,
228
+ boundedRuns: 0,
229
+ assertionsAfterOutcome: 0,
230
+ checkpoints: [],
231
+ verified: false
213
232
  };
214
233
  }
234
+ function createMetadata(waiverReason) {
235
+ return { version: 3, waiverReason, evidence: createEvidence() };
236
+ }
215
237
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
216
238
  const reason = normalizePlaythroughWaiverReason(waiverReason);
217
239
  const metadata = createMetadata(reason);
218
240
  (0, import_vitest.test)("production game completes a bounded playthrough", {
219
241
  skip: Boolean(reason),
220
242
  meta: { reactPlaythrough: metadata }
221
- }, async ({ expect }) => {
243
+ }, async ({ annotate, expect }) => {
244
+ metadata.evidence = createEvidence();
245
+ metadata.trace = void 0;
222
246
  const evidence = metadata.evidence;
223
247
  let assertionsAtOutcome;
224
248
  let enteredRecorded = false;
225
249
  let domTextAtEntered;
226
250
  let enteredObservation;
227
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
+ });
228
272
  const recordInput = () => {
229
273
  evidence.domInputEvents += 1;
230
274
  };
@@ -269,6 +313,9 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
269
313
  playthroughOptions.observe,
270
314
  "after-primary"
271
315
  );
316
+ afterPrimaryTrace = afterPrimaryObservation.formatted;
317
+ } else {
318
+ afterPrimaryTrace = sampleDomTrace();
272
319
  }
273
320
  }
274
321
  },
@@ -290,8 +337,10 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
290
337
  playthroughOptions.observe,
291
338
  "entered"
292
339
  );
340
+ enteredTrace = enteredObservation.formatted;
293
341
  } else {
294
342
  domTextAtEntered = document.body.textContent ?? "";
343
+ enteredTrace = sampleDomTrace();
295
344
  }
296
345
  evidence.checkpoints.push(kind);
297
346
  return;
@@ -314,11 +363,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
314
363
  evidence.checkpoints.push(kind);
315
364
  },
316
365
  async stepUntil(condition, stepOptions = {}) {
366
+ const stepBound = stepOptions.maxSteps ?? 120;
367
+ stepTrace = { bound: stepBound };
317
368
  const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
318
369
  ...stepOptions,
319
370
  diagnostics: playthroughOptions.observe
320
371
  };
321
372
  const steps = await runBoundedUntil(condition, boundedOptions);
373
+ stepTrace = { bound: stepBound, completed: steps };
322
374
  if (evidence.primaryInputs === 0) {
323
375
  throw new Error(
324
376
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
@@ -334,15 +386,19 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
334
386
  playthroughOptions.observe,
335
387
  "outcome"
336
388
  );
389
+ outcomeTrace = outcomeObservation.formatted;
337
390
  if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
338
391
  throw new Error(
339
392
  `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
340
393
  );
341
394
  }
342
- } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
343
- throw new Error(
344
- 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
345
- );
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
+ }
346
402
  }
347
403
  evidence.boundedRuns += 1;
348
404
  assertionsAtOutcome = expect.getState().assertionCalls;
@@ -377,7 +433,15 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
377
433
  );
378
434
  }
379
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;
380
443
  } finally {
444
+ metadata.trace ??= createFailureTrace();
381
445
  for (const event of INPUT_EVENTS) {
382
446
  document.removeEventListener(event, recordInput, true);
383
447
  }
@@ -114,6 +114,29 @@ var INPUT_EVENTS = [
114
114
  "touchend"
115
115
  ];
116
116
  var MIN_CHECKPOINTS = 2;
117
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
118
+ var MAX_TRACE_VALUE_LENGTH = 180;
119
+ var MAX_TRACE_LENGTH = 720;
120
+ function truncateTraceValue(value, limit) {
121
+ const compact = value.replace(/\s+/g, " ").trim();
122
+ if (compact.length <= limit) return compact;
123
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
124
+ }
125
+ function formatReactPlaythroughFailureTrace(trace) {
126
+ const stages = [
127
+ ["entered", trace.entered],
128
+ ["after-primary", trace.afterPrimary],
129
+ ["last", trace.last]
130
+ ].filter((stage) => stage[1] !== void 0).map(
131
+ ([stage, value]) => `${stage}=${truncateTraceValue(value, MAX_TRACE_VALUE_LENGTH)}`
132
+ );
133
+ const details = [
134
+ trace.checkpoints.length > 0 ? `checkpoints=${trace.checkpoints.join(",")}` : "checkpoints=none",
135
+ trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0
136
+ ].filter((detail) => Boolean(detail));
137
+ const formatted = `${stages.join(" -> ")}${stages.length ? "; " : ""}${details.join("; ")}`;
138
+ return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
139
+ }
117
140
  var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
118
141
  function formatObservation(fingerprint) {
119
142
  if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
@@ -159,34 +182,55 @@ function describeMissingEvidence(evidence) {
159
182
  }
160
183
  return "a complete playthrough verification marker";
161
184
  }
162
- function createMetadata(waiverReason) {
185
+ function createEvidence() {
163
186
  return {
164
- version: 3,
165
- waiverReason,
166
- evidence: {
167
- domInputEvents: 0,
168
- entryInputs: 0,
169
- primaryInputs: 0,
170
- boundedRuns: 0,
171
- assertionsAfterOutcome: 0,
172
- checkpoints: [],
173
- verified: false
174
- }
187
+ domInputEvents: 0,
188
+ entryInputs: 0,
189
+ primaryInputs: 0,
190
+ boundedRuns: 0,
191
+ assertionsAfterOutcome: 0,
192
+ checkpoints: [],
193
+ verified: false
175
194
  };
176
195
  }
196
+ function createMetadata(waiverReason) {
197
+ return { version: 3, waiverReason, evidence: createEvidence() };
198
+ }
177
199
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
178
200
  const reason = normalizePlaythroughWaiverReason(waiverReason);
179
201
  const metadata = createMetadata(reason);
180
202
  test("production game completes a bounded playthrough", {
181
203
  skip: Boolean(reason),
182
204
  meta: { reactPlaythrough: metadata }
183
- }, async ({ expect }) => {
205
+ }, async ({ annotate, expect }) => {
206
+ metadata.evidence = createEvidence();
207
+ metadata.trace = void 0;
184
208
  const evidence = metadata.evidence;
185
209
  let assertionsAtOutcome;
186
210
  let enteredRecorded = false;
187
211
  let domTextAtEntered;
188
212
  let enteredObservation;
189
213
  let afterPrimaryObservation;
214
+ let enteredTrace;
215
+ let afterPrimaryTrace;
216
+ let outcomeTrace;
217
+ let stepTrace;
218
+ const sampleDomTrace = () => JSON.stringify((document.body.textContent ?? "").replace(/\s+/g, " ").trim());
219
+ const sampleLastTrace = () => {
220
+ if (!playthroughOptions?.observe) return sampleDomTrace();
221
+ try {
222
+ return sampleObservation(playthroughOptions.observe, "outcome").formatted;
223
+ } catch (error) {
224
+ return `<observe unavailable: ${String(error)}>`;
225
+ }
226
+ };
227
+ const createFailureTrace = () => formatReactPlaythroughFailureTrace({
228
+ entered: enteredTrace,
229
+ afterPrimary: afterPrimaryTrace,
230
+ last: outcomeTrace ?? sampleLastTrace(),
231
+ checkpoints: [...evidence.checkpoints],
232
+ step: stepTrace
233
+ });
190
234
  const recordInput = () => {
191
235
  evidence.domInputEvents += 1;
192
236
  };
@@ -231,6 +275,9 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
231
275
  playthroughOptions.observe,
232
276
  "after-primary"
233
277
  );
278
+ afterPrimaryTrace = afterPrimaryObservation.formatted;
279
+ } else {
280
+ afterPrimaryTrace = sampleDomTrace();
234
281
  }
235
282
  }
236
283
  },
@@ -252,8 +299,10 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
252
299
  playthroughOptions.observe,
253
300
  "entered"
254
301
  );
302
+ enteredTrace = enteredObservation.formatted;
255
303
  } else {
256
304
  domTextAtEntered = document.body.textContent ?? "";
305
+ enteredTrace = sampleDomTrace();
257
306
  }
258
307
  evidence.checkpoints.push(kind);
259
308
  return;
@@ -276,11 +325,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
276
325
  evidence.checkpoints.push(kind);
277
326
  },
278
327
  async stepUntil(condition, stepOptions = {}) {
328
+ const stepBound = stepOptions.maxSteps ?? 120;
329
+ stepTrace = { bound: stepBound };
279
330
  const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
280
331
  ...stepOptions,
281
332
  diagnostics: playthroughOptions.observe
282
333
  };
283
334
  const steps = await runBoundedUntil(condition, boundedOptions);
335
+ stepTrace = { bound: stepBound, completed: steps };
284
336
  if (evidence.primaryInputs === 0) {
285
337
  throw new Error(
286
338
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
@@ -296,15 +348,19 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
296
348
  playthroughOptions.observe,
297
349
  "outcome"
298
350
  );
351
+ outcomeTrace = outcomeObservation.formatted;
299
352
  if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
300
353
  throw new Error(
301
354
  `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
302
355
  );
303
356
  }
304
- } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
305
- throw new Error(
306
- 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
307
- );
357
+ } else {
358
+ outcomeTrace = sampleDomTrace();
359
+ if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
360
+ throw new Error(
361
+ '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.'
362
+ );
363
+ }
308
364
  }
309
365
  evidence.boundedRuns += 1;
310
366
  assertionsAtOutcome = expect.getState().assertionCalls;
@@ -339,7 +395,15 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
339
395
  );
340
396
  }
341
397
  evidence.verified = true;
398
+ } catch (error) {
399
+ metadata.trace = createFailureTrace();
400
+ try {
401
+ await annotate(metadata.trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
402
+ } catch {
403
+ }
404
+ throw error;
342
405
  } finally {
406
+ metadata.trace ??= createFailureTrace();
343
407
  for (const event of INPUT_EVENTS) {
344
408
  document.removeEventListener(event, recordInput, true);
345
409
  }
@@ -103,6 +103,29 @@ var INPUT_EVENTS = [
103
103
  "touchend"
104
104
  ];
105
105
  var MIN_CHECKPOINTS = 2;
106
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
107
+ var MAX_TRACE_VALUE_LENGTH = 180;
108
+ var MAX_TRACE_LENGTH = 720;
109
+ function truncateTraceValue(value, limit) {
110
+ const compact = value.replace(/\s+/g, " ").trim();
111
+ if (compact.length <= limit) return compact;
112
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
113
+ }
114
+ function formatReactPlaythroughFailureTrace(trace) {
115
+ const stages = [
116
+ ["entered", trace.entered],
117
+ ["after-primary", trace.afterPrimary],
118
+ ["last", trace.last]
119
+ ].filter((stage) => stage[1] !== void 0).map(
120
+ ([stage, value]) => `${stage}=${truncateTraceValue(value, MAX_TRACE_VALUE_LENGTH)}`
121
+ );
122
+ const details = [
123
+ trace.checkpoints.length > 0 ? `checkpoints=${trace.checkpoints.join(",")}` : "checkpoints=none",
124
+ trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0
125
+ ].filter((detail) => Boolean(detail));
126
+ const formatted = `${stages.join(" -> ")}${stages.length ? "; " : ""}${details.join("; ")}`;
127
+ return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
128
+ }
106
129
  var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
107
130
  function formatObservation(fingerprint) {
108
131
  if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
@@ -148,34 +171,55 @@ function describeMissingEvidence(evidence) {
148
171
  }
149
172
  return "a complete playthrough verification marker";
150
173
  }
151
- function createMetadata(waiverReason) {
174
+ function createEvidence() {
152
175
  return {
153
- version: 3,
154
- waiverReason,
155
- evidence: {
156
- domInputEvents: 0,
157
- entryInputs: 0,
158
- primaryInputs: 0,
159
- boundedRuns: 0,
160
- assertionsAfterOutcome: 0,
161
- checkpoints: [],
162
- verified: false
163
- }
176
+ domInputEvents: 0,
177
+ entryInputs: 0,
178
+ primaryInputs: 0,
179
+ boundedRuns: 0,
180
+ assertionsAfterOutcome: 0,
181
+ checkpoints: [],
182
+ verified: false
164
183
  };
165
184
  }
185
+ function createMetadata(waiverReason) {
186
+ return { version: 3, waiverReason, evidence: createEvidence() };
187
+ }
166
188
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
167
189
  const reason = normalizePlaythroughWaiverReason(waiverReason);
168
190
  const metadata = createMetadata(reason);
169
191
  (0, import_vitest.test)("production game completes a bounded playthrough", {
170
192
  skip: Boolean(reason),
171
193
  meta: { reactPlaythrough: metadata }
172
- }, async ({ expect }) => {
194
+ }, async ({ annotate, expect }) => {
195
+ metadata.evidence = createEvidence();
196
+ metadata.trace = void 0;
173
197
  const evidence = metadata.evidence;
174
198
  let assertionsAtOutcome;
175
199
  let enteredRecorded = false;
176
200
  let domTextAtEntered;
177
201
  let enteredObservation;
178
202
  let afterPrimaryObservation;
203
+ let enteredTrace;
204
+ let afterPrimaryTrace;
205
+ let outcomeTrace;
206
+ let stepTrace;
207
+ const sampleDomTrace = () => JSON.stringify((document.body.textContent ?? "").replace(/\s+/g, " ").trim());
208
+ const sampleLastTrace = () => {
209
+ if (!playthroughOptions?.observe) return sampleDomTrace();
210
+ try {
211
+ return sampleObservation(playthroughOptions.observe, "outcome").formatted;
212
+ } catch (error) {
213
+ return `<observe unavailable: ${String(error)}>`;
214
+ }
215
+ };
216
+ const createFailureTrace = () => formatReactPlaythroughFailureTrace({
217
+ entered: enteredTrace,
218
+ afterPrimary: afterPrimaryTrace,
219
+ last: outcomeTrace ?? sampleLastTrace(),
220
+ checkpoints: [...evidence.checkpoints],
221
+ step: stepTrace
222
+ });
179
223
  const recordInput = () => {
180
224
  evidence.domInputEvents += 1;
181
225
  };
@@ -220,6 +264,9 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
220
264
  playthroughOptions.observe,
221
265
  "after-primary"
222
266
  );
267
+ afterPrimaryTrace = afterPrimaryObservation.formatted;
268
+ } else {
269
+ afterPrimaryTrace = sampleDomTrace();
223
270
  }
224
271
  }
225
272
  },
@@ -241,8 +288,10 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
241
288
  playthroughOptions.observe,
242
289
  "entered"
243
290
  );
291
+ enteredTrace = enteredObservation.formatted;
244
292
  } else {
245
293
  domTextAtEntered = document.body.textContent ?? "";
294
+ enteredTrace = sampleDomTrace();
246
295
  }
247
296
  evidence.checkpoints.push(kind);
248
297
  return;
@@ -265,11 +314,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
265
314
  evidence.checkpoints.push(kind);
266
315
  },
267
316
  async stepUntil(condition, stepOptions = {}) {
317
+ const stepBound = stepOptions.maxSteps ?? 120;
318
+ stepTrace = { bound: stepBound };
268
319
  const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
269
320
  ...stepOptions,
270
321
  diagnostics: playthroughOptions.observe
271
322
  };
272
323
  const steps = await runBoundedUntil(condition, boundedOptions);
324
+ stepTrace = { bound: stepBound, completed: steps };
273
325
  if (evidence.primaryInputs === 0) {
274
326
  throw new Error(
275
327
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
@@ -285,15 +337,19 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
285
337
  playthroughOptions.observe,
286
338
  "outcome"
287
339
  );
340
+ outcomeTrace = outcomeObservation.formatted;
288
341
  if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
289
342
  throw new Error(
290
343
  `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
291
344
  );
292
345
  }
293
- } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
294
- throw new Error(
295
- 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
296
- );
346
+ } else {
347
+ outcomeTrace = sampleDomTrace();
348
+ if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
349
+ throw new Error(
350
+ '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.'
351
+ );
352
+ }
297
353
  }
298
354
  evidence.boundedRuns += 1;
299
355
  assertionsAtOutcome = expect.getState().assertionCalls;
@@ -328,7 +384,15 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
328
384
  );
329
385
  }
330
386
  evidence.verified = true;
387
+ } catch (error) {
388
+ metadata.trace = createFailureTrace();
389
+ try {
390
+ await annotate(metadata.trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
391
+ } catch {
392
+ }
393
+ throw error;
331
394
  } finally {
395
+ metadata.trace ??= createFailureTrace();
332
396
  for (const event of INPUT_EVENTS) {
333
397
  document.removeEventListener(event, recordInput, true);
334
398
  }
@@ -421,21 +485,41 @@ function errorLocation(value) {
421
485
  const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
422
486
  return match?.[1];
423
487
  }
488
+ function failureTrace(test2) {
489
+ const annotations = test2.annotations();
490
+ let annotationTrace;
491
+ for (let index = annotations.length - 1; index >= 0; index -= 1) {
492
+ if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
493
+ annotationTrace = annotations[index].message;
494
+ break;
495
+ }
496
+ }
497
+ const metadata = test2.meta().reactPlaythrough;
498
+ const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
499
+ return trace ? truncateReporterLine(trace, 720) : void 0;
500
+ }
501
+ function truncateReporterLine(value, limit) {
502
+ const compact = (0, import_node_util.stripVTControlCharacters)(value).replace(/\s+/g, " ").trim();
503
+ if (compact.length <= limit) return compact;
504
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
505
+ }
424
506
  function toModuleResult(module2, projectRoot) {
425
507
  const tests = [...module2.children.allTests()];
508
+ const moduleErrors = module2.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
426
509
  const errors = [
427
- ...module2.errors().map((error) => firstLine(error.message)),
510
+ ...moduleErrors,
428
511
  ...tests.flatMap(
429
512
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
430
513
  )
431
514
  ].filter((message) => Boolean(message));
432
515
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
433
- const raw = test2.result().errors?.[0]?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
516
+ const raw = test2.result().errors?.at(-1)?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
434
517
  return {
435
518
  test: test2.fullName,
436
519
  cause: firstLine(raw) ?? "Unknown failure",
437
520
  location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
438
- hint: failureHint(raw)
521
+ hint: failureHint(raw),
522
+ trace: failureTrace(test2)
439
523
  };
440
524
  });
441
525
  if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
@@ -451,11 +535,12 @@ function toModuleResult(module2, projectRoot) {
451
535
  file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
452
536
  state: module2.state(),
453
537
  errors,
538
+ primaryError: moduleErrors[0] ?? failures[0]?.cause,
454
539
  tests: tests.map(toAuditInput),
455
540
  failures
456
541
  };
457
542
  }
458
- function formatFailureSummary(modules) {
543
+ function formatReactFailureSummary(modules) {
459
544
  const failures = modules.flatMap(
460
545
  (module2) => (module2.failures ?? []).map((failure) => ({ ...failure, file: module2.file }))
461
546
  );
@@ -465,6 +550,7 @@ function formatFailureSummary(modules) {
465
550
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
466
551
  lines.push(`TEST: ${failure.test}`);
467
552
  lines.push(`CAUSE: ${failure.cause}`);
553
+ if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
468
554
  if (failure.location) lines.push(`AT: ${failure.location}`);
469
555
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
470
556
  }
@@ -539,7 +625,7 @@ function assessReactPlaythroughReport(input) {
539
625
  const tests = input.modules.flatMap((module2) => module2.tests);
540
626
  const audit = auditReactPlaythroughRun(tests);
541
627
  if (!audit.passed) {
542
- const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
628
+ const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
543
629
  return {
544
630
  ...base,
545
631
  status: "FAILED",
@@ -609,7 +695,7 @@ var ReactPlaythroughReporter = class {
609
695
  } else {
610
696
  console.log(output);
611
697
  }
612
- const summary = formatFailureSummary(
698
+ const summary = formatReactFailureSummary(
613
699
  testModules.map((module2) => toModuleResult(module2, this.projectRoot))
614
700
  );
615
701
  if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
@@ -69,6 +69,29 @@ var INPUT_EVENTS = [
69
69
  "touchend"
70
70
  ];
71
71
  var MIN_CHECKPOINTS = 2;
72
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
73
+ var MAX_TRACE_VALUE_LENGTH = 180;
74
+ var MAX_TRACE_LENGTH = 720;
75
+ function truncateTraceValue(value, limit) {
76
+ const compact = value.replace(/\s+/g, " ").trim();
77
+ if (compact.length <= limit) return compact;
78
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
79
+ }
80
+ function formatReactPlaythroughFailureTrace(trace) {
81
+ const stages = [
82
+ ["entered", trace.entered],
83
+ ["after-primary", trace.afterPrimary],
84
+ ["last", trace.last]
85
+ ].filter((stage) => stage[1] !== void 0).map(
86
+ ([stage, value]) => `${stage}=${truncateTraceValue(value, MAX_TRACE_VALUE_LENGTH)}`
87
+ );
88
+ const details = [
89
+ trace.checkpoints.length > 0 ? `checkpoints=${trace.checkpoints.join(",")}` : "checkpoints=none",
90
+ trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0
91
+ ].filter((detail) => Boolean(detail));
92
+ const formatted = `${stages.join(" -> ")}${stages.length ? "; " : ""}${details.join("; ")}`;
93
+ return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
94
+ }
72
95
  var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
73
96
  function formatObservation(fingerprint) {
74
97
  if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
@@ -114,34 +137,55 @@ function describeMissingEvidence(evidence) {
114
137
  }
115
138
  return "a complete playthrough verification marker";
116
139
  }
117
- function createMetadata(waiverReason) {
140
+ function createEvidence() {
118
141
  return {
119
- version: 3,
120
- waiverReason,
121
- evidence: {
122
- domInputEvents: 0,
123
- entryInputs: 0,
124
- primaryInputs: 0,
125
- boundedRuns: 0,
126
- assertionsAfterOutcome: 0,
127
- checkpoints: [],
128
- verified: false
129
- }
142
+ domInputEvents: 0,
143
+ entryInputs: 0,
144
+ primaryInputs: 0,
145
+ boundedRuns: 0,
146
+ assertionsAfterOutcome: 0,
147
+ checkpoints: [],
148
+ verified: false
130
149
  };
131
150
  }
151
+ function createMetadata(waiverReason) {
152
+ return { version: 3, waiverReason, evidence: createEvidence() };
153
+ }
132
154
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
133
155
  const reason = normalizePlaythroughWaiverReason(waiverReason);
134
156
  const metadata = createMetadata(reason);
135
157
  test("production game completes a bounded playthrough", {
136
158
  skip: Boolean(reason),
137
159
  meta: { reactPlaythrough: metadata }
138
- }, async ({ expect }) => {
160
+ }, async ({ annotate, expect }) => {
161
+ metadata.evidence = createEvidence();
162
+ metadata.trace = void 0;
139
163
  const evidence = metadata.evidence;
140
164
  let assertionsAtOutcome;
141
165
  let enteredRecorded = false;
142
166
  let domTextAtEntered;
143
167
  let enteredObservation;
144
168
  let afterPrimaryObservation;
169
+ let enteredTrace;
170
+ let afterPrimaryTrace;
171
+ let outcomeTrace;
172
+ let stepTrace;
173
+ const sampleDomTrace = () => JSON.stringify((document.body.textContent ?? "").replace(/\s+/g, " ").trim());
174
+ const sampleLastTrace = () => {
175
+ if (!playthroughOptions?.observe) return sampleDomTrace();
176
+ try {
177
+ return sampleObservation(playthroughOptions.observe, "outcome").formatted;
178
+ } catch (error) {
179
+ return `<observe unavailable: ${String(error)}>`;
180
+ }
181
+ };
182
+ const createFailureTrace = () => formatReactPlaythroughFailureTrace({
183
+ entered: enteredTrace,
184
+ afterPrimary: afterPrimaryTrace,
185
+ last: outcomeTrace ?? sampleLastTrace(),
186
+ checkpoints: [...evidence.checkpoints],
187
+ step: stepTrace
188
+ });
145
189
  const recordInput = () => {
146
190
  evidence.domInputEvents += 1;
147
191
  };
@@ -186,6 +230,9 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
186
230
  playthroughOptions.observe,
187
231
  "after-primary"
188
232
  );
233
+ afterPrimaryTrace = afterPrimaryObservation.formatted;
234
+ } else {
235
+ afterPrimaryTrace = sampleDomTrace();
189
236
  }
190
237
  }
191
238
  },
@@ -207,8 +254,10 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
207
254
  playthroughOptions.observe,
208
255
  "entered"
209
256
  );
257
+ enteredTrace = enteredObservation.formatted;
210
258
  } else {
211
259
  domTextAtEntered = document.body.textContent ?? "";
260
+ enteredTrace = sampleDomTrace();
212
261
  }
213
262
  evidence.checkpoints.push(kind);
214
263
  return;
@@ -231,11 +280,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
231
280
  evidence.checkpoints.push(kind);
232
281
  },
233
282
  async stepUntil(condition, stepOptions = {}) {
283
+ const stepBound = stepOptions.maxSteps ?? 120;
284
+ stepTrace = { bound: stepBound };
234
285
  const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
235
286
  ...stepOptions,
236
287
  diagnostics: playthroughOptions.observe
237
288
  };
238
289
  const steps = await runBoundedUntil(condition, boundedOptions);
290
+ stepTrace = { bound: stepBound, completed: steps };
239
291
  if (evidence.primaryInputs === 0) {
240
292
  throw new Error(
241
293
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
@@ -251,15 +303,19 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
251
303
  playthroughOptions.observe,
252
304
  "outcome"
253
305
  );
306
+ outcomeTrace = outcomeObservation.formatted;
254
307
  if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
255
308
  throw new Error(
256
309
  `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
257
310
  );
258
311
  }
259
- } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
260
- throw new Error(
261
- 'stepUntil found the outcome at step 0, and the DOM has not changed since checkpoint("entered"). The flow therefore provides no evidence that the primary gameplay input produced a result. For Canvas or Controller state outside the DOM, declare one playthrough observe callback.'
262
- );
312
+ } else {
313
+ outcomeTrace = sampleDomTrace();
314
+ if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
315
+ throw new Error(
316
+ '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.'
317
+ );
318
+ }
263
319
  }
264
320
  evidence.boundedRuns += 1;
265
321
  assertionsAtOutcome = expect.getState().assertionCalls;
@@ -294,7 +350,15 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
294
350
  );
295
351
  }
296
352
  evidence.verified = true;
353
+ } catch (error) {
354
+ metadata.trace = createFailureTrace();
355
+ try {
356
+ await annotate(metadata.trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
357
+ } catch {
358
+ }
359
+ throw error;
297
360
  } finally {
361
+ metadata.trace ??= createFailureTrace();
298
362
  for (const event of INPUT_EVENTS) {
299
363
  document.removeEventListener(event, recordInput, true);
300
364
  }
@@ -387,21 +451,41 @@ function errorLocation(value) {
387
451
  const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
388
452
  return match?.[1];
389
453
  }
454
+ function failureTrace(test2) {
455
+ const annotations = test2.annotations();
456
+ let annotationTrace;
457
+ for (let index = annotations.length - 1; index >= 0; index -= 1) {
458
+ if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
459
+ annotationTrace = annotations[index].message;
460
+ break;
461
+ }
462
+ }
463
+ const metadata = test2.meta().reactPlaythrough;
464
+ const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
465
+ return trace ? truncateReporterLine(trace, 720) : void 0;
466
+ }
467
+ function truncateReporterLine(value, limit) {
468
+ const compact = stripVTControlCharacters(value).replace(/\s+/g, " ").trim();
469
+ if (compact.length <= limit) return compact;
470
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
471
+ }
390
472
  function toModuleResult(module, projectRoot) {
391
473
  const tests = [...module.children.allTests()];
474
+ const moduleErrors = module.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
392
475
  const errors = [
393
- ...module.errors().map((error) => firstLine(error.message)),
476
+ ...moduleErrors,
394
477
  ...tests.flatMap(
395
478
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
396
479
  )
397
480
  ].filter((message) => Boolean(message));
398
481
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
399
- const raw = test2.result().errors?.[0]?.message ?? module.errors()[0]?.message ?? "Unknown failure";
482
+ const raw = test2.result().errors?.at(-1)?.message ?? module.errors()[0]?.message ?? "Unknown failure";
400
483
  return {
401
484
  test: test2.fullName,
402
485
  cause: firstLine(raw) ?? "Unknown failure",
403
486
  location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
404
- hint: failureHint(raw)
487
+ hint: failureHint(raw),
488
+ trace: failureTrace(test2)
405
489
  };
406
490
  });
407
491
  if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
@@ -417,11 +501,12 @@ function toModuleResult(module, projectRoot) {
417
501
  file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
418
502
  state: module.state(),
419
503
  errors,
504
+ primaryError: moduleErrors[0] ?? failures[0]?.cause,
420
505
  tests: tests.map(toAuditInput),
421
506
  failures
422
507
  };
423
508
  }
424
- function formatFailureSummary(modules) {
509
+ function formatReactFailureSummary(modules) {
425
510
  const failures = modules.flatMap(
426
511
  (module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
427
512
  );
@@ -431,6 +516,7 @@ function formatFailureSummary(modules) {
431
516
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
432
517
  lines.push(`TEST: ${failure.test}`);
433
518
  lines.push(`CAUSE: ${failure.cause}`);
519
+ if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
434
520
  if (failure.location) lines.push(`AT: ${failure.location}`);
435
521
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
436
522
  }
@@ -505,7 +591,7 @@ function assessReactPlaythroughReport(input) {
505
591
  const tests = input.modules.flatMap((module) => module.tests);
506
592
  const audit = auditReactPlaythroughRun(tests);
507
593
  if (!audit.passed) {
508
- const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
594
+ const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
509
595
  return {
510
596
  ...base,
511
597
  status: "FAILED",
@@ -575,7 +661,7 @@ var ReactPlaythroughReporter = class {
575
661
  } else {
576
662
  console.log(output);
577
663
  }
578
- const summary = formatFailureSummary(
664
+ const summary = formatReactFailureSummary(
579
665
  testModules.map((module) => toModuleResult(module, this.projectRoot))
580
666
  );
581
667
  if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",