miaoda-game-devkit 0.5.0 → 0.6.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.
@@ -15,6 +15,13 @@ import { test } from "vitest";
15
15
 
16
16
  // src/react/react-playthrough-core.ts
17
17
  import { act } from "@testing-library/react";
18
+ function throwIfAborted(signal) {
19
+ if (!signal?.aborted) return;
20
+ if (signal.reason instanceof Error) throw signal.reason;
21
+ throw new Error("Playthrough advancement was cancelled.", {
22
+ cause: signal.reason
23
+ });
24
+ }
18
25
  function formatDiagnostics(read) {
19
26
  if (!read) return void 0;
20
27
  try {
@@ -43,11 +50,13 @@ async function runBoundedUntil(condition, options = {}) {
43
50
  );
44
51
  }
45
52
  for (let step = 0; step <= maxSteps; step += 1) {
53
+ throwIfAborted(options.signal);
46
54
  if (condition()) return step;
47
55
  if (step < maxSteps) {
48
56
  await act(async () => {
49
57
  await options.step?.(step + 1);
50
58
  });
59
+ throwIfAborted(options.signal);
51
60
  }
52
61
  }
53
62
  const diagnostics = formatDiagnostics(options.diagnostics);
@@ -68,36 +77,35 @@ var INPUT_EVENTS = [
68
77
  "touchstart",
69
78
  "touchend"
70
79
  ];
71
- var MIN_CHECKPOINTS = 2;
72
- var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
73
- var MAX_TRACE_VALUE_LENGTH = 180;
80
+ var MIN_STAGES = 5;
81
+ var MIN_MILESTONES = 3;
82
+ var MAX_TRACE_VALUE_LENGTH = 140;
74
83
  var MAX_TRACE_LENGTH = 720;
84
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
75
85
  function truncateTraceValue(value, limit) {
76
86
  const compact = value.replace(/\s+/g, " ").trim();
77
87
  if (compact.length <= limit) return compact;
78
88
  return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
79
89
  }
80
90
  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)}`
91
+ const stages = trace.stages.map(
92
+ (stage) => `${stage.kind}:${stage.name}=${truncateTraceValue(stage.state, MAX_TRACE_VALUE_LENGTH)}`
87
93
  );
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("; ")}`;
94
+ if (trace.current) {
95
+ stages.push(
96
+ `current:${trace.current.name}=${truncateTraceValue(trace.current.before, 70)}\u2192${truncateTraceValue(trace.current.last, 70)}`
97
+ );
98
+ }
99
+ const step = trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0;
100
+ const formatted = `${stages.length > 0 ? stages.join(" -> ") : "stages=none"}${step ? `; ${step}` : ""}`;
93
101
  return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
94
102
  }
