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.
@@ -60,6 +60,13 @@ import { test } from "vitest";
60
60
 
61
61
  // src/react/react-playthrough-core.ts
62
62
  import { act } from "@testing-library/react";
63
+ function throwIfAborted(signal) {
64
+ if (!signal?.aborted) return;
65
+ if (signal.reason instanceof Error) throw signal.reason;
66
+ throw new Error("Playthrough advancement was cancelled.", {
67
+ cause: signal.reason
68
+ });
69
+ }
63
70
  function formatDiagnostics(read) {
64
71
  if (!read) return void 0;
65
72
  try {
@@ -88,11 +95,13 @@ async function runBoundedUntil(condition, options = {}) {
88
95
  );
89
96
  }
90
97
  for (let step = 0; step <= maxSteps; step += 1) {
98
+ throwIfAborted(options.signal);
91
99
  if (condition()) return step;
92
100
  if (step < maxSteps) {
93
101
  await act(async () => {
94
102
  await options.step?.(step + 1);
95
103
  });
104
+ throwIfAborted(options.signal);
96
105
  }
97
106
  }
98
107
  const diagnostics = formatDiagnostics(options.diagnostics);
@@ -113,36 +122,35 @@ var INPUT_EVENTS = [
113
122
  "touchstart",
114
123
  "touchend"
115
124
  ];
116
- var MIN_CHECKPOINTS = 2;
117
- var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
118
- var MAX_TRACE_VALUE_LENGTH = 180;
125
+ var MIN_STAGES = 5;
126
+ var MIN_MILESTONES = 3;
127
+ var MAX_TRACE_VALUE_LENGTH = 140;
119
128
  var MAX_TRACE_LENGTH = 720;
129
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
120
130
  function truncateTraceValue(value, limit) {
121
131
  const compact = value.replace(/\s+/g, " ").trim();
122
132
  if (compact.length <= limit) return compact;
123
133
  return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
124
134
  }
125
135
  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)}`
136
+ const stages = trace.stages.map(
137
+ (stage) => `${stage.kind}:${stage.name}=${truncateTraceValue(stage.state, MAX_TRACE_VALUE_LENGTH)}`
132
138
  );
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("; ")}`;
139
+ if (trace.current) {
140
+ stages.push(
141
+ `current:${trace.current.name}=${truncateTraceValue(trace.current.before, 70)}\u2192${truncateTraceValue(trace.current.last, 70)}`
142
+ );
143
+ }
144
+ const step = trace.step ? `stepUntil=${trace.step.completed ?? "failed"}/${trace.step.bound}` : void 0;
145
+ const formatted = `${stages.length > 0 ? stages.join(" -> ") : "stages=none"}${step ? `; ${step}` : ""}`;
138
146
  return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
139
147
  }
