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.
@@ -96,14 +96,112 @@ var import_react2 = require("@testing-library/react");
96
96
  var import_user_event = __toESM(require("@testing-library/user-event"));
97
97
  var import_vitest = require("vitest");
98
98
 
99
+ // src/react/react-error-diagnostics.ts
100
+ var MAX_DIAGNOSTIC_LENGTH = 1e3;
101
+ function truncate(value) {
102
+ const trimmed = value.trim();
103
+ if (trimmed.length <= MAX_DIAGNOSTIC_LENGTH) return trimmed;
104
+ return `${trimmed.slice(0, MAX_DIAGNOSTIC_LENGTH - 1)}\u2026`;
105
+ }
106
+ function safeJson(value) {
107
+ const seen = /* @__PURE__ */ new WeakSet();
108
+ try {
109
+ return JSON.stringify(value, (_key, nested) => {
110
+ if (typeof nested === "bigint") return `${nested}n`;
111
+ if (typeof nested === "function") {
112
+ return `Function<${nested.name || "anonymous"}>`;
113
+ }
114
+ if (typeof nested === "symbol") return nested.toString();
115
+ if (nested && typeof nested === "object") {
116
+ if (seen.has(nested)) return "[Circular]";
117
+ seen.add(nested);
118
+ }
119
+ return nested;
120
+ });
121
+ } catch {
122
+ return void 0;
123
+ }
124
+ }
125
+ function collectEntries(value, fallbackCode, seen) {
126
+ if (typeof value === "string") {
127
+ return value.trim() ? [{ code: fallbackCode, message: truncate(value) }] : [];
128
+ }
129
+ if (value === null || value === void 0 || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol") {
130
+ return [{ code: fallbackCode, message: String(value) }];
131
+ }
132
+ if (typeof value === "function") {
133
+ return [
134
+ { code: fallbackCode, message: `Function<${value.name || "anonymous"}>` }
135
+ ];
136
+ }
137
+ if (seen.has(value)) return [];
138
+ seen.add(value);
139
+ if (Array.isArray(value)) {
140
+ return value.flatMap((item) => collectEntries(item, fallbackCode, seen));
141
+ }
142
+ const record = value;
143
+ const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : fallbackCode;
144
+ const entries = [];
145
+ if (typeof record.message === "string" && record.message.trim()) {
146
+ entries.push({ code, message: truncate(record.message) });
147
+ }
148
+ if (record.cause !== void 0) {
149
+ entries.push(...collectEntries(record.cause, fallbackCode, seen));
150
+ }
151
+ if (Array.isArray(record.errors)) {
152
+ entries.push(...collectEntries(record.errors, fallbackCode, seen));
153
+ }
154
+ if (entries.length > 0) return entries;
155
+ if (typeof record.stack === "string" && record.stack.trim()) {
156
+ return [{ code, message: truncate(record.stack) }];
157
+ }
158
+ const json = safeJson(value);
159
+ return json && json !== "{}" ? [{ code, message: truncate(json) }] : [];
160
+ }
161
+ function extractFailureEntries(value, fallbackCode = "TEST_FAILURE") {
162
+ const entries = collectEntries(value, fallbackCode, /* @__PURE__ */ new WeakSet());
163
+ const keys = /* @__PURE__ */ new Set();
164
+ return entries.filter((entry) => {
165
+ const key = `${entry.code}\0${entry.message}`;
166
+ if (keys.has(key)) return false;
167
+ keys.add(key);
168
+ return true;
169
+ });
170
+ }
171
+ function createFailureDiagnostic(source, value) {
172
+ return { source, entries: extractFailureEntries(value) };
173
+ }
174
+ function appendCurrentAttemptFailures(current, runnerValue) {
175
+ const runner = createFailureDiagnostic("test-runtime", runnerValue);
176
+ if (!current || current.entries.length === 0) return runner;
177
+ const primary = current.entries[0];
178
+ const currentStart = runner.entries.findIndex(
179
+ (entry) => entry.code === primary.code && entry.message === primary.message
180
+ );
181
+ if (currentStart < 0) return current;
182
+ return {
183
+ source: current.source,
184
+ entries: extractFailureEntries([
185
+ ...current.entries,
186
+ ...runner.entries.slice(currentStart + 1)
187
+ ])
188
+ };
189
+ }
190
+ function codedError(code, message) {
191
+ const error = new Error(message);
192
+ error.code = code;
193
+ return error;
194
+ }
195
+
99
196
  // src/react/react-playthrough-core.ts
100
197
  var import_react = require("@testing-library/react");
