miaoda-game-devkit 0.6.1 → 0.6.3

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.
@@ -58,14 +58,112 @@ import { render } from "@testing-library/react";
58
58
  import userEvent from "@testing-library/user-event";
59
59
  import { test } from "vitest";
60
60
 
61
+ // src/react/react-error-diagnostics.ts
62
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
63
+ function truncate(value) {
64
+ const trimmed = value.trim();
65
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
66
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
67
+ }
68
+ function safeJson(value) {
69
+ const seen = /* @__PURE__ */ new WeakSet();
70
+ try {
71
+ return JSON.stringify(value, (_key, nested) => {
72
+ if (typeof nested === "bigint") return `${nested}n`;
73
+ if (typeof nested === "function") {
74
+ return `Function<${nested.name || "anonymous"}>`;
75
+ }
76
+ if (typeof nested === "symbol") return nested.toString();
77
+ if (nested && typeof nested === "object") {
78
+ if (seen.has(nested)) return "[Circular]";
79
+ seen.add(nested);
80
+ }
81
+ return nested;
82
+ });
83
+ } catch {
84
+ return void 0;
85
+ }
86
+ }
87
+ function collectEntries(value, fallbackCode, seen) {
88
+ if (typeof value === "string") {
89
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
90
+ }
91
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
92
+ return [{ code: fallbackCode, message: String(value) }];
93
+ }
94
+ if (typeof value === "function") {
95
+ return [
96
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
97
+ ];
98
+ }
99
+ if (seen.has(value)) return [];
100
+ seen.add(value);
101
+ if (Array.isArray(value)) {
102
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
103
+ }
104
+ const record = value;
105
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
106
+ const entries = [];
107
+ if (typeof record.message === "string" && record.message.trim()) {
108
+ entries.push({ code, message: truncate(record.message) });
109
+ }
110
+ if (record.cause !== void 0) {
111
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
112
+ }
113
+ if (Array.isArray(record.errors)) {
114
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
115
+ }
116
+ if (entries.length > 0) return entries;
117
+ if (typeof record.stack === "string" && record.stack.trim()) {
118
+ return [{ code, message: truncate(record.stack) }];
119
+ }
120
+ const json = safeJson(value);
121
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
122
+ }
123
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
124
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
125
+ const keys = /* @__PURE__ */ new Set();
126
+ return entries.filter((entry) => {
127
+ const key = `${entry.code}\0${entry.message}`;
128
+ if (keys.has(key)) return false;
129
+ keys.add(key);
130
+ return true;
131
+ });
132
+ }
133
+ function createFailureDiagnostic(source, value) {
134
+ return { source, entries: extractFailureEntries(value) };
135
+ }
136
+ function appendCurrentAttemptFailures(current, runnerValue) {
137
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
138
+ if (!current || current.entries.length === 0) return runner;
139
+ const primary = current.entries[0];
140
+ const currentStart = runner.entries.findIndex(
141
+ (entry) => entry.code === primary.code && entry.message === primary.message
142
+ );
143
+ if (currentStart < 0) return current;
144
+ return {
145
+ source: current.source,
146
+ entries: extractFailureEntries([
147
+ ...current.entries,
148
+ ...runner.entries.slice(currentStart + 1)
149
+ ])
150
+ };
151
+ }
152
+ function codedError(code, message) {
153
+ const error = new Error(message);
154
+ error.code = code;
155
+ return error;
156
+ }
157
+
61
158
  // src/react/react-playthrough-core.ts
62
159
  import { act } from "@testing-library/react";
63
160
  function throwIfAborted(signal) {
64
161
  if (!signal?.aborted) return;
65
162
  if (signal.reason instanceof Error) throw signal.reason;
66
- throw new Error("Playthrough advancement was cancelled.", {
67
- cause: signal.reason
68
- });
163
+ throw codedError(
164
+ "PLAYTHROUGH_CANCELLED",
165
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
166
+ );
69
167
  }