140
- var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
141
- function formatObservation(fingerprint) {
142
- if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
143
- return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
148
+ var MAX_FORMATTED_STATE_LENGTH = 500;
149
+ function formatState(fingerprint) {
150
+ if (fingerprint.length <= MAX_FORMATTED_STATE_LENGTH) return fingerprint;
151
+ return `${fingerprint.slice(0, MAX_FORMATTED_STATE_LENGTH)}\u2026 (${fingerprint.length} chars)`;
144
152
  }
145
- function sampleObservation(observe, stage) {
153
+ function sampleObservedState(observe, stage) {
146
154
  let value;
147
155
  try {
148
156
  value = observe();
@@ -152,263 +160,272 @@ function sampleObservation(observe, stage) {
152
160
  try {
153
161
  const fingerprint = JSON.stringify(value);
154
162
  if (fingerprint === void 0) throw new Error("unsupported value");
155
- return { fingerprint, formatted: formatObservation(fingerprint) };
163
+ return { fingerprint, formatted: formatState(fingerprint) };
156
164
  } catch {
157
165
  throw new Error(
158
166
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
159
167
  );
160
168
  }
161
169
  }
162
- function formatObservationTimeline(entered, afterPrimary, outcome) {
163
- return [
164
- `entered=${entered.formatted}`,
165
- `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
166
- `outcome=${outcome.formatted}`
167
- ].join(", ");
168
- }
169
- function describeMissingEvidence(evidence) {
170
- if (!evidence || evidence.entryInputs === 0) return "an entry input";
171
- if (evidence.primaryInputs === 0) return "a primary gameplay input";
172
- if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
173
- if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
174
- if (evidence.assertionsAfterOutcome === 0)
175
- return "an outcome assertion after stepUntil";
176
- if (evidence.checkpoints.length < MIN_CHECKPOINTS)
177
- return `at least ${MIN_CHECKPOINTS} checkpoints`;
178
- if (!evidence.checkpoints.some(
179
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
180
- )) {
181
- return "progress/terminal checkpoint";
182
- }
183
- return "a complete playthrough verification marker";
170
+ function sampleDomState(view) {
171
+ const fingerprint = view.container.innerHTML.replace(/\s+/g, " ").trim();
172
+ return { fingerprint, formatted: formatState(JSON.stringify(fingerprint)) };
184
173
  }
185
174
  function createEvidence() {
186
- return {
187
- domInputEvents: 0,
188
- entryInputs: 0,
189
- primaryInputs: 0,
190
- boundedRuns: 0,
191
- assertionsAfterOutcome: 0,
192
- checkpoints: [],
193
- verified: false
194
- };
175
+ return { domInputEvents: 0, stages: [], verified: false };
195
176
  }
196
177
  function createMetadata(waiverReason) {
197
- return { version: 3, waiverReason, evidence: createEvidence() };
178
+ return { version: 4, waiverReason, evidence: createEvidence() };
179
+ }
180
+ function stageLabel(kind, name) {
181
+ return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
182
+ }
183
+ function describeMissingEvidence(evidence) {
184
+ if (!evidence || evidence.stages.length === 0) return "an enter stage";
185
+ if (evidence.stages[0]?.kind !== "entered") return "the entered stage";
186
+ const milestones = evidence.stages.filter(
187
+ (stage) => stage.kind === "milestone"
188
+ );
189
+ if (milestones.length < MIN_MILESTONES) {
190
+ return `at least ${MIN_MILESTONES} gameplay milestones`;
191
+ }
192
+ const finalStage = evidence.stages.at(-1);
193
+ if (finalStage?.kind !== "progress" && finalStage?.kind !== "terminal") {
194
+ return "a progress or terminal finish stage";
195
+ }
196
+ if (evidence.stages.length < MIN_STAGES) {
197
+ return `at least ${MIN_STAGES} evidenced stages`;
198
+ }
199
+ return "a complete playthrough verification marker";
198
200
  }
199
201
  function definePlaythrough(element, run, playthroughOptions, waiverReason) {
200
202
  const reason = normalizePlaythroughWaiverReason(waiverReason);
201
203
  const metadata = createMetadata(reason);
202
- test("production game completes a bounded playthrough", {
203
- skip: Boolean(reason),
204
- meta: { reactPlaythrough: metadata }
205
- }, async ({ annotate, expect }) => {
206
- metadata.evidence = createEvidence();
207
- metadata.trace = void 0;
208
- const evidence = metadata.evidence;
209
- let assertionsAtOutcome;
210
- let enteredRecorded = false;
211
- let domTextAtEntered;
212
- let enteredObservation;
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
- });
234
- const recordInput = () => {
235
- evidence.domInputEvents += 1;
236
- };
237
- for (const event of INPUT_EVENTS) {
238
- document.addEventListener(event, recordInput, true);
239
- }
240
- try {
241
- const view = render(element);
242
- if (view.container.childNodes.length === 0) {
243
- throw new Error(
244
- "playthroughTest must render the production game entry."
245
- );
204
+ test(
205
+ "production game completes a bounded playthrough",
206
+ {
207
+ concurrent: false,
208
+ skip: Boolean(reason),
209
+ meta: { reactPlaythrough: metadata }
210
+ },
211
+ async ({ annotate, expect, onTestFailed, signal }) => {
212
+ metadata.evidence = createEvidence();
213
+ metadata.trace = void 0;
214
+ const evidence = metadata.evidence;
215
+ let entered = false;
216
+ let finished = false;
217
+ let activeStage;
218
+ let lastSample;
219
+ let stepTrace;
220
+ let failureTraceFactory;
221
+ let acceptingStageInput = false;
222
+ let inputCaptureAttached = false;
223
+ const recordInput = () => {
224
+ if (acceptingStageInput) evidence.domInputEvents += 1;
225
+ };
226
+ const stopInputCapture = () => {
227
+ acceptingStageInput = false;
228
+ if (!inputCaptureAttached) return;
229
+ inputCaptureAttached = false;
230
+ for (const event of INPUT_EVENTS) {
231
+ document.removeEventListener(event, recordInput, true);
232
+ }
233
+ };
234
+ const captureFailureTrace = () => {
235
+ try {
236
+ return failureTraceFactory?.() ?? "stages=none";
237
+ } catch {
238
+ return "stages=none";
239
+ }
240
+ };
241
+ onTestFailed(() => {
242
+ metadata.trace ??= captureFailureTrace();
243
+ });
244
+ for (const event of INPUT_EVENTS) {
245
+ document.addEventListener(event, recordInput, true);
246
246
  }
247
- const user = userEvent.setup();
248
- await run({
249
- view,
250
- user,
251
- expect,
252
- async performInput(kind, input) {
253
- if (kind === "entry" && enteredRecorded) {
247
+ inputCaptureAttached = true;
248
+ signal.addEventListener("abort", stopInputCapture, { once: true });
249
+ try {
250
+ const view = render(element);
251
+ if (view.container.childNodes.length === 0) {
252
+ throw new Error(
253
+ "playthroughTest must render the production game entry."
254
+ );
255
+ }
256
+ const user = userEvent.setup();
257
+ const sampleState = (label) => playthroughOptions?.observe ? sampleObservedState(playthroughOptions.observe, label) : sampleDomState(view);
258
+ lastSample = sampleState("initial render");
259
+ failureTraceFactory = () => formatReactPlaythroughFailureTrace({
260
+ stages: evidence.stages.map((stage) => ({
261
+ name: stage.name,
262
+ kind: stage.kind,
263
+ state: stage.after
264
+ })),
265
+ current: activeStage ? {
266
+ name: stageLabel(activeStage.kind, activeStage.name),
267
+ before: activeStage.before.formatted,
268
+ last: (() => {
269
+ try {
270
+ return sampleState("failure").formatted;
271
+ } catch (error) {
272
+ return `<state unavailable: ${String(error)}>`;
273
+ }
274
+ })()
275
+ } : void 0,
276
+ step: stepTrace
277
+ });
278
+ const executeStage = async (name, kind, stage) => {
279
+ const normalizedName = name.trim();
280
+ if (normalizedName.length === 0) {
281
+ throw new Error("playthrough stage names must be non-empty strings.");
282
+ }
283
+ if (evidence.stages.some(
284
+ (completed) => completed.name === normalizedName
285
+ )) {
254
286
  throw new Error(
255
- 'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
287
+ `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
256
288
  );
257
289
  }
258
- if (kind === "primary" && !enteredRecorded) {
290
+ if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
259
291
  throw new Error(
260
- 'Before performInput("primary"), run an entry input and checkpoint("entered").'
292
+ `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
261
293
  );
262
294
  }
263
- const inputsBefore = evidence.domInputEvents;
264
- await input();
265
- if (evidence.domInputEvents === inputsBefore) {
295
+ const before = lastSample ?? sampleState(`before ${normalizedName}`);
296
+ activeStage = { name: normalizedName, kind, before };
297
+ stepTrace = { bound: stage.maxSteps ?? 120 };
298
+ if (stage.until()) {
266
299
  throw new Error(
267
- `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.`
300
+ `${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.`
268
301
  );
269
302
  }
270
- if (kind === "entry") evidence.entryInputs += 1;
271
- else {
272
- evidence.primaryInputs += 1;
273
- if (playthroughOptions?.observe) {
274
- afterPrimaryObservation = sampleObservation(
275
- playthroughOptions.observe,
276
- "after-primary"
277
- );
278
- afterPrimaryTrace = afterPrimaryObservation.formatted;
279
- } else {
280
- afterPrimaryTrace = sampleDomTrace();
281
- }
282
- }
283
- },
284
- checkpoint(kind) {
285
- if (kind === "entered") {
286
- if (enteredRecorded) {
287
- throw new Error(
288
- 'checkpoint("entered") may only be recorded once, before the primary input.'
289
- );
303
+ const inputsBefore = evidence.domInputEvents;
304
+ if (stage.act) {
305
+ acceptingStageInput = true;
306
+ try {
307
+ await stage.act();
308
+ } finally {
309
+ acceptingStageInput = false;
290
310
  }
291
- if (evidence.entryInputs === 0) {
311
+ if (evidence.domInputEvents === inputsBefore) {
292
312
  throw new Error(
293
- 'checkpoint("entered") must follow performInput("entry", ...).'
294
- );
295
- }
296
- enteredRecorded = true;
297
- if (playthroughOptions?.observe) {
298
- enteredObservation = sampleObservation(
299
- playthroughOptions.observe,
300
- "entered"
313
+ `${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.`
301
314
  );
302
- enteredTrace = enteredObservation.formatted;
303
- } else {
304
- domTextAtEntered = document.body.textContent ?? "";
305
- enteredTrace = sampleDomTrace();
306
315
  }
307
- evidence.checkpoints.push(kind);
308
- return;
309
316
  }
310
- if (evidence.primaryInputs === 0) {
311
- throw new Error(
312
- `checkpoint("${kind}") must follow performInput("primary", ...).`
313
- );
314
- }
315
- if (evidence.boundedRuns === 0) {
317
+ let advancedSteps = 0;
318
+ const stepBound = stage.maxSteps ?? 120;
319
+ stepTrace = { bound: stepBound };
320
+ const steps = await runBoundedUntil(stage.until, {
321
+ maxSteps: stage.maxSteps,
322
+ signal,
323
+ diagnostics: stage.diagnostics ?? playthroughOptions?.observe,
324
+ step: stage.step ? async (step) => {
325
+ advancedSteps += 1;
326
+ await stage.step?.(step);
327
+ } : void 0
328
+ });
329
+ stepTrace = { bound: stepBound, completed: steps };
330
+ if (!stage.act && advancedSteps === 0) {
316
331
  throw new Error(
317
- `checkpoint("${kind}") must be recorded after stepUntil returns.`
332
+ `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
318
333
  );
319
334
  }
320
- if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
335
+ const assertionsBefore = expect.getState().assertionCalls;
336
+ await stage.assert({ expect, user, view });
337
+ const assertions = expect.getState().assertionCalls - assertionsBefore;
338
+ if (assertions === 0) {
321
339
  throw new Error(
322
- `Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
340
+ `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
323
341
  );
324
342
  }
325
- evidence.checkpoints.push(kind);
326
- },
327
- async stepUntil(condition, stepOptions = {}) {
328
- const stepBound = stepOptions.maxSteps ?? 120;
329
- stepTrace = { bound: stepBound };
330
- const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
331
- ...stepOptions,
332
- diagnostics: playthroughOptions.observe
333
- };
334
- const steps = await runBoundedUntil(condition, boundedOptions);
335
- stepTrace = { bound: stepBound, completed: steps };
336
- if (evidence.primaryInputs === 0) {
343
+ const after = sampleState(`after ${normalizedName}`);
344
+ if (after.fingerprint === before.fingerprint) {
345
+ const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
337
346
  throw new Error(
338
- 'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
347
+ `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
339
348
  );
340
349
  }
341
- if (playthroughOptions?.observe) {
342
- if (!enteredObservation) {
343
- throw new Error(
344
- 'observe requires checkpoint("entered") before primary gameplay input.'
345
- );
350
+ evidence.stages.push({
351
+ name: normalizedName,
352
+ kind,
353
+ domInputEvents: evidence.domInputEvents - inputsBefore,
354
+ advancedSteps,
355
+ assertions,
356
+ stateChanged: true,
357
+ before: before.formatted,
358
+ after: after.formatted
359
+ });
360
+ lastSample = after;
361
+ activeStage = void 0;
362
+ };
363
+ await run({
364
+ view,
365
+ user,
366
+ async enter(stage) {
367
+ if (entered) {
368
+ throw new Error("enter may only be called once.");
346
369
  }
347
- const outcomeObservation = sampleObservation(
348
- playthroughOptions.observe,
349
- "outcome"
350
- );
351
- outcomeTrace = outcomeObservation.formatted;
352
- if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
353
- throw new Error(
354
- `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
355
- );
370
+ if (evidence.stages.length > 0) {
371
+ throw new Error("enter must be the first playthrough stage.");
356
372
  }
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
- );
373
+ await executeStage("entered", "entered", stage);
374
+ entered = true;
375
+ },
376
+ async milestone(name, stage) {
377
+ if (!entered) {
378
+ throw new Error("milestone must follow enter.");
379
+ }
380
+ if (finished) {
381
+ throw new Error("milestone cannot run after finish.");
363
382
  }
383
+ await executeStage(name, "milestone", stage);
384
+ },
385
+ async finish(name, stage) {
386
+ if (!entered) throw new Error("finish must follow enter.");
387
+ if (finished) {
388
+ throw new Error("finish may only be called once.");
389
+ }
390
+ await executeStage(name, stage.kind, stage);
391
+ finished = true;
364
392
  }
365
- evidence.boundedRuns += 1;
366
- assertionsAtOutcome = expect.getState().assertionCalls;
367
- return steps;
368
- }
369
- });
370
- const assertionCalls = expect.getState().assertionCalls;
371
- evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
372
- if (evidence.entryInputs === 0) {
373
- throw new Error(
374
- 'playthroughTest must perform an entry input with performInput("entry", ...).'
375
- );
376
- }
377
- if (evidence.primaryInputs === 0) {
378
- throw new Error(
379
- 'playthroughTest must perform a core game action with performInput("primary", ...).'
393
+ });
394
+ const milestones = evidence.stages.filter(
395
+ (stage) => stage.kind === "milestone"
380
396
  );
381
- }
382
- if (evidence.boundedRuns === 0) {
383
- throw new Error("playthroughTest must complete one bounded stepUntil.");
384
- }
385
- if (evidence.assertionsAfterOutcome === 0) {
386
- throw new Error(
387
- "Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
388
- );
389
- }
390
- if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
391
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
392
- )) {
393
- throw new Error(
394
- `playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
395
- );
396
- }
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;
405
- } finally {
406
- metadata.trace ??= createFailureTrace();
407
- for (const event of INPUT_EVENTS) {
408
- document.removeEventListener(event, recordInput, true);
397
+ if (!entered) throw new Error("playthroughTest must call enter once.");
398
+ if (milestones.length < MIN_MILESTONES) {
399
+ throw new Error(
400
+ `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
401
+ );
402
+ }
403
+ if (!finished) {
404
+ throw new Error(
405
+ 'playthroughTest must call finish with kind "progress" or "terminal".'
406
+ );
407
+ }
408
+ if (evidence.stages.length < MIN_STAGES) {
409
+ throw new Error(
410
+ `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
411
+ );
412
+ }
413
+ evidence.verified = true;
414
+ } catch (error) {
415
+ const trace = captureFailureTrace();
416
+ metadata.trace = trace;
417
+ try {
418
+ await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
419
+ } catch {
420
+ }
421
+ throw error;
422
+ } finally {
423
+ metadata.trace ??= failureTraceFactory?.();
424
+ signal.removeEventListener("abort", stopInputCapture);
425
+ stopInputCapture();
409
426
  }
410
427
  }
411
- });
428
+ );
412
429
  }
413
430
  var playthroughTest = Object.assign(
414
431
  (element, optionsOrRun, maybeRun) => {
@@ -416,19 +433,29 @@ var playthroughTest = Object.assign(
416
433
  definePlaythrough(element, optionsOrRun);
417
434
  return;
418
435
  }
419
- if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
436
+ if (!maybeRun) {
437
+ throw new TypeError("playthroughTest requires a run callback.");
438
+ }
420
439
  definePlaythrough(element, maybeRun, optionsOrRun);
421
440
  },
422
441
  {
423
- skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
442
+ skip: (reason, element) => definePlaythrough(element, async () => {
443
+ }, void 0, reason)
424
444
  }
425
445
  );
426
446
  function auditReactPlaythroughRun(tests) {
427
447
  const declared = tests.filter((candidate) => candidate.metadata);
428
448
  const valid = declared.filter(({ state, metadata }) => {
429
449
  const evidence = metadata?.evidence;
430
- 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(
431
- (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
450
+ if (!evidence || state !== "passed" || evidence.verified !== true) {
451
+ return false;
452
+ }
453
+ const milestones = evidence.stages.filter(
454
+ (stage) => stage.kind === "milestone"
455
+ );
456
+ const finalStage = evidence.stages.at(-1);
457
+ 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(
458
+ (stage) => stage.assertions > 0 && (stage.domInputEvents > 0 || stage.advancedSteps > 0) && stage.stateChanged === true
432
459
  );
433
460
  });
434
461
  const waivers = declared.filter(
@@ -437,13 +464,11 @@ function auditReactPlaythroughRun(tests) {
437
464
  const issues = [];
438
465
  if (declared.length === 0) {
439
466
  issues.push(
440
- '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").'
467
+ "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."
441
468
  );
442
469
  } else {
443
470
  for (const candidate of declared) {
444
- const isValid = valid.includes(candidate);
445
- const isWaived = waivers.includes(candidate);
446
- if (isValid || isWaived) continue;
471
+ if (valid.includes(candidate) || waivers.includes(candidate)) continue;
447
472
  if (candidate.state === "skipped") {
448
473
  issues.push(
449
474
  `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
@@ -453,9 +478,8 @@ function auditReactPlaythroughRun(tests) {
453
478
  `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
454
479
  );
455
480
  } else {
456
- const missing = describeMissingEvidence(candidate.metadata?.evidence);
457
481
  issues.push(
458
- `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
482
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${describeMissingEvidence(candidate.metadata?.evidence)}.`
459
483
  );
460
484
  }
461
485
  }