101
198
  function throwIfAborted(signal) {
102
199
  if (!signal?.aborted) return;
103
200
  if (signal.reason instanceof Error) throw signal.reason;
104
- throw new Error("Playthrough advancement was cancelled.", {
105
- cause: signal.reason
106
- });
201
+ throw codedError(
202
+ "PLAYTHROUGH_CANCELLED",
203
+ `Playthrough advancement was cancelled. Cause: ${String(signal.reason)}`
204
+ );
107
205
  }
108
206
  function formatDiagnostics(read) {
109
207
  if (!read) return void 0;
@@ -119,7 +217,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
119
217
  if (waiverReason === void 0) return void 0;
120
218
  const reason = waiverReason.trim();
121
219
  if (reason.length < 20) {
122
- throw new Error(
220
+ throw codedError(
221
+ "INVALID_PLAYTHROUGH_WAIVER",
123
222
  "playthroughTest.skip reason must contain at least 20 characters."
124
223
  );
125
224
  }
@@ -128,7 +227,8 @@ function normalizePlaythroughWaiverReason(waiverReason) {
128
227
  async function runBoundedUntil(condition, options = {}) {
129
228
  const maxSteps = options.maxSteps ?? 120;
130
229
  if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
131
- throw new RangeError(
230
+ throw codedError(
231
+ "INVALID_STEP_BOUND",
132
232
  "stepUntil maxSteps must be a safe integer between 0 and 10000."
133
233
  );
134
234
  }
@@ -145,7 +245,8 @@ async function runBoundedUntil(condition, options = {}) {
145
245
  const diagnostics = formatDiagnostics(options.diagnostics);
146
246
  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().";
147
247
  const suffix = diagnostics ? ` Last diagnostics: ${diagnostics}` : "";
148
- throw new Error(
248
+ throw codedError(
249
+ options.step ? "PLAYTHROUGH_BOUND_EXHAUSTED" : "PLAYTHROUGH_CLOCK_NOT_ADVANCED",
149
250
  `Playthrough outcome was not reached within ${maxSteps} steps. ${guidance}${suffix}`
150
251
  );
151
252
  }
@@ -164,6 +265,11 @@ var MIN_STAGES = 5;
164
265
  var MIN_MILESTONES = 3;
165
266
  var MAX_TRACE_VALUE_LENGTH = 140;
166
267
  var MAX_TRACE_LENGTH = 720;
268
+ function eventTargetsCanvas(event) {
269
+ const path = typeof event.composedPath === "function" ? event.composedPath() : [];
270
+ if (path.some((target) => target instanceof HTMLCanvasElement)) return true;
271
+ return event.target instanceof HTMLCanvasElement;
272
+ }
167
273
  var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
168
274
  function truncateTraceValue(value, limit) {
169
275
  const compact = value.replace(/\s+/g, " ").trim();
@@ -193,14 +299,18 @@ function sampleObservedState(observe, stage) {
193
299
  try {
194
300
  value = observe();
195
301
  } catch (error) {
196
- throw new Error(`observe() threw at ${stage}: ${String(error)}`);
302
+ throw codedError(
303
+ "OBSERVE_FAILED",
304
+ `observe() threw at ${stage}: ${String(error)}`
305
+ );
197
306
  }
198
307
  try {
199
308
  const fingerprint = JSON.stringify(value);
200
309
  if (fingerprint === void 0) throw new Error("unsupported value");
201
310
  return { fingerprint, formatted: formatState(fingerprint) };
202
311
  } catch {
203
- throw new Error(
312
+ throw codedError(
313
+ "OBSERVE_NOT_SERIALIZABLE",
204
314
  `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
205
315
  );
206
316
  }
@@ -213,7 +323,7 @@ function createEvidence() {
213
323
  return { domInputEvents: 0, stages: [], verified: false };
214
324
  }
215
325
  function createMetadata(waiverReason) {
216
- return { version: 4, waiverReason, evidence: createEvidence() };
326
+ return { version: 5, waiverReason, evidence: createEvidence() };
217
327
  }
218
328
  function stageLabel(kind, name) {
219
329
  return kind === "entered" ? "entered" : `${kind}(${JSON.stringify(name)})`;
@@ -249,6 +359,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
249
359
  async ({ annotate, expect, onTestFailed, signal }) => {
250
360
  metadata.evidence = createEvidence();
251
361
  metadata.trace = void 0;
362
+ metadata.failure = void 0;
252
363
  const evidence = metadata.evidence;
253
364
  let entered = false;
254
365
  let finished = false;
@@ -257,9 +368,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
257
368
  let stepTrace;
258
369
  let failureTraceFactory;
259
370
  let acceptingStageInput = false;
371
+ let activeStageTargetedCanvas = false;
260
372
  let inputCaptureAttached = false;
261
- const recordInput = () => {
262
- if (acceptingStageInput) evidence.domInputEvents += 1;
373
+ const recordInput = (event) => {
374
+ if (!acceptingStageInput) return;
375
+ evidence.domInputEvents += 1;
376
+ activeStageTargetedCanvas ||= eventTargetsCanvas(event);
263
377
  };
264
378
  const stopInputCapture = () => {
265
379
  acceptingStageInput = false;
@@ -276,8 +390,12 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
276
390
  return "stages=none";
277
391
  }
278
392
  };
279
- onTestFailed(() => {
393
+ onTestFailed(({ task }) => {
280
394
  metadata.trace ??= captureFailureTrace();
395
+ metadata.failure = appendCurrentAttemptFailures(
396
+ metadata.failure,
397
+ task.result?.errors ?? []
398
+ );
281
399
  });
282
400
  for (const event of INPUT_EVENTS) {
283
401
  document.addEventListener(event, recordInput, true);
@@ -287,7 +405,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
287
405
  try {
288
406
  const view = (0, import_react2.render)(element);
289
407
  if (view.container.childNodes.length === 0) {
290
- throw new Error(
408
+ throw codedError(
409
+ "PRODUCTION_ENTRY_NOT_RENDERED",
291
410
  "playthroughTest must render the production game entry."
292
411
  );
293
412
  }
@@ -316,29 +435,42 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
316
435
  const executeStage = async (name, kind, stage) => {
317
436
  const normalizedName = name.trim();
318
437
  if (normalizedName.length === 0) {
319
- throw new Error("playthrough stage names must be non-empty strings.");
438
+ throw codedError(
439
+ "INVALID_STAGE_NAME",
440
+ "playthrough stage names must be non-empty strings."
441
+ );
320
442
  }
321
443
  if (evidence.stages.some(
322
444
  (completed) => completed.name === normalizedName
323
445
  )) {
324
- throw new Error(
446
+ throw codedError(
447
+ "DUPLICATE_STAGE_NAME",
325
448
  `playthrough stage ${JSON.stringify(normalizedName)} may only be recorded once.`
326
449
  );
327
450
  }
328
451
  if (kind === "milestone" && ["entered", "progress", "terminal"].includes(normalizedName)) {
329
- throw new Error(
452
+ throw codedError(
453
+ "RESERVED_STAGE_NAME",
330
454
  `milestone name ${JSON.stringify(normalizedName)} is reserved; use a game-domain name such as "first-point" or "boss-entered".`
331
455
  );
332
456
  }
333
457
  const before = lastSample ?? sampleState(`before ${normalizedName}`);
334
458
  activeStage = { name: normalizedName, kind, before };
335
459
  stepTrace = { bound: stage.maxSteps ?? 120 };
460
+ if (stage.step && !playthroughOptions?.observe) {
461
+ throw codedError(
462
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_STEP",
463
+ `${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.`
464
+ );
465
+ }
336
466
  if (stage.until()) {
337
- throw new Error(
467
+ throw codedError(
468
+ "STAGE_OUTCOME_ALREADY_REACHED",
338
469
  `${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.`
339
470
  );
340
471
  }
341
472
  const inputsBefore = evidence.domInputEvents;
473
+ activeStageTargetedCanvas = false;
342
474
  if (stage.act) {
343
475
  acceptingStageInput = true;
344
476
  try {
@@ -347,10 +479,17 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
347
479
  acceptingStageInput = false;
348
480
  }
349
481
  if (evidence.domInputEvents === inputsBefore) {
350
- throw new Error(
482
+ throw codedError(
483
+ "PRODUCTION_INPUT_NOT_DISPATCHED",
351
484
  `${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.`
352
485
  );
353
486
  }
487
+ if (activeStageTargetedCanvas && !playthroughOptions?.observe) {
488
+ throw codedError(
489
+ "AUTHORITATIVE_OBSERVE_REQUIRED_FOR_CANVAS",
490
+ `${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.`
491
+ );
492
+ }
354
493
  }
355
494
  let advancedSteps = 0;
356
495
  const stepBound = stage.maxSteps ?? 120;
@@ -366,7 +505,8 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
366
505
  });
367
506
  stepTrace = { bound: stepBound, completed: steps };
368
507
  if (!stage.act && advancedSteps === 0) {
369
- throw new Error(
508
+ throw codedError(
509
+ "AUTONOMOUS_STAGE_NOT_ADVANCED",
370
510
  `${stageLabel(kind, normalizedName)} did not execute its deterministic step. Autonomous stages must advance production time or frames at least once.`
371
511
  );
372
512
  }
@@ -374,14 +514,16 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
374
514
  await stage.assert({ expect, user, view });
375
515
  const assertions = expect.getState().assertionCalls - assertionsBefore;
376
516
  if (assertions === 0) {
377
- throw new Error(
517
+ throw codedError(
518
+ "STAGE_ASSERTION_MISSING",
378
519
  `${stageLabel(kind, normalizedName)} assert must call the expect provided by playthroughTest at least once.`
379
520
  );
380
521
  }
381
522
  const after = sampleState(`after ${normalizedName}`);
382
523
  if (after.fingerprint === before.fingerprint) {
383
524
  const source = playthroughOptions?.observe ? "authoritative observe() state" : "production DOM";
384
- throw new Error(
525
+ throw codedError(
526
+ "STAGE_STATE_UNCHANGED",
385
527
  `${stageLabel(kind, normalizedName)} did not change the ${source} from the previous stage. Each milestone must prove a new gameplay result.`
386
528
  );
387
529
  }
@@ -403,27 +545,27 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
403
545
  user,
404
546
  async enter(stage) {
405
547
  if (entered) {
406
- throw new Error("enter may only be called once.");
548
+ throw codedError("INVALID_STAGE_ORDER", "enter may only be called once.");
407
549
  }
408
550
  if (evidence.stages.length > 0) {
409
- throw new Error("enter must be the first playthrough stage.");
551
+ throw codedError("INVALID_STAGE_ORDER", "enter must be the first playthrough stage.");
410
552
  }
411
553
  await executeStage("entered", "entered", stage);
412
554
  entered = true;
413
555
  },
414
556
  async milestone(name, stage) {
415
557
  if (!entered) {
416
- throw new Error("milestone must follow enter.");
558
+ throw codedError("INVALID_STAGE_ORDER", "milestone must follow enter.");
417
559
  }
418
560
  if (finished) {
419
- throw new Error("milestone cannot run after finish.");
561
+ throw codedError("INVALID_STAGE_ORDER", "milestone cannot run after finish.");
420
562
  }
421
563
  await executeStage(name, "milestone", stage);
422
564
  },
423
565
  async finish(name, stage) {
424
- if (!entered) throw new Error("finish must follow enter.");
566
+ if (!entered) throw codedError("INVALID_STAGE_ORDER", "finish must follow enter.");
425
567
  if (finished) {
426
- throw new Error("finish may only be called once.");
568
+ throw codedError("INVALID_STAGE_ORDER", "finish may only be called once.");
427
569
  }
428
570
  await executeStage(name, stage.kind, stage);
429
571
  finished = true;
@@ -432,19 +574,22 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
432
574
  const milestones = evidence.stages.filter(
433
575
  (stage) => stage.kind === "milestone"
434
576
  );
435
- if (!entered) throw new Error("playthroughTest must call enter once.");
577
+ if (!entered) throw codedError("INCOMPLETE_PLAYTHROUGH_EVIDENCE", "playthroughTest must call enter once.");
436
578
  if (milestones.length < MIN_MILESTONES) {
437
- throw new Error(
579
+ throw codedError(
580
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
438
581
  `playthroughTest requires at least ${MIN_MILESTONES} named gameplay milestones between enter and finish; received ${milestones.length}.`
439
582
  );
440
583
  }
441
584
  if (!finished) {
442
- throw new Error(
585
+ throw codedError(
586
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
443
587
  'playthroughTest must call finish with kind "progress" or "terminal".'
444
588
  );
445
589
  }
446
590
  if (evidence.stages.length < MIN_STAGES) {
447
- throw new Error(
591
+ throw codedError(
592
+ "INCOMPLETE_PLAYTHROUGH_EVIDENCE",
448
593
  `playthroughTest requires at least ${MIN_STAGES} evidenced stages: enter, ${MIN_MILESTONES} named milestones, and finish.`
449
594
  );
450
595
  }
@@ -452,6 +597,7 @@ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
452
597
  } catch (error) {
453
598
  const trace = captureFailureTrace();
454
599
  metadata.trace = trace;
600
+ metadata.failure = createFailureDiagnostic("playthrough", error);
455
601
  try {
456
602
  await annotate(trace, REACT_PLAYTHROUGH_TRACE_ANNOTATION);
457
603
  } catch {