miaoda-game-devkit 0.3.0 → 0.5.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.
@@ -69,47 +69,123 @@ var INPUT_EVENTS = [
69
69
  "touchend"
70
70
  ];
71
71
  var MIN_CHECKPOINTS = 2;
72
+ var REACT_PLAYTHROUGH_TRACE_ANNOTATION = "miaoda:react-playthrough-trace";
73
+ var MAX_TRACE_VALUE_LENGTH = 180;
74
+ var MAX_TRACE_LENGTH = 720;
75
+ function truncateTraceValue(value, limit) {
76
+ const compact = value.replace(/\s+/g, " ").trim();
77
+ if (compact.length <= limit) return compact;
78
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
79
+ }
80
+ 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)}`
87
+ );
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("; ")}`;
93
+ return truncateTraceValue(formatted, MAX_TRACE_LENGTH);
94
+ }
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)`;
99
+ }
100
+ function sampleObservation(observe, stage) {
101
+ let value;
102
+ try {
103
+ value = observe();
104
+ } catch (error) {
105
+ throw new Error(`observe() threw at ${stage}: ${String(error)}`);
106
+ }
107
+ try {
108
+ const fingerprint = JSON.stringify(value);
109
+ if (fingerprint === void 0) throw new Error("unsupported value");
110
+ return { fingerprint, formatted: formatObservation(fingerprint) };
111
+ } catch {
112
+ throw new Error(
113
+ `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
114
+ );
115
+ }
116
+ }
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
+ }
72
124
  function describeMissingEvidence(evidence) {
73
- if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
74
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
125
+ if (!evidence || evidence.entryInputs === 0) return "an entry input";
126
+ if (evidence.primaryInputs === 0) return "a primary gameplay input";
75
127
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
76
- if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
77
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
128
+ if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
129
+ if (evidence.assertionsAfterOutcome === 0)
130
+ return "an outcome assertion after stepUntil";
78
131
  if (evidence.checkpoints.length < MIN_CHECKPOINTS)
79
- return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
132
+ return `at least ${MIN_CHECKPOINTS} checkpoints`;
80
133
  if (!evidence.checkpoints.some(
81
134
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
82
135
  )) {
83
136
  return "progress/terminal checkpoint";
84
137
  }
85
- return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
138
+ return "a complete playthrough verification marker";
86
139
  }
87
- function createMetadata(waiverReason) {
140
+ function createEvidence() {
88
141
  return {
89
- version: 3,
90
- waiverReason,
91
- evidence: {
92
- domInputEvents: 0,
93
- entryInputs: 0,
94
- primaryInputs: 0,
95
- boundedRuns: 0,
96
- assertionsAfterOutcome: 0,
97
- checkpoints: [],
98
- verified: false
99
- }
142
+ domInputEvents: 0,
143
+ entryInputs: 0,
144
+ primaryInputs: 0,
145
+ boundedRuns: 0,
146
+ assertionsAfterOutcome: 0,
147
+ checkpoints: [],
148
+ verified: false
100
149
  };
101
150
  }
102
- function definePlaythrough(element, run, waiverReason) {
151
+ function createMetadata(waiverReason) {
152
+ return { version: 3, waiverReason, evidence: createEvidence() };
153
+ }
154
+ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
103
155
  const reason = normalizePlaythroughWaiverReason(waiverReason);
104
156
  const metadata = createMetadata(reason);
105
157
  test("production game completes a bounded playthrough", {
106
158
  skip: Boolean(reason),
107
159
  meta: { reactPlaythrough: metadata }
108
- }, async ({ expect }) => {
160
+ }, async ({ annotate, expect }) => {
161
+ metadata.evidence = createEvidence();
162
+ metadata.trace = void 0;
109
163
  const evidence = metadata.evidence;
110
164
  let assertionsAtOutcome;
111
165
  let enteredRecorded = false;
112
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
+ });
113
189
  const recordInput = () => {
114
190
  evidence.domInputEvents += 1;
115
191
  };
@@ -147,7 +223,18 @@ function definePlaythrough(element, run, waiverReason) {
147
223
  );
148
224
  }
149
225
  if (kind === "entry") evidence.entryInputs += 1;
150
- else evidence.primaryInputs += 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
+ }
151
238
  },
