miaoda-game-devkit 0.6.2 → 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
  }
@@ -160,14 +261,18 @@ function sampleObservedState(observe, stage) {
160
261
  try {
161
262
  value = observe();
162
263
  } catch (error) {
163
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
264
+ throw codedError(
265
+ "OBSERVE_FAILED",
266
+ `observe() threw at ${stage}: ${String(error)}`
267
+ );
164
268
  }
165
269
  try {
166
270
  const fingerprint = JSON.stringify(value);
167
271
  if (fingerprint === void 0) throw new Error("unsupported value");
168
272
  return { fingerprint, formatted: formatState(fingerprint) };
169
273
  } catch {
170
- throw new Error(
274
+ throw codedError(
275
+ "OBSERVE_NOT_SERIALIZABLE",
171
276
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
172
277
  );
173
278
  }
@@ -180,7 +285,7 @@ function createEvidence() {
180
285
  return { domInputEvents: 0, stages: [], verified: false };
181
286
  }
182
287
  function createMetadata(waiverReason) {
183
- return { version: 4, waiverReason, evidence: createEvidence() };
288
+ return { version: 5, waiverReason, evidence: createEvidence() };
184
289
  }
185
290
  function stageLabel(kind, name) {
186
291
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -216,6 +321,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
216
321
  async ({ annotate, expect, onTestFailed, signal }) => {
217
322
  metadata.evidence = createEvidence();
218
323
  metadata.trace = void 0;
324
+ metadata.failure = void 0;
219
325
  const evidence = metadata.evidence;
220
326
  let entered = false;
221
327
  let finished = false;
@@ -246,8 +352,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
246
352
  return "stages=none";
247
353
  }
248
354
  };
249
- onTestFailed(() => {
355
+ onTestFailed(({ task }) => {
250
356
  metadata.trace ??= captureFailureTrace();
357
+ metadata.failure = appendCurrentAttemptFailures(
358
+ metadata.failure,
359
+ task.result?.errors ?? []
360
+ );
251
361
  });
252
362
  for (const event of INPUT_EVENTS) {
253
363
  document.addEventListener(event, recordInput, true);
@@ -257,7 +367,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
257
367
  try {
258
368
  const view = render(element);
259
369
  if (view.container.childNodes.length === 0) {
260
- throw new Error(
370
+ throw codedError(
371
+ "PRODUCTION_ENTRY_NOT_RENDERED",
261
372
  "playthroughTest must render the production game entry."
262
373
  );
263
374
  }
@@ -286,17 +397,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
286
397
  const executeStage = async (name, kind, stage) => {
287
398
  const normalizedName = name.trim();
288
399
  if (normalizedName.length === 0) {
289
- 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
+ );
290
404
  }
291
405
  if (evidence.stages.some(
292
406
  (completed) => completed.name === normalizedName
293
407
  )) {
294
- throw new Error(
408
+ throw codedError(
409
+ "DUPLICATE_STAGE_NAME",
295
410
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
296
411
  );
297
412
  }
298
413
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
299
- throw new Error(
414
+ throw codedError(
415
+ "RESERVED_STAGE_NAME",
300
416
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
301
417
  );
302
418
  }
@@ -304,12 +420,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
304
420
  activeStage = { name: normalizedName, kind, before };
305
421
  stepTrace = { bound: stage.maxSteps ?? 120 };
306
422
  if (stage.step && !playthroughOptions?.observe) {
307
- throw new Error(
423
+ throw codedError(
424
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
308
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.`
309
426
  );
310
427
  }
311
428
  if (stage.until()) {
312
- throw new Error(
429
+ throw codedError(
430
+ "STAGE_OUTCOME_ALREADY_REACHED",
313
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.`
314
432
  );
315
433
  }
@@ -323,12 +441,14 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
323
441
  acceptingStageInput = false;
324
442
  }
325
443
  if (evidence.domInputEvents === inputsBefore) {
326
- throw new Error(
444
+ throw codedError(
445
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
327
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.`
328
447
  );
329
448
  }
330
449
  if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
331
- throw new Error(
450
+ throw codedError(
451
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
332
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.`
333
453
  );
334
454
  }
@@ -347,7 +467,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
347
467
  });
348
468
  stepTrace = { bound: stepBound, completed: steps };
349
469
  if (!stage.act && advancedSteps === 0) {
350
- throw new Error(
470
+ throw codedError(
471
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
351
472
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
352
473
  );
353
474
  }
@@ -355,14 +476,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
355
476
  await stage.assert({ expect, user, view });
356
477
  const assertions = expect.getState().assertionCalls - assertionsBefore;
357
478
  if (assertions === 0) {
358
- throw new Error(
479
+ throw codedError(
480
+ "STAGE_ASSERTION_MISSING",
359
481
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
360
482
  );
361
483
  }
362
484
  const after = sampleState(`after ${normalizedName}`);
363
485
  if (after.fingerprint === before.fingerprint) {
364
486
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
365
- throw new Error(
487
+ throw codedError(
488
+ "STAGE_STATE_UNCHANGED",
366
489
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
367
490
  );
368
491
  }
@@ -384,27 +507,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
384
507
  user,
385
508
  async enter(stage) {
386
509
  if (entered) {
387
- throw new Error("enter may only be called once.");
510
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
388
511
  }
389
512
  if (evidence.stages.length > 0) {
390
- throw new Error("enter must be the first playthrough stage.");
513
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
391
514
  }
392
515
  await executeStage("entered", "entered", stage);
393
516
  entered = true;
394
517
  },
395
518
  async milestone(name, stage) {
396
519
  if (!entered) {
397
- throw new Error("milestone must follow enter.");
520
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
398
521
  }
399
522
  if (finished) {
400
- throw new Error("milestone cannot run after finish.");
523
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
401
524
  }
402
525
  await executeStage(name, "milestone", stage);
403
526
  },
404
527
  async finish(name, stage) {
405
- if (!entered) throw new Error("finish must follow enter.");
528
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
406
529
  if (finished) {
407
- throw new Error("finish may only be called once.");
530
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
408
531
  }
409
532
  await executeStage(name, stage.kind, stage);
410
533
  finished = true;
@@ -413,19 +536,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
413
536
  const milestones = evidence.stages.filter(
414
537
  (stage) => stage.kind === "milestone"
415
538
  );
416
- if (!entered) throw new Error("playthroughTest must call enter once.");
539
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
417
540
  if (milestones.length < MIN_MILESTONES) {
418
- throw new Error(
541
+ throw codedError(
542
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
419
543
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
420
544
  );
421
545
  }
422
546
  if (!finished) {
423
- throw new Error(
547
+ throw codedError(
548
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
424
549
  'playthroughTest must call finish with kind "progress" or "terminal".'
425
550
  );
426
551
  }
427
552
  if (evidence.stages.length < MIN_STAGES) {
428
- throw new Error(
553
+ throw codedError(
554
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
429
555
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
430
556
  );
431
557
  }
@@ -433,6 +559,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
433
559
  } catch (error) {
434
560
  const trace = captureFailureTrace();
435
561
  metadata.trace = trace;
562
+ metadata.failure = createFailureDiagnostic("playthrough", error);
436
563
  try {
437
564
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
438
565
  } catch {