95
- var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
96
- function formatObservation(fingerprint) {
97
- if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
98
- return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
103
+ var MAX_FORMATTED_STATE_LENGTH = 500;
104
+ function formatState(fingerprint) {
105
+ if (fingerprint.length <= MAX_FORMATTED_STATE_LENGTH) return fingerprint;
106
+ return `${fingerprint.slice(0, MAX_FORMATTED_STATE_LENGTH)}\u2026 (${fingerprint.length} chars)`;
99
107
  }
100
- function sampleObservation(observe, stage) {
108
+ function sampleObservedState(observe, stage) {
101
109
  let value;
102
110
  try {
103
111
  value = observe();
@@ -107,263 +115,272 @@ function sampleObservation(observe, stage) {
107
115
  try {
108
116
  const fingerprint = JSON.stringify(value);
109
117
  if (fingerprint === void 0) throw new Error("unsupported value");
110
- return { fingerprint, formatted: formatObservation(fingerprint) };
118
+ return { fingerprint, formatted: formatState(fingerprint) };
111
119
  } catch {
112
120
  throw new Error(
113
121
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
114
122
  );
115
123
  }
116
124
  }
117
- function formatObservationTimeline(entered, afterPrimary, outcome) {
118
- return [
119
- `entered=${entered.formatted}`,
120
- `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
121
- `outcome=${outcome.formatted}`
122
- ].join(", ");
123
- }
124
- function describeMissingEvidence(evidence) {
125
- if (!evidence || evidence.entryInputs === 0) return "an entry input";
126
- if (evidence.primaryInputs === 0) return "a primary gameplay input";
127
- if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
128
- if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
129
- if (evidence.assertionsAfterOutcome === 0)
130
- return "an outcome assertion after stepUntil";
131
- if (evidence.checkpoints.length < MIN_CHECKPOINTS)
132
- return `at least ${MIN_CHECKPOINTS} checkpoints`;
133
- if (!evidence.checkpoints.some(
134
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
135
- )) {
136
- return "progress/terminal checkpoint";
137
- }
138
- return "a complete playthrough verification marker";
125
+ function sampleDomState(view) {
126
+ const fingerprint = view.container.innerHTML.replace(/\s+/g, " ").trim();
127
+ return { fingerprint, formatted: formatState(JSON.stringify(fingerprint)) };
139
128
  }
140
129
  function createEvidence() {
141
- return {
142
- domInputEvents: 0,
143
- entryInputs: 0,
144
- primaryInputs: 0,
145
- boundedRuns: 0,
146
- assertionsAfterOutcome: 0,
147
- checkpoints: [],
148
- verified: false
149
- };
130
+ return { domInputEvents: 0, stages: [], verified: false };
150
131
  }
151
132
  function createMetadata(waiverReason) {
152
- return { version: 3, waiverReason, evidence: createEvidence() };
133
+ return { version: 4, waiverReason, evidence: createEvidence() };
134
+ }
135
+ function stageLabel(kind, name) {
136
+ return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
137
+ }
138
+ function describeMissingEvidence(evidence) {
139
+ if (!evidence || evidence.stages.length === 0) return "an enter stage";
140
+ if (evidence.stages[0]?.kind !== "entered") return "the entered stage";
141
+ const milestones = evidence.stages.filter(
142
+ (stage) => stage.kind === "milestone"
143
+ );
144
+ if (milestones.length < MIN_MILESTONES) {
145
+ return `at least ${MIN_MILESTONES} gameplay milestones`;
146
+ }
147
+ const finalStage = evidence.stages.at(-1);
148
+ if (finalStage?.kind !== "progress" && finalStage?.kind !== "terminal") {
149
+ return "a progress or terminal finish stage";
150
+ }
151
+ if (evidence.stages.length < MIN_STAGES) {
152
+ return `at least ${MIN_STAGES} evidenced stages`;
153
+ }
154
+ return "a complete playthrough verification marker";
153
155
  }
154
156
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
155
157
  const reason = normalizePlaythroughWaiverReason(waiverReason);
156
158
  const metadata = createMetadata(reason);
157
- test("production game completes a bounded playthrough", {
158
- skip: Boolean(reason),
159
- meta: { reactPlaythrough: metadata }
160
- }, async ({ annotate, expect }) => {
161
- metadata.evidence = createEvidence();
162
- metadata.trace = void 0;
163
- const evidence = metadata.evidence;
164
- let assertionsAtOutcome;
165
- let enteredRecorded = false;
166
- let domTextAtEntered;
167
- let enteredObservation;
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
- });
189
- const recordInput = () => {
190
- evidence.domInputEvents += 1;
191
- };
192
- for (const event of INPUT_EVENTS) {
193
- document.addEventListener(event, recordInput, true);
194
- }
195
- try {
196
- const view = render(element);
197
- if (view.container.childNodes.length === 0) {
198
- throw new Error(
199
- "playthroughTest must render the production game entry."
200
- );
159
+ test(
160
+ "production game completes a bounded playthrough",
161
+ {
162
+ concurrent: false,
163
+ skip: Boolean(reason),
164
+ meta: { reactPlaythrough: metadata }
165
+ },
166
+ async ({ annotate, expect, onTestFailed, signal }) => {
167
+ metadata.evidence = createEvidence();
168
+ metadata.trace = void 0;
169
+ const evidence = metadata.evidence;
170
+ let entered = false;
171
+ let finished = false;
172
+ let activeStage;
173
+ let lastSample;
174
+ let stepTrace;
175
+ let failureTraceFactory;
176
+ let acceptingStageInput = false;
177
+ let inputCaptureAttached = false;
178
+ const recordInput = () => {
179
+ if (acceptingStageInput) evidence.domInputEvents += 1;
180
+ };
181
+ const stopInputCapture = () => {
182
+ acceptingStageInput = false;
183
+ if (!inputCaptureAttached) return;
184
+ inputCaptureAttached = false;
185
+ for (const event of INPUT_EVENTS) {
186
+ document.removeEventListener(event, recordInput, true);
187
+ }
188
+ };
189
+ const captureFailureTrace = () => {
190
+ try {
191
+ return failureTraceFactory?.() ?? "stages=none";
192
+ } catch {
193
+ return "stages=none";
194
+ }
195
+ };
196
+ onTestFailed(() => {
197
+ metadata.trace ??= captureFailureTrace();
198
+ });
199
+ for (const event of INPUT_EVENTS) {
200
+ document.addEventListener(event, recordInput, true);
201
201
  }
202
- const user = userEvent.setup();
203
- await run({
204
- view,
205
- user,
206
- expect,
207
- async performInput(kind, input) {
208
- if (kind === "entry" && enteredRecorded) {
202
+ inputCaptureAttached = true;
203
+ signal.addEventListener("abort", stopInputCapture, { once: true });
204
+ try {
205
+ const view = render(element);
206
+ if (view.container.childNodes.length === 0) {
207
+ throw new Error(
208
+ "playthroughTest must render the production game entry."
209
+ );
210
+ }
211
+ const user = userEvent.setup();
212
+ const sampleState = (label) => playthroughOptions?.observe ? sampleObservedState(playthroughOptions.observe, label) : sampleDomState(view);
213
+ lastSample = sampleState("initial render");
214
+ failureTraceFactory = () => formatReactPlaythroughFailureTrace({
215
+ stages: evidence.stages.map((stage) => ({
216
+ name: stage.name,
217
+ kind: stage.kind,
218
+ state: stage.after
219
+ })),
220
+ current: activeStage ? {
221
+ name: stageLabel(activeStage.kind, activeStage.name),
222
+ before: activeStage.before.formatted,
223
+ last: (() => {
224
+ try {
225
+ return sampleState("failure").formatted;
226
+ } catch (error) {
227
+ return `<state unavailable: ${String(error)}>`;
228
+ }
229
+ })()
230
+ } : void 0,
231
+ step: stepTrace
232
+ });
233
+ const executeStage = async (name, kind, stage) => {
234
+ const normalizedName = name.trim();
235
+ if (normalizedName.length === 0) {
236
+ throw new Error("playthrough stage names must be non-empty strings.");
237
+ }
238
+ if (evidence.stages.some(
239
+ (completed) => completed.name === normalizedName
240
+ )) {
209
241
  throw new Error(
210
- 'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
242
+ `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
211
243
  );
212
244
  }
213
- if (kind === "primary" && !enteredRecorded) {
245
+ if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
214
246
  throw new Error(
215
- 'Before performInput("primary"), run an entry input and checkpoint("entered").'
247
+ `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
216
248
  );
217
249
  }
218
- const inputsBefore = evidence.domInputEvents;
219
- await input();
220
- if (evidence.domInputEvents === inputsBefore) {
250
+ const before = lastSample ?? sampleState(`before ${normalizedName}`);
251
+ activeStage = { name: normalizedName, kind, before };
252
+ stepTrace = { bound: stage.maxSteps ?? 120 };
253
+ if (stage.until()) {
221
254
  throw new Error(
222
- `performInput("${kind}") did not dispatch a supported production DOM input. Use the provided user or dispatch a real keyboard, pointer, or touch event to the production target.`
255
+ `${stageLabel(kind, normalizedName)} until condition must be false before its driver runs. Wait for a result caused by this stage, not state left by an earlier stage.`
223
256
  );
224
257
  }
225
- if (kind === "entry") evidence.entryInputs += 1;
226
- else {
227
- evidence.primaryInputs += 1;
228
- if (playthroughOptions?.observe) {
229
- afterPrimaryObservation = sampleObservation(
230
- playthroughOptions.observe,
231
- "after-primary"
232
- );
233
- afterPrimaryTrace = afterPrimaryObservation.formatted;
234
- } else {
235
- afterPrimaryTrace = sampleDomTrace();
236
- }
237
- }
238
- },
239
- checkpoint(kind) {
240
- if (kind === "entered") {
241
- if (enteredRecorded) {
242
- throw new Error(
243
- 'checkpoint("entered") may only be recorded once, before the primary input.'
244
- );
258
+ const inputsBefore = evidence.domInputEvents;
259
+ if (stage.act) {
260
+ acceptingStageInput = true;
261
+ try {
262
+ await stage.act();
263
+ } finally {
264
+ acceptingStageInput = false;
245
265
  }
246
- if (evidence.entryInputs === 0) {
266
+ if (evidence.domInputEvents === inputsBefore) {
247
267
  throw new Error(
248
- 'checkpoint("entered") must follow performInput("entry", ...).'
249
- );
250
- }
251
- enteredRecorded = true;
252
- if (playthroughOptions?.observe) {
253
- enteredObservation = sampleObservation(
254
- playthroughOptions.observe,
255
- "entered"
268
+ `${stageLabel(kind, normalizedName)} act did not dispatch a supported production DOM input. Use the provided user to click, type, press, point, or touch the production target; do not call Controller commands directly.`
256
269
  );
257
- enteredTrace = enteredObservation.formatted;
258
- } else {
259
- domTextAtEntered = document.body.textContent ?? "";
260
- enteredTrace = sampleDomTrace();
261
270
  }
262
- evidence.checkpoints.push(kind);
263
- return;
264
- }
265
- if (evidence.primaryInputs === 0) {
266
- throw new Error(
267
- `checkpoint("${kind}") must follow performInput("primary", ...).`
268
- );
269
271
  }
270
- if (evidence.boundedRuns === 0) {
272
+ let advancedSteps = 0;
273
+ const stepBound = stage.maxSteps ?? 120;
274
+ stepTrace = { bound: stepBound };
275
+ const steps = await runBoundedUntil(stage.until, {
276
+ maxSteps: stage.maxSteps,
277
+ signal,
278
+ diagnostics: stage.diagnostics ?? playthroughOptions?.observe,
279
+ step: stage.step ? async (step) => {
280
+ advancedSteps += 1;
281
+ await stage.step?.(step);
282
+ } : void 0
283
+ });
284
+ stepTrace = { bound: stepBound, completed: steps };
285
+ if (!stage.act && advancedSteps === 0) {
271
286
  throw new Error(
272
- `checkpoint("${kind}") must be recorded after stepUntil returns.`
287
+ `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
273
288
  );
274
289
  }
275
- if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
290
+ const assertionsBefore = expect.getState().assertionCalls;
291
+ await stage.assert({ expect, user, view });
292
+ const assertions = expect.getState().assertionCalls - assertionsBefore;
293
+ if (assertions === 0) {
276
294
  throw new Error(
277
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
295
+ `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
278
296
  );
279
297
  }
280
- evidence.checkpoints.push(kind);
281
- },
282
- async stepUntil(condition, stepOptions = {}) {
283
- const stepBound = stepOptions.maxSteps ?? 120;
284
- stepTrace = { bound: stepBound };
285
- const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
286
- ...stepOptions,
287
- diagnostics: playthroughOptions.observe
288
- };
289
- const steps = await runBoundedUntil(condition, boundedOptions);
290
- stepTrace = { bound: stepBound, completed: steps };
291
- if (evidence.primaryInputs === 0) {
298
+ const after = sampleState(`after ${normalizedName}`);
299
+ if (after.fingerprint === before.fingerprint) {
300
+ const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
292
301
  throw new Error(
293
- 'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
302
+ `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
294
303
  );
295
304
  }
296
- if (playthroughOptions?.observe) {
297
- if (!enteredObservation) {
298
- throw new Error(
299
- 'observe requires checkpoint("entered") before primary gameplay input.'
300
- );
305
+ evidence.stages.push({
306
+ name: normalizedName,
307
+ kind,
308
+ domInputEvents: evidence.domInputEvents - inputsBefore,
309
+ advancedSteps,
310
+ assertions,
311
+ stateChanged: true,
312
+ before: before.formatted,
313
+ after: after.formatted
314
+ });
315
+ lastSample = after;
316
+ activeStage = void 0;
317
+ };
318
+ await run({
319
+ view,
320
+ user,
321
+ async enter(stage) {
322
+ if (entered) {
323
+ throw new Error("enter may only be called once.");
301
324
  }
302
- const outcomeObservation = sampleObservation(
303
- playthroughOptions.observe,
304
- "outcome"
305
- );
306
- outcomeTrace = outcomeObservation.formatted;
307
- if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
308
- throw new Error(
309
- `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
310
- );
325
+ if (evidence.stages.length > 0) {
326
+ throw new Error("enter must be the first playthrough stage.");
311
327
  }
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
- );
328
+ await executeStage("entered", "entered", stage);
329
+ entered = true;
330
+ },
331
+ async milestone(name, stage) {
332
+ if (!entered) {
333
+ throw new Error("milestone must follow enter.");
318
334
  }
335
+ if (finished) {
336
+ throw new Error("milestone cannot run after finish.");
337
+ }
338
+ await executeStage(name, "milestone", stage);
339
+ },
340
+ async finish(name, stage) {
341
+ if (!entered) throw new Error("finish must follow enter.");
342
+ if (finished) {
343
+ throw new Error("finish may only be called once.");
344
+ }
345
+ await executeStage(name, stage.kind, stage);
346
+ finished = true;
319
347
  }
320
- evidence.boundedRuns += 1;
321
- assertionsAtOutcome = expect.getState().assertionCalls;
322
- return steps;
323
- }
324
- });
325
- const assertionCalls = expect.getState().assertionCalls;
326
- evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
327
- if (evidence.entryInputs === 0) {
328
- throw new Error(
329
- 'playthroughTest must perform an entry input with performInput("entry", ...).'
330
- );
331
- }
332
- if (evidence.primaryInputs === 0) {
333
- throw new Error(
334
- 'playthroughTest must perform a core game action with performInput("primary", ...).'
335
- );
336
- }
337
- if (evidence.boundedRuns === 0) {
338
- throw new Error("playthroughTest must complete one bounded stepUntil.");
339
- }
340
- if (evidence.assertionsAfterOutcome === 0) {
341
- throw new Error(
342
- "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
343
- );
344
- }
345
- if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
346
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
347
- )) {
348
- throw new Error(
349
- `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
348
+ });
349
+ const milestones = evidence.stages.filter(
350
+ (stage) => stage.kind === "milestone"
350
351
  );
351
- }
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;
360
- } finally {
361
- metadata.trace ??= createFailureTrace();
362
- for (const event of INPUT_EVENTS) {
363
- document.removeEventListener(event, recordInput, true);
352
+ if (!entered) throw new Error("playthroughTest must call enter once.");
353
+ if (milestones.length < MIN_MILESTONES) {
354
+ throw new Error(
355
+ `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
356
+ );
357
+ }
358
+ if (!finished) {
359
+ throw new Error(
360
+ 'playthroughTest must call finish with kind "progress" or "terminal".'
361
+ );
362
+ }
363
+ if (evidence.stages.length < MIN_STAGES) {
364
+ throw new Error(
365
+ `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
366
+ );
367
+ }
368
+ evidence.verified = true;
369
+ } catch (error) {
370
+ const trace = captureFailureTrace();
371
+ metadata.trace = trace;
372
+ try {
373
+ await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
374
+ } catch {
375
+ }
376
+ throw error;
377
+ } finally {
378
+ metadata.trace ??= failureTraceFactory?.();
379
+ signal.removeEventListener("abort", stopInputCapture);
380
+ stopInputCapture();
364
381
  }
365
382
  }
366
- });
383
+ );
367
384
  }
368
385
  var playthroughTest = Object.assign(
369
386
  (element, optionsOrRun, maybeRun) => {
@@ -371,19 +388,29 @@ var playthroughTest = Object.assign(
371
388
  definePlaythrough(element, optionsOrRun);
372
389
  return;
373
390
  }
374
- if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
391
+ if (!maybeRun) {
392
+ throw new TypeError("playthroughTest requires a run callback.");
393
+ }
375
394
  definePlaythrough(element, maybeRun, optionsOrRun);
376
395
  },
377
396
  {
378
- skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
397
+ skip: (reason, element) => definePlaythrough(element, async () => {
398
+ }, void 0, reason)
379
399
  }
380
400
  );
381
401
  function auditReactPlaythroughRun(tests) {
382
402
  const declared = tests.filter((candidate) => candidate.metadata);
383
403
  const valid = declared.filter(({ state, metadata }) => {
384
404
  const evidence = metadata?.evidence;
385
- 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(
386
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
405
+ if (!evidence || state !== "passed" || evidence.verified !== true) {
406
+ return false;
407
+ }
408
+ const milestones = evidence.stages.filter(
409
+ (stage) => stage.kind === "milestone"
410
+ );
411
+ const finalStage = evidence.stages.at(-1);
412
+ return evidence.domInputEvents > 0 && evidence.stages.length >= MIN_STAGES && evidence.stages[0]?.kind === "entered" && milestones.length >= MIN_MILESTONES && (finalStage?.kind === "progress" || finalStage?.kind === "terminal") && evidence.stages.every(
413
+ (stage) => stage.assertions > 0 && (stage.domInputEvents > 0 || stage.advancedSteps > 0) && stage.stateChanged === true
387
414
  );
388
415
  });
389
416
  const waivers = declared.filter(
@@ -392,13 +419,11 @@ function auditReactPlaythroughRun(tests) {
392
419
  const issues = [];
393
420
  if (declared.length === 0) {
394
421
  issues.push(
395
- '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").'
422
+ "No production gameplay verification was declared. Use playthroughTest to render <App />, then compose one real-input enter stage, at least three named milestones, and finish. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result."
396
423
  );
397
424
  } else {
398
425
  for (const candidate of declared) {
399
- const isValid = valid.includes(candidate);
400
- const isWaived = waivers.includes(candidate);
401
- if (isValid || isWaived) continue;
426
+ if (valid.includes(candidate) || waivers.includes(candidate)) continue;
402
427
  if (candidate.state === "skipped") {
403
428
  issues.push(
404
429
  `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
@@ -408,9 +433,8 @@ function auditReactPlaythroughRun(tests) {
408
433
  `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
409
434
  );
410
435
  } else {
411
- const missing = describeMissingEvidence(candidate.metadata?.evidence);
412
436
  issues.push(
413
- `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
437
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${describeMissingEvidence(candidate.metadata?.evidence)}.`
414
438
  );
415
439
  }
416
440
  }
@@ -426,7 +450,12 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
426
450
  function isMetadata(value) {
427
451
  if (!value || typeof value !== "object") return false;
428
452
  const metadata = value;
429
- return metadata.version === 3 && Boolean(metadata.evidence);
453
+ if (metadata.version !== 4) return false;
454
+ const evidence = metadata.evidence;
455
+ if (!evidence || typeof evidence !== "object") return false;
456
+ return typeof evidence.domInputEvents === "number" && Array.isArray(evidence.stages) && typeof evidence.verified === "boolean" && evidence.stages.every(
457
+ (stage) => Boolean(stage) && typeof stage === "object" && typeof stage.name === "string" && typeof stage.kind === "string" && typeof stage.domInputEvents === "number" && typeof stage.advancedSteps === "number" && typeof stage.assertions === "number" && typeof stage.stateChanged === "boolean" && typeof stage.before === "string" && typeof stage.after === "string"
458
+ );
430
459
  }
431
460
  function toAuditInput(test2) {
432
461
  const metadata = test2.meta().reactPlaythrough;
@@ -448,7 +477,9 @@ function failureHint(value) {
448
477
  return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
449
478
  }
450
479
  function errorLocation(value) {
451
- const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
480
+ const match = value.match(
481
+ /(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/
482
+ );
452
483
  return match?.[1];
453
484
  }
454
485
  function failureTrace(test2) {
@@ -508,7 +539,10 @@ function toModuleResult(module, projectRoot) {
508
539
  }
509
540
  function formatReactFailureSummary(modules) {
510
541
  const failures = modules.flatMap(
511
- (module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
542
+ (module) => (module.failures ?? []).map((failure) => ({
543
+ ...failure,
544
+ file: module.file
545
+ }))
512
546
  );
513
547
  if (failures.length === 0) return ["TEST_RESULT: PASS"];
514
548
  const lines = [`FAILED_TESTS: ${failures.length}`];
@@ -527,24 +561,22 @@ function repairGuidance(cause) {
527
561
  if (/snapshot\(\) returned the same reference/i.test(cause)) {
528
562
  return "The game mutated state without publishing a new snapshot reference, so React skipped the render after an Object.is comparison. Publish a new top-level object before notifying subscribers, for example cachedSnapshot = { ...state }, and never expose a mutable internal object as the snapshot.";
529
563
  }
530
- if (/(?:already true|found the outcome) at step 0.*DOM has not changed/i.test(
531
- cause
532
- )) {
533
- return 'Make the stepUntil condition false at checkpoint("entered"). Verify that the primary input reaches the production control, then wait for a post-input outcome such as a changed score, a removed entry overlay, a completed turn, or a result screen. For Canvas or Controller state outside the DOM, declare observe once on playthroughTest and return the read-only production Telemetry snapshot.';
564
+ if (/until condition must be false before its driver runs/i.test(cause)) {
565
+ return "Make this stage's until condition describe a new result that does not exist before act or step runs. Do not reuse state completed by an earlier milestone.";
534
566
  }
535
- if (/authoritative observation did not change/i.test(cause)) {
536
- return "The flow reached its condition while observe still returned the same authoritative state. Make observe read the same production Controller that React renders, verify the primary input changes that Controller, and wait for a post-input result. Inspect the Timeline values to locate the disconnected stage.";
567
+ if (/did not change the (?:authoritative observe\(\) state|production DOM)/i.test(cause)) {
568
+ return "The stage ran and asserted, but its observable state matched the previous milestone. Wait for a genuinely new gameplay result. Canvas or Controller games should make observe read the same production Controller that React renders.";
537
569
  }
538
570
  if (/No step callback was provided/i.test(cause)) {
539
- return "This flow is driven by time or frames, but stepUntil did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
571
+ return "This stage is driven by time or frames, but it did not advance the game clock. Inject the devkit GameClock into the production game and pass step: () => clock.stepFrame(). Do not replace deterministic advancement with a real setTimeout.";
540
572
  }
541
573
  if (/outcome was not reached within \d+ steps/i.test(cause)) {
542
- return "The real input was dispatched, but gameplay did not reach the outcome within the bound. Confirm that the production control received the input, then inspect Last diagnostics to determine whether the game loop, rule state, or UI synchronization failed to advance.";
574
+ return "The stage driver ran, but gameplay did not reach the outcome within the bound. Confirm that its production input or deterministic step changed the intended rule state, then inspect Last diagnostics to locate the stalled stage.";
543
575
  }
544
- if (/performInput|checkpoint/.test(cause)) {
545
- return 'Complete the evidence sequence in order: performInput("entry"), checkpoint("entered"), performInput("primary"), bounded stepUntil, an authoritative result assertion using the provided expect, then checkpoint("progress") or checkpoint("terminal").';
576
+ if (/enter|milestone|finish|stage| act | assert /.test(cause)) {
577
+ return "Compose one enter stage, at least three named gameplay milestones, and one finish stage. Enter must drive real DOM input; each later stage must drive input or deterministic steps. Every stage waits for a bounded new result and asserts it with the provided expect.";
546
578
  }
547
- return "Start from the production entry, dispatch real DOM input, and use stepUntil to reach a bounded player-visible or authoritative game outcome before asserting it. Do not jump to an internal level or mutate gameplay state.";
579
+ return "Start from the production entry and describe the real game as enter, named milestones, and finish. Enter must act through production input; later stages may act or step. Every stage waits for and asserts a new player-visible or authoritative result. Do not jump to an internal level or mutate gameplay state.";
548
580
  }
549
581
  function assessReactPlaythroughReport(input) {
550
582
  const base = { file: input.expectedFile };
@@ -561,7 +593,7 @@ function assessReactPlaythroughReport(input) {
561
593
  ...base,
562
594
  status: "FAILED",
563
595
  cause: "The required production playthrough test file does not exist.",
564
- next: 'Create the file and render <App />. Drive a legal entry with performInput("entry"), record checkpoint("entered"), perform a core game action with performInput("primary"), and use a bounded stepUntil. Assert the authoritative result with the provided expect, then record checkpoint("progress") or checkpoint("terminal").',
596
+ next: "Create the file and render <App />. Compose one real-input enter stage, at least three named gameplay milestones, and one finish stage. Later stages may drive production input or deterministic advancement; every stage must reach and assert a bounded new result.",
565
597
  failsRun: true
566
598
  };
567
599
  }
@@ -588,7 +620,7 @@ function assessReactPlaythroughReport(input) {
588
620
  failsRun: true
589
621
  };
590
622
  }
591
- const tests = input.modules.flatMap((module) => module.tests);
623
+ const tests = productionModule.tests;
592
624
  const audit = auditReactPlaythroughRun(tests);
593
625
  if (!audit.passed) {
594
626
  const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";