152
239
  checkpoint(kind) {
153
240
  if (kind === "entered") {
@@ -162,7 +249,16 @@ function definePlaythrough(element, run, waiverReason) {
162
249
  );
163
250
  }
164
251
  enteredRecorded = true;
165
- domTextAtEntered = document.body.textContent ?? "";
252
+ if (playthroughOptions?.observe) {
253
+ enteredObservation = sampleObservation(
254
+ playthroughOptions.observe,
255
+ "entered"
256
+ );
257
+ enteredTrace = enteredObservation.formatted;
258
+ } else {
259
+ domTextAtEntered = document.body.textContent ?? "";
260
+ enteredTrace = sampleDomTrace();
261
+ }
166
262
  evidence.checkpoints.push(kind);
167
263
  return;
168
264
  }
@@ -183,17 +279,43 @@ function definePlaythrough(element, run, waiverReason) {
183
279
  }
184
280
  evidence.checkpoints.push(kind);
185
281
  },
186
- async stepUntil(condition, options = {}) {
187
- const steps = await runBoundedUntil(condition, options);
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 };
188
291
  if (evidence.primaryInputs === 0) {
189
292
  throw new Error(
190
293
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
191
294
  );
192
295
  }
193
- if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
194
- throw new Error(
195
- 'stepUntil outcome was already true at step 0 and the DOM has not changed since checkpoint("entered"), so the flow cannot prove that gameplay changed anything. \u610F\u601D\uFF1A\u6E38\u620F\u4E00\u6B65\u90FD\u6CA1\u73A9\uFF0C\u7B49\u5F85\u7684"\u7ED3\u679C"\u5C31\u5DF2\u7ECF\u6210\u7ACB\uFF0C\u9875\u9762\u4E5F\u4E00\u4E2A\u5B57\u6CA1\u53D8\u2014\u2014\u8FD9\u4E2A\u7ED3\u679C\u8BC1\u660E\u4E0D\u4E86\u4EFB\u4F55\u4E8B\u3002\u5E38\u89C1\u539F\u56E0\uFF1A\u2460 \u8F93\u5165\u6CA1\u6709\u63A5\u5230\u6E38\u620F\u4E0A\uFF1B\u2461 \u754C\u9762\u5361\u6B7B\uFF08\u72B6\u6001\u6539\u4E86\u4F46 snapshot \u5F15\u7528\u6CA1\u6362\uFF0CReact \u6CA1\u6709\u5237\u65B0\uFF09\uFF1B\u2462 \u65AD\u8A00\u4E86\u5F00\u5C40\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u4FEE\u590D\uFF1A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u8D77\u6765\u4E4B\u540E\u624D\u4F1A\u51FA\u73B0\u7684\u4E1C\u897F\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u753B\u9762\u51FA\u73B0\uFF09\u3002\u4F8B\u5916\uFF1A\u7ED3\u679C\u753B\u5728 Canvas \u4E0A\u3001\u7ECF Telemetry \u7B49\u9875\u9762\u5916\u72B6\u6001\u89C2\u5BDF\u7684\u6E38\u620F\uFF0C\u663E\u5F0F\u4F20 { allowStaticDom: true }\u3002'
296
+ if (playthroughOptions?.observe) {
297
+ if (!enteredObservation) {
298
+ throw new Error(
299
+ 'observe requires checkpoint("entered") before primary gameplay input.'
300
+ );
301
+ }
302
+ const outcomeObservation = sampleObservation(
303
+ playthroughOptions.observe,
304
+ "outcome"
196
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
+ );
311
+ }
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
+ );
318
+ }
197
319
  }
198
320
  evidence.boundedRuns += 1;
199
321
  assertionsAtOutcome = expect.getState().assertionCalls;
@@ -228,7 +350,15 @@ function definePlaythrough(element, run, waiverReason) {
228
350
  );
229
351
  }