70
168
  function formatDiagnostics(read) {
71
169
  if (!read) return void 0;
@@ -81,7 +179,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
81
179
  if (waiverReason === void 0) return void 0;
82
180
  const reason = waiverReason.trim();
83
181
  if (reason.length < 20) {
84
- throw new Error(
182
+ throw codedError(
183
+ "INVALID_PLAYTHROUGH_WAIVER",
85
184
  "playthroughTest.skip reason must contain at least 20 characters."
86
185
  );
87
186
  }
@@ -90,7 +189,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
90
189
  async function runBoundedUntil(condition, options = {}) {
91
190
  const maxSteps = options.maxSteps ?? 120;
92
191
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
93
- throw new RangeError(
192
+ throw codedError(
193
+ "INVALID_STEP_BOUND",
94
194
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
95
195
  );
96
196
  }
@@ -107,7 +207,8 @@ async function runBoundedUntil(condition, options = {}) {
107
207
  const diagnostics = formatDiagnostics(options.diagnostics);
108
208
  const guidance = options.step ? "The step callback ran, but the authoritative outcome did not change." : "No step callback was provided, so time-driven gameplay was not advanced. Inject a ManualGameClock for this test and pass step: () => clock.stepFrame().";
109
209
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
110
- throw new Error(
210
+ throw codedError(
211
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
111
212
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
112
213
  );
113
214
  }
@@ -126,6 +227,11 @@ var MIN_STAGES = 5;
126
227
  var MIN_MILESTONES = 3;
127
228
  var MAX_TRACE_VALUE_LENGTH = 140;
128
229
  var MAX_TRACE_LENGTH = 720;
230
+ function eventTargetsCanvas(event) {
231
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
232
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
233
+ return event.target instanceof HTMLCanvasElement;
234
+ }
129
235
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
130
236
  function truncateTraceValue(value, limit) {
131
237
  const compact = value.replace(/\s+/g, " ").trim();
@@ -155,14 +261,18 @@ function sampleObservedState(observe, stage) {
155
261
  try {
156
262
  value = observe();
157
263
  } catch (error) {
158
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
264
+ throw codedError(
265
+ "OBSERVE_FAILED",
266
+ `observe() threw at ${stage}: ${String(error)}`
267
+ );
159
268
  }
160
269
  try {
161
270
  const fingerprint = JSON.stringify(value);
162
271
  if (fingerprint === void 0) throw new Error("unsupported value");
163
272
  return { fingerprint, formatted: formatState(fingerprint) };
164
273
  } catch {
165
- throw new Error(
274
+ throw codedError(
275
+ "OBSERVE_NOT_SERIALIZABLE",
166
276
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
167
277
  );
168
278
  }
@@ -175,7 +285,7 @@ function createEvidence() {
175
285
  return { domInputEvents: 0, stages: [], verified: false };
176
286
  }
177
287
  function createMetadata(waiverReason) {
178
- return { version: 4, waiverReason, evidence: createEvidence() };
288
+ return { version: 5, waiverReason, evidence: createEvidence() };
179
289
  }
180
290
  function stageLabel(kind, name) {
181
291
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -211,6 +321,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
211
321
  async ({ annotate, expect, onTestFailed, signal }) => {
212
322
  metadata.evidence = createEvidence();
213
323
  metadata.trace = void 0;
324
+ metadata.failure = void 0;
214
325
  const evidence = metadata.evidence;
215
326
  let entered = false;
216
327
  let finished = false;
@@ -219,9 +330,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
219
330
  let stepTrace;
220
331
  let failureTraceFactory;
221
332
  let acceptingStageInput = false;
333
+ let activeStageTargetedCanvas = false;
222
334
  let inputCaptureAttached = false;
223
- const recordInput = () => {
224
- if (acceptingStageInput) evidence.domInputEvents += 1;
335
+ const recordInput = (event) => {
336
+ if (!acceptingStageInput) return;
337
+ evidence.domInputEvents += 1;
338
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
225
339
  };
226
340
  const stopInputCapture = () => {
227
341
  acceptingStageInput = false;
@@ -238,8 +352,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
238
352
  return "stages=none";
239
353
  }
240
354
  };
241
- onTestFailed(() => {
355
+ onTestFailed(({ task }) => {
242
356
  metadata.trace ??= captureFailureTrace();
357
+ metadata.failure = appendCurrentAttemptFailures(
358
+ metadata.failure,
359
+ task.result?.errors ?? []
360
+ );
243
361
  });
244
362
  for (const event of INPUT_EVENTS) {
245
363
  document.addEventListener(event, recordInput, true);
@@ -249,7 +367,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
249
367
  try {
250
368
  const view = render(element);
251
369
  if (view.container.childNodes.length === 0) {
252
- throw new Error(
370
+ throw codedError(
371
+ "PRODUCTION_ENTRY_NOT_RENDERED",
253
372
  "playthroughTest must render the production game entry."
254
373
  );
255
374
  }
@@ -278,29 +397,42 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
278
397
  const executeStage = async (name, kind, stage) => {
279
398
  const normalizedName = name.trim();
280
399
  if (normalizedName.length === 0) {
281
- throw new Error("playthrough stage names must be non-empty strings.");
400
+ throw codedError(
401
+ "INVALID_STAGE_NAME",
402
+ "playthrough stage names must be non-empty strings."
403
+ );
282
404
  }
283
405
  if (evidence.stages.some(
284
406
  (completed) => completed.name === normalizedName
285
407
  )) {
286
- throw new Error(
408
+ throw codedError(
409
+ "DUPLICATE_STAGE_NAME",
287
410
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
288
411
  );
289
412
  }
290
413
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
291
- throw new Error(
414
+ throw codedError(
415
+ "RESERVED_STAGE_NAME",
292
416
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
293
417
  );
294
418
  }
295
419
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
296
420
  activeStage = { name: normalizedName, kind, before };
297
421
  stepTrace = { bound: stage.maxSteps ?? 120 };
422
+ if (stage.step && !playthroughOptions?.observe) {
423
+ throw codedError(
424
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
425
+ `${stageLabel(kind, normalizedName)} uses deterministic step advancement without an authoritative observe callback. Time- or frame-driven stages must observe the same production Controller that <App /> renders through Telemetry; DOM labels alone are not deterministic gameplay state.`
426
+ );
427
+ }
298
428
  if (stage.until()) {
299
- throw new Error(
429
+ throw codedError(
430
+ "STAGE_OUTCOME_ALREADY_REACHED",
300
431
  `${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.`
301
432
  );
302
433
  }
303
434
  const inputsBefore = evidence.domInputEvents;
435
+ activeStageTargetedCanvas = false;
304
436
  if (stage.act) {
305
437
  acceptingStageInput = true;
306
438
  try {
@@ -309,10 +441,17 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
309
441
  acceptingStageInput = false;
310
442
  }
311
443
  if (evidence.domInputEvents === inputsBefore) {
312
- throw new Error(
444
+ throw codedError(
445
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
313
446
  `${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.`
314
447
  );
315
448
  }
449
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
450
+ throw codedError(
451
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
452
+ `${stageLabel(kind, normalizedName)} dispatched production input to Canvas without an authoritative observe callback. Canvas pixels and control labels are not gameplay state; observe the same production Controller that <App /> renders through Telemetry.`
453
+ );
454
+ }
316
455
  }
317
456
  let advancedSteps = 0;
318
457
  const stepBound = stage.maxSteps ?? 120;
@@ -328,7 +467,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
328
467
  });
329
468
  stepTrace = { bound: stepBound, completed: steps };
330
469
  if (!stage.act && advancedSteps === 0) {
331
- throw new Error(
470
+ throw codedError(
471
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
332
472
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
333
473
  );
334
474
  }
@@ -336,14 +476,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
336
476
  await stage.assert({ expect, user, view });
337
477
  const assertions = expect.getState().assertionCalls - assertionsBefore;
338
478
  if (assertions === 0) {
339
- throw new Error(
479
+ throw codedError(
480
+ "STAGE_ASSERTION_MISSING",
340
481
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
341
482
  );
342
483
  }
343
484
  const after = sampleState(`after ${normalizedName}`);
344
485
  if (after.fingerprint === before.fingerprint) {
345
486
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
346
- throw new Error(
487
+ throw codedError(
488
+ "STAGE_STATE_UNCHANGED",
347
489
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
348
490
  );
349
491
  }
@@ -365,27 +507,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
365
507
  user,
366
508
  async enter(stage) {
367
509
  if (entered) {
368
- throw new Error("enter may only be called once.");
510
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
369
511
  }
370
512
  if (evidence.stages.length > 0) {
371
- throw new Error("enter must be the first playthrough stage.");
513
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
372
514
  }
373
515
  await executeStage("entered", "entered", stage);
374
516
  entered = true;
375
517
  },
376
518
  async milestone(name, stage) {
377
519
  if (!entered) {
378
- throw new Error("milestone must follow enter.");
520
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
379
521
  }
380
522
  if (finished) {
381
- throw new Error("milestone cannot run after finish.");
523
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
382
524
  }
383
525
  await executeStage(name, "milestone", stage);
384
526
  },
385
527
  async finish(name, stage) {
386
- if (!entered) throw new Error("finish must follow enter.");
528
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
387
529
  if (finished) {
388
- throw new Error("finish may only be called once.");
530
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
389
531
  }
390
532
  await executeStage(name, stage.kind, stage);
391
533
  finished = true;
@@ -394,19 +536,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
394
536
  const milestones = evidence.stages.filter(
395
537
  (stage) => stage.kind === "milestone"
396
538
  );
397
- if (!entered) throw new Error("playthroughTest must call enter once.");
539
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
398
540
  if (milestones.length < MIN_MILESTONES) {
399
- throw new Error(
541
+ throw codedError(
542
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
400
543
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
401
544
  );
402
545
  }
403
546
  if (!finished) {
404
- throw new Error(
547
+ throw codedError(
548
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
405
549
  'playthroughTest must call finish with kind "progress" or "terminal".'
406
550
  );
407
551
  }
408
552
  if (evidence.stages.length < MIN_STAGES) {
409
- throw new Error(
553
+ throw codedError(
554
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
410
555
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
411
556
  );
412
557
  }
@@ -414,6 +559,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
414
559
  } catch (error) {
415
560
  const trace = captureFailureTrace();
416
561
  metadata.trace = trace;
562
+ metadata.failure = createFailureDiagnostic("playthrough", error);
417
563
  try {
418
564
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
419
565
  } catch {