230
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;
231
360
  } finally {
361
+ metadata.trace ??= createFailureTrace();
232
362
  for (const event of INPUT_EVENTS) {
233
363
  document.removeEventListener(event, recordInput, true);
234
364
  }
@@ -236,9 +366,16 @@ function definePlaythrough(element, run, waiverReason) {
236
366
  });
237
367
  }
238
368
  var playthroughTest = Object.assign(
239
- (element, run) => definePlaythrough(element, run),
369
+ (element, optionsOrRun, maybeRun) => {
370
+ if (typeof optionsOrRun === "function") {
371
+ definePlaythrough(element, optionsOrRun);
372
+ return;
373
+ }
374
+ if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
375
+ definePlaythrough(element, maybeRun, optionsOrRun);
376
+ },
240
377
  {
241
- skip: (reason, element, run) => definePlaythrough(element, run, reason)
378
+ skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
242
379
  }
243
380
  );
244
381
  function auditReactPlaythroughRun(tests) {
@@ -255,7 +392,7 @@ function auditReactPlaythroughRun(tests) {
255
392
  const issues = [];
256
393
  if (declared.length === 0) {
257
394
  issues.push(
258
- '\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\uFF1B\u81F3\u5C11\u5B8C\u6210\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002'
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").'
259
396
  );
260
397
  } else {
261
398
  for (const candidate of declared) {
@@ -264,13 +401,17 @@ function auditReactPlaythroughRun(tests) {
264
401
  if (isValid || isWaived) continue;
265
402
  if (candidate.state === "skipped") {
266
403
  issues.push(
267
- `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u88AB\u8DF3\u8FC7\uFF0C\u4F46\u6CA1\u6709\u81F3\u5C11 20 \u4E2A\u5B57\u7B26\u7684\u660E\u786E\u7406\u7531\u3002`
404
+ `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
268
405
  );
269
406
  } else if (candidate.state !== "passed") {
270
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
407
+ issues.push(
408
+ `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
409
+ );
271
410
  } else {
272
411
  const missing = describeMissingEvidence(candidate.metadata?.evidence);
273
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
412
+ issues.push(
413
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
414
+ );
274
415
  }
275
416
  }
276
417
  }
@@ -310,21 +451,41 @@ function errorLocation(value) {
310
451
  const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
311
452
  return match?.[1];
312
453
  }
454
+ function failureTrace(test2) {
455
+ const annotations = test2.annotations();
456
+ let annotationTrace;
457
+ for (let index = annotations.length - 1; index >= 0; index -= 1) {
458
+ if (annotations[index].type === REACT_PLAYTHROUGH_TRACE_ANNOTATION) {
459
+ annotationTrace = annotations[index].message;
460
+ break;
461
+ }
462
+ }
463
+ const metadata = test2.meta().reactPlaythrough;
464
+ const trace = annotationTrace ?? (isMetadata(metadata) ? metadata.trace : void 0);
465
+ return trace ? truncateReporterLine(trace, 720) : void 0;
466
+ }
467
+ function truncateReporterLine(value, limit) {
468
+ const compact = stripVTControlCharacters(value).replace(/\s+/g, " ").trim();
469
+ if (compact.length <= limit) return compact;
470
+ return `${compact.slice(0, Math.max(0, limit - 1))}\u2026`;
471
+ }
313
472
  function toModuleResult(module, projectRoot) {
314
473
  const tests = [...module.children.allTests()];
474
+ const moduleErrors = module.errors().map((error) => firstLine(error.message)).filter((message) => Boolean(message));
315
475
  const errors = [
316
- ...module.errors().map((error) => firstLine(error.message)),
476
+ ...moduleErrors,
317
477
  ...tests.flatMap(
318
478
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
319
479
  )
320
480
  ].filter((message) => Boolean(message));
321
481
  const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
322
- const raw = test2.result().errors?.[0]?.message ?? module.errors()[0]?.message ?? "Unknown failure";
482
+ const raw = test2.result().errors?.at(-1)?.message ?? module.errors()[0]?.message ?? "Unknown failure";
323
483
  return {
324
484
  test: test2.fullName,
325
485
  cause: firstLine(raw) ?? "Unknown failure",
326
486
  location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
327
- hint: failureHint(raw)
487
+ hint: failureHint(raw),
488
+ trace: failureTrace(test2)
328
489
  };
329
490
  });
330
491
  if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
@@ -340,11 +501,12 @@ function toModuleResult(module, projectRoot) {
340
501
  file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
341
502
  state: module.state(),
342
503
  errors,
504
+ primaryError: moduleErrors[0] ?? failures[0]?.cause,
343
505
  tests: tests.map(toAuditInput),
344
506
  failures
345
507
  };
346
508
  }
347
- function formatFailureSummary(modules) {
509
+ function formatReactFailureSummary(modules) {
348
510
  const failures = modules.flatMap(
349
511
  (module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
350
512
  );
@@ -354,12 +516,36 @@ function formatFailureSummary(modules) {
354
516
  lines.push(`FAILURE_${index + 1}: ${failure.file}`);
355
517
  lines.push(`TEST: ${failure.test}`);
356
518
  lines.push(`CAUSE: ${failure.cause}`);
519
+ if (failure.trace) lines.push(`TRACE: ${failure.trace}`);
357
520
  if (failure.location) lines.push(`AT: ${failure.location}`);
358
521
  if (failure.hint) lines.push(`HINT: ${failure.hint}`);
359
522
  }
360
523
  lines.push("TEST_RESULT: FAIL");
361
524
  return lines;
362
525
  }
526
+ function repairGuidance(cause) {
527
+ if (/snapshot\(\) returned the same reference/i.test(cause)) {
528
+ 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
+ }
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.';
534
+ }
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.";
537
+ }
538
+ 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.";
540
+ }
541
+ 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.";
543
+ }
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").';
546
+ }
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.";
548
+ }
363
549
  function assessReactPlaythroughReport(input) {
364
550
  const base = { file: input.expectedFile };
365
551
  if (!input.expectedFileScheduled) {
@@ -367,15 +553,15 @@ function assessReactPlaythroughReport(input) {
367
553
  return {
368
554
  ...base,
369
555
  status: "NOT_CHECKED",
370
- next: `\u672C\u6B21\u662F\u805A\u7126\u8FD0\u884C\uFF0C\u672A\u68C0\u67E5\u6700\u4F4E\u53EF\u73A9\u6D41\u7A0B\uFF1B\u63D0\u4EA4\u524D\u8FD0\u884C pnpm test\uFF0C\u786E\u4FDD ${input.expectedFile} \u901A\u8FC7\u3002`,
556
+ next: `This focused run did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
371
557
  failsRun: false
372
558
  };
373
559
  }
374
560
  return {
375
561
  ...base,
376
562
  status: "FAILED",
377
- cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
378
- next: '\u521B\u5EFA\u8BE5\u6587\u4EF6\uFF1A\u4ECE <App /> \u901A\u8FC7\u771F\u5B9E DOM \u8F93\u5165\u4F9D\u6B21\u6267\u884C performInput("entry")\u3001checkpoint("entered")\u3001performInput("primary")\u3001stepUntil\u3001\u7528 playthroughTest \u63D0\u4F9B\u7684 expect \u65AD\u8A00\u6743\u5A01\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 checkpoint("progress") \u6216 checkpoint("terminal")\u3002\u6BCF\u6761\u4E3B\u6D41\u7A0B\u81F3\u5C11\u9700\u8981\u8FD9\u4E24\u6B21 checkpoint \u7B7E\u5230\u3002',
563
+ 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").',
379
565
  failsRun: true
380
566
  };
381
567
  }
@@ -389,7 +575,7 @@ function assessReactPlaythroughReport(input) {
389
575
  return {
390
576
  ...base,
391
577
  status: "NOT_CHECKED",
392
- next: `\u672C\u6B21\u540D\u79F0\u6216\u884C\u53F7\u8FC7\u6EE4\u6CA1\u6709\u6267\u884C\u6700\u4F4E\u53EF\u73A9\u6D41\u7A0B\uFF1B\u63D0\u4EA4\u524D\u8FD0\u884C pnpm test\uFF0C\u786E\u4FDD ${input.expectedFile} \u901A\u8FC7\u3002`,
578
+ next: `The name or line filter did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
393
579
  failsRun: false
394
580
  };
395
581
  }
@@ -397,25 +583,20 @@ function assessReactPlaythroughReport(input) {
397
583
  return {
398
584
  ...base,
399
585
  status: "NOT_RUN",
400
- cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u672A\u5B8C\u6210\u6536\u96C6\u6216\u6267\u884C\u3002",
401
- next: "\u5148\u4FEE\u590D Vitest \u4E0A\u65B9\u9996\u4E2A\u8BED\u6CD5\u3001\u5BFC\u5165\u6216\u6536\u96C6\u9519\u8BEF\uFF0C\u518D\u8FD0\u884C pnpm test\uFF1B\u4E0D\u8981\u7528 skip \u63A9\u76D6\u52A0\u8F7D\u5931\u8D25\u3002",
586
+ cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
587
+ next: "Fix the first Vitest syntax, import, environment, or collection error shown above, then run pnpm test again. Do not use skip to hide a load failure.",
402
588
  failsRun: true
403
589
  };
404
590
  }
405
591
  const tests = input.modules.flatMap((module) => module.tests);
406
592
  const audit = auditReactPlaythroughRun(tests);
407
593
  if (!audit.passed) {
408
- const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "\u751F\u4EA7\u53EF\u73A9\u6D41\u7A0B\u6CA1\u6709\u7559\u4E0B\u5B8C\u6574\u8BC1\u636E\u3002";
409
- const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
410
- const missingStep = /No step callback was provided/i.test(cause);
411
- const staticOutcome = /already true at step 0 and the DOM has not changed/i.test(cause);
412
- const staleSnapshot = /snapshot\(\) returned the same reference/i.test(cause);
413
- const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
594
+ const cause = productionModule.primaryError ?? productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
414
595
  return {
415
596
  ...base,
416
597
  status: "FAILED",
417
598
  cause,
418
- next: staleSnapshot ? "\u6E38\u620F\u539F\u5730\u4FEE\u6539\u72B6\u6001\u540E\u6CA1\u6709\u53D1\u5E03\u65B0\u7684\u5FEB\u7167\u5F15\u7528\uFF0CReact \u56E0 Object.is \u6BD4\u8F83\u76F8\u540C\u800C\u8DF3\u8FC7\u91CD\u6E32\u3002\u5728 controller \u7684 notify \u8DEF\u5F84\u4E0A\u53D1\u5E03\u65B0\u9876\u5C42\u5BF9\u8C61\uFF08cachedSnapshot = { ...state }\uFF09\uFF0C\u4E0D\u8981\u628A\u53EF\u53D8\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u4F5C\u4E3A\u5FEB\u7167\u66B4\u9732\u3002" : staticOutcome ? "\u7ED3\u679C\u5728\u63A8\u8FDB\u524D\u5DF2\u6210\u7ACB\u4E14 DOM \u81EA entered \u4EE5\u6765\u65E0\u53D8\u5316\uFF1A\u8F93\u5165\u53EF\u80FD\u672A\u63A5\u5230\u751F\u4EA7\u63A7\u5236\uFF0CUI \u53EF\u80FD\u51BB\u7ED3\uFF0C\u4E5F\u53EF\u80FD\u65AD\u8A00\u4E86\u6E38\u620F\u5F00\u59CB\u524D\u5C31\u5B58\u5728\u7684\u9759\u6001\u6587\u672C\u3002\u6539\u4E3A\u7B49\u5F85\u5E76\u65AD\u8A00\u53EA\u6709\u73A9\u6CD5\u63A8\u8FDB\u540E\u624D\u51FA\u73B0\u7684\u72B6\u6001\uFF08\u5F00\u59CB\u906E\u7F69\u6D88\u5931\u3001\u6BD4\u5206\u53D8\u5316\u3001\u7ED3\u7B97\u51FA\u73B0\uFF09\uFF1B\u7ECF Canvas/Telemetry \u7B49 DOM \u5916\u6743\u5A01\u72B6\u6001\u89C2\u5BDF\u7684\u6D41\u7A0B\u4F20 { allowStaticDom: true }\u3002" : missingStep ? "\u8BE5\u6D41\u7A0B\u662F\u65F6\u95F4\u6216\u5E27\u9A71\u52A8\u7684\uFF0C\u4F46 stepUntil \u6CA1\u6709\u63A8\u8FDB\u6E38\u620F\u65F6\u95F4\uFF1B\u4E3A\u751F\u4EA7\u6E38\u620F\u6CE8\u5165 devkit \u7684 GameClock\uFF0C\u5E76\u4F20\u5165 step: () => clock.stepFrame()\u3002\u4E0D\u8981\u7528\u771F\u5B9E setTimeout\u3002" : timedOut ? "\u771F\u5B9E\u8F93\u5165\u5DF2\u6267\u884C\uFF0C\u4F46\u73A9\u6CD5\u6CA1\u6709\u5728\u4E0A\u9650\u5185\u4EA7\u751F\u7ED3\u679C\uFF1B\u68C0\u67E5\u751F\u4EA7\u63A7\u5236\u662F\u5426\u6536\u5230\u8F93\u5165\uFF0C\u518D\u67E5\u770B\u8D85\u65F6\u9519\u8BEF\u4E2D\u7684 Last diagnostics \u5224\u65AD\u662F\u6E38\u620F\u5FAA\u73AF\u3001\u89C4\u5219\u72B6\u6001\u8FD8\u662F UI \u540C\u6B65\u672A\u63A8\u8FDB\u3002" : missingStructuredEvidence ? '\u6309\u987A\u5E8F\u8865\u9F50\u81F3\u5C11\u4E24\u6B21\u7B7E\u5230\uFF1AperformInput("entry") \u540E\u8C03\u7528 checkpoint("entered")\uFF1B\u518D\u7528 performInput("primary") \u6267\u884C\u6838\u5FC3\u64CD\u4F5C\uFF0CstepUntil \u7B49\u5F85\u7ED3\u679C\uFF0C\u7528\u56DE\u8C03\u63D0\u4F9B\u7684 expect \u65AD\u8A00\u540E\u8C03\u7528 checkpoint("progress") \u6216 checkpoint("terminal")\u3002' : "\u4ECE\u751F\u4EA7\u5165\u53E3\u6267\u884C\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u6743\u5A01\u72B6\u6001\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
599
+ next: repairGuidance(cause),
419
600
  failsRun: true
420
601
  };
421
602
  }
@@ -433,7 +614,7 @@ function formatReactPlaythroughReport(report) {
433
614
  const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
434
615
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
435
616
  if (report.waiverReasons?.length) {
436
- lines.push(`REASON: ${report.waiverReasons.join("\uFF1B")}`);
617
+ lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
437
618
  }
438
619
  if (report.next) lines.push(`NEXT: ${report.next}`);
439
620
  return `
@@ -480,7 +661,7 @@ var ReactPlaythroughReporter = class {
480
661
  } else {
481
662
  console.log(output);
482
663
  }
483
- const summary = formatFailureSummary(
664
+ const summary = formatReactFailureSummary(
484
665
  testModules.map((module) => toModuleResult(module, this.projectRoot))
485
666
  );
486
667
  if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
@@ -516,6 +697,9 @@ function resolvePhaser3BrowserEntry(projectRoot) {
516
697
  function defineReactGameVitestConfig(options) {
517
698
  const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
518
699
  return defineConfig({
700
+ // Keep discovery and dependency resolution anchored to the generated app even
701
+ // when an external runner invokes Vitest from a parent workspace directory.
702
+ root: options.projectRoot,
519
703
  resolve: {
520
704
  alias: {
521
705
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/rules/react-test-boundary-plugin.ts
21
+ var react_test_boundary_plugin_exports = {};
22
+ __export(react_test_boundary_plugin_exports, {
23
+ default: () => react_test_boundary_plugin_default
24
+ });
25
+ module.exports = __toCommonJS(react_test_boundary_plugin_exports);
26
+ var PRODUCTION_PLAYTHROUGH = "/tests/production-playthrough.test.tsx";
27
+ function normalizedFilename(filename) {
28
+ return filename.replaceAll("\\", "/");
29
+ }
30
+ function importedName(specifier) {
31
+ const imported = specifier.imported;
32
+ return imported.type === "Identifier" ? imported.name : String(imported.value);
33
+ }
34
+ function internalGameImports(node) {
35
+ if (typeof node.source.value !== "string") return [];
36
+ if (!node.source.value.startsWith("@/game/")) return [];
37
+ if (node.importKind === "type") return [];
38
+ return node.specifiers.flatMap((specifier) => {
39
+ const importKind = specifier.importKind;
40
+ return importKind === "type" ? [] : [specifier.local.name];
41
+ });
42
+ }
43
+ var rule = {
44
+ meta: {
45
+ type: "problem",
46
+ docs: {
47
+ description: "Protect production React game and playthrough boundaries"
48
+ },
49
+ messages: {
50
+ boundExpect: "Use the expect provided by playthroughTest. An imported Vitest expect may belong to a different module instance and cannot provide reliable assertion evidence.",
51
+ providedUser: "Use the user provided by playthroughTest; do not import or create another userEvent instance in the production playthrough.",
52
+ productionTestingImport: "Production source must not import miaoda-game-devkit/react/testing. Inject test clocks and observers through the production App factory boundary.",
53
+ productionEntry: "Render <App /> from the production playthrough. Import Controller and Telemetry helpers when needed, but do not render an internal game component directly."
54
+ },
55
+ schema: []
56
+ },
57
+ create(context) {
58
+ const filename = normalizedFilename(context.filename);
59
+ const isProductionPlaythrough = filename.endsWith(PRODUCTION_PLAYTHROUGH);
60
+ const isProductionSource = filename.includes("/src/");
61
+ const internalGameBindings = /* @__PURE__ */ new Set();
62
+ return {
63
+ ImportDeclaration(node) {
64
+ const source = node.source.value;
65
+ if (typeof source !== "string") return;
66
+ if (isProductionSource && source === "miaoda-game-devkit/react/testing") {
67
+ context.report({ node: node.source, messageId: "productionTestingImport" });
68
+ }
69
+ if (!isProductionPlaythrough) return;
70
+ if (source === "vitest" && node.specifiers.some(
71
+ (specifier) => specifier.type === "ImportSpecifier" && importedName(specifier) === "expect"
72
+ )) {
73
+ context.report({ node: node.source, messageId: "boundExpect" });
74
+ }
75
+ if (source === "@testing-library/user-event") {
76
+ context.report({ node: node.source, messageId: "providedUser" });
77
+ }
78
+ for (const name of internalGameImports(node)) {
79
+ internalGameBindings.add(name);
80
+ }
81
+ },
82
+ JSXOpeningElement(node) {
83
+ if (!isProductionPlaythrough) return;
84
+ const opening = node;
85
+ if (opening.name?.type === "JSXIdentifier" && opening.name.name && internalGameBindings.has(opening.name.name)) {
86
+ context.report({ node, messageId: "productionEntry" });
87
+ }
88
+ }
89
+ };
90
+ }
91
+ };
92
+ var plugin = {
93
+ meta: { name: "react-game-boundaries" },
94
+ rules: { "no-test-bypass": rule }
95
+ };
96
+ var react_test_boundary_plugin_default = plugin;
97
+ module.exports = module.exports.default;
@@ -5,10 +5,12 @@
5
5
  },
6
6
  "jsPlugins": [
7
7
  "./dist/rules/check-image-import-plugin.js",
8
- "./dist/rules/check-style-import-plugin.js"
8
+ "./dist/rules/check-style-import-plugin.js",
9
+ "./dist/rules/react-test-boundary-plugin.js"
9
10
  ],
10
11
  "rules": {
11
12
  "check-image-exists/no-missing-image": "error",
12
- "check-style-exists/no-missing-style": "error"
13
+ "check-style-exists/no-missing-style": "error",
14
+ "react-game-boundaries/no-test-bypass": "error"
13
15
  }
14
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared React and Phaser game lint plus deterministic testing tools for Miaoda games",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",