miaoda-game-devkit 0.2.21 → 0.4.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.
@@ -6,6 +6,7 @@ import { defineConfig } from "vitest/config";
6
6
  // src/react/react-playthrough-reporter.ts
7
7
  import { existsSync } from "fs";
8
8
  import { relative, resolve } from "path";
9
+ import { stripVTControlCharacters } from "util";
9
10
 
10
11
  // src/react/react-playthrough.ts
11
12
  import { render } from "@testing-library/react";
@@ -68,20 +69,50 @@ var INPUT_EVENTS = [
68
69
  "touchend"
69
70
  ];
70
71
  var MIN_CHECKPOINTS = 2;
72
+ var MAX_FORMATTED_OBSERVATION_LENGTH = 500;
73
+ function formatObservation(fingerprint) {
74
+ if (fingerprint.length <= MAX_FORMATTED_OBSERVATION_LENGTH) return fingerprint;
75
+ return `${fingerprint.slice(0, MAX_FORMATTED_OBSERVATION_LENGTH)}\u2026 (${fingerprint.length} chars)`;
76
+ }
77
+ function sampleObservation(observe, stage) {
78
+ let value;
79
+ try {
80
+ value = observe();
81
+ } catch (error) {
82
+ throw new Error(`observe() threw at ${stage}: ${String(error)}`);
83
+ }
84
+ try {
85
+ const fingerprint = JSON.stringify(value);
86
+ if (fingerprint === void 0) throw new Error("unsupported value");
87
+ return { fingerprint, formatted: formatObservation(fingerprint) };
88
+ } catch {
89
+ throw new Error(
90
+ `observe() must return JSON-serializable read-only state; sampling failed at ${stage}.`
91
+ );
92
+ }
93
+ }
94
+ function formatObservationTimeline(entered, afterPrimary, outcome) {
95
+ return [
96
+ `entered=${entered.formatted}`,
97
+ `after-primary=${afterPrimary?.formatted ?? "<not sampled>"}`,
98
+ `outcome=${outcome.formatted}`
99
+ ].join(", ");
100
+ }
71
101
  function describeMissingEvidence(evidence) {
72
- if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
73
- if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
102
+ if (!evidence || evidence.entryInputs === 0) return "an entry input";
103
+ if (evidence.primaryInputs === 0) return "a primary gameplay input";
74
104
  if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
75
- if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
76
- if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
105
+ if (evidence.boundedRuns === 0) return "a bounded stepUntil call";
106
+ if (evidence.assertionsAfterOutcome === 0)
107
+ return "an outcome assertion after stepUntil";
77
108
  if (evidence.checkpoints.length < MIN_CHECKPOINTS)
78
- return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
109
+ return `at least ${MIN_CHECKPOINTS} checkpoints`;
79
110
  if (!evidence.checkpoints.some(
80
111
  (checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
81
112
  )) {
82
113
  return "progress/terminal checkpoint";
83
114
  }
84
- return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
115
+ return "a complete playthrough verification marker";
85
116
  }
86
117
  function createMetadata(waiverReason) {
87
118
  return {
@@ -98,7 +129,7 @@ function createMetadata(waiverReason) {
98
129
  }
99
130
  };
100
131
  }
101
- function definePlaythrough(element, run, waiverReason) {
132
+ function definePlaythrough(element, run, playthroughOptions, waiverReason) {
102
133
  const reason = normalizePlaythroughWaiverReason(waiverReason);
103
134
  const metadata = createMetadata(reason);
104
135
  test("production game completes a bounded playthrough", {
@@ -108,6 +139,9 @@ function definePlaythrough(element, run, waiverReason) {
108
139
  const evidence = metadata.evidence;
109
140
  let assertionsAtOutcome;
110
141
  let enteredRecorded = false;
142
+ let domTextAtEntered;
143
+ let enteredObservation;
144
+ let afterPrimaryObservation;
111
145
  const recordInput = () => {
112
146
  evidence.domInputEvents += 1;
113
147
  };
@@ -145,7 +179,15 @@ function definePlaythrough(element, run, waiverReason) {
145
179
  );
146
180
  }
147
181
  if (kind === "entry") evidence.entryInputs += 1;
148
- else evidence.primaryInputs += 1;
182
+ else {
183
+ evidence.primaryInputs += 1;
184
+ if (playthroughOptions?.observe) {
185
+ afterPrimaryObservation = sampleObservation(
186
+ playthroughOptions.observe,
187
+ "after-primary"
188
+ );
189
+ }
190
+ }
149
191
  },
150
192
  checkpoint(kind) {
151
193
  if (kind === "entered") {
@@ -160,6 +202,14 @@ function definePlaythrough(element, run, waiverReason) {
160
202
  );
161
203
  }
162
204
  enteredRecorded = true;
205
+ if (playthroughOptions?.observe) {
206
+ enteredObservation = sampleObservation(
207
+ playthroughOptions.observe,
208
+ "entered"
209
+ );
210
+ } else {
211
+ domTextAtEntered = document.body.textContent ?? "";
212
+ }
163
213
  evidence.checkpoints.push(kind);
164
214
  return;
165
215
  }
@@ -180,13 +230,37 @@ function definePlaythrough(element, run, waiverReason) {
180
230
  }
181
231
  evidence.checkpoints.push(kind);
182
232
  },
183
- async stepUntil(condition, options = {}) {
184
- const steps = await runBoundedUntil(condition, options);
233
+ async stepUntil(condition, stepOptions = {}) {
234
+ const boundedOptions = stepOptions.diagnostics || !playthroughOptions?.observe ? stepOptions : {
235
+ ...stepOptions,
236
+ diagnostics: playthroughOptions.observe
237
+ };
238
+ const steps = await runBoundedUntil(condition, boundedOptions);
185
239
  if (evidence.primaryInputs === 0) {
186
240
  throw new Error(
187
241
  'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
188
242
  );
189
243
  }
244
+ if (playthroughOptions?.observe) {
245
+ if (!enteredObservation) {
246
+ throw new Error(
247
+ 'observe requires checkpoint("entered") before primary gameplay input.'
248
+ );
249
+ }
250
+ const outcomeObservation = sampleObservation(
251
+ playthroughOptions.observe,
252
+ "outcome"
253
+ );
254
+ if (outcomeObservation.fingerprint === enteredObservation.fingerprint) {
255
+ throw new Error(
256
+ `The authoritative observation did not change from checkpoint("entered") to the outcome. Timeline: ${formatObservationTimeline(enteredObservation, afterPrimaryObservation, outcomeObservation)}`
257
+ );
258
+ }
259
+ } else if (steps === 0 && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
260
+ throw new Error(
261
+ '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.'
262
+ );
263
+ }
190
264
  evidence.boundedRuns += 1;
191
265
  assertionsAtOutcome = expect.getState().assertionCalls;
192
266
  return steps;
@@ -228,9 +302,16 @@ function definePlaythrough(element, run, waiverReason) {
228
302
  });
229
303
  }
230
304
  var playthroughTest = Object.assign(
231
- (element, run) => definePlaythrough(element, run),
305
+ (element, optionsOrRun, maybeRun) => {
306
+ if (typeof optionsOrRun === "function") {
307
+ definePlaythrough(element, optionsOrRun);
308
+ return;
309
+ }
310
+ if (!maybeRun) throw new TypeError("playthroughTest requires a run callback.");
311
+ definePlaythrough(element, maybeRun, optionsOrRun);
312
+ },
232
313
  {
233
- skip: (reason, element, run) => definePlaythrough(element, run, reason)
314
+ skip: (reason, element, run) => definePlaythrough(element, run, void 0, reason)
234
315
  }
235
316
  );
236
317
  function auditReactPlaythroughRun(tests) {
@@ -247,7 +328,7 @@ function auditReactPlaythroughRun(tests) {
247
328
  const issues = [];
248
329
  if (declared.length === 0) {
249
330
  issues.push(
250
- '\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'
331
+ '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").'
251
332
  );
252
333
  } else {
253
334
  for (const candidate of declared) {
@@ -256,13 +337,17 @@ function auditReactPlaythroughRun(tests) {
256
337
  if (isValid || isWaived) continue;
257
338
  if (candidate.state === "skipped") {
258
339
  issues.push(
259
- `\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`
340
+ `Playthrough ${JSON.stringify(candidate.name)} was skipped without an explicit reason of at least 20 characters.`
260
341
  );
261
342
  } else if (candidate.state !== "passed") {
262
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
343
+ issues.push(
344
+ `Playthrough ${JSON.stringify(candidate.name)} finished with state ${candidate.state}.`
345
+ );
263
346
  } else {
264
347
  const missing = describeMissingEvidence(candidate.metadata?.evidence);
265
- issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7F3A\u5C11${missing}\u3002`);
348
+ issues.push(
349
+ `Playthrough ${JSON.stringify(candidate.name)} is missing ${missing}.`
350
+ );
266
351
  }
267
352
  }
268
353
  }
@@ -289,7 +374,18 @@ function toAuditInput(test2) {
289
374
  }
290
375
  function firstLine(value) {
291
376
  if (typeof value !== "string") return void 0;
292
- return value.split("\n").map((line) => line.trim()).find(Boolean);
377
+ return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
378
+ }
379
+ function failureHint(value) {
380
+ const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
381
+ (match) => match[1]
382
+ );
383
+ if (names.length === 0) return void 0;
384
+ return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
385
+ }
386
+ function errorLocation(value) {
387
+ const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
388
+ return match?.[1];
293
389
  }
294
390
  function toModuleResult(module, projectRoot) {
295
391
  const tests = [...module.children.allTests()];
@@ -299,13 +395,71 @@ function toModuleResult(module, projectRoot) {
299
395
  (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
300
396
  )
301
397
  ].filter((message) => Boolean(message));
398
+ const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
399
+ const raw = test2.result().errors?.[0]?.message ?? module.errors()[0]?.message ?? "Unknown failure";
400
+ return {
401
+ test: test2.fullName,
402
+ cause: firstLine(raw) ?? "Unknown failure",
403
+ location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
404
+ hint: failureHint(raw)
405
+ };
406
+ });
407
+ if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
408
+ const raw = module.errors()[0]?.message ?? "Module failed to load";
409
+ failures.push({
410
+ test: "<collection>",
411
+ cause: firstLine(raw) ?? "Module failed to load",
412
+ location: errorLocation(raw),
413
+ hint: failureHint(raw)
414
+ });
415
+ }
302
416
  return {
303
417
  file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
304
418
  state: module.state(),
305
419
  errors,
306
- tests: tests.map(toAuditInput)
420
+ tests: tests.map(toAuditInput),
421
+ failures
307
422
  };
308
423
  }
424
+ function formatFailureSummary(modules) {
425
+ const failures = modules.flatMap(
426
+ (module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
427
+ );
428
+ if (failures.length === 0) return ["TEST_RESULT: PASS"];
429
+ const lines = [`FAILED_TESTS: ${failures.length}`];
430
+ for (const [index, failure] of failures.entries()) {
431
+ lines.push(`FAILURE_${index + 1}: ${failure.file}`);
432
+ lines.push(`TEST: ${failure.test}`);
433
+ lines.push(`CAUSE: ${failure.cause}`);
434
+ if (failure.location) lines.push(`AT: ${failure.location}`);
435
+ if (failure.hint) lines.push(`HINT: ${failure.hint}`);
436
+ }
437
+ lines.push("TEST_RESULT: FAIL");
438
+ return lines;
439
+ }
440
+ function repairGuidance(cause) {
441
+ if (/snapshot\(\) returned the same reference/i.test(cause)) {
442
+ 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.";
443
+ }
444
+ if (/(?:already true|found the outcome) at step 0.*DOM has not changed/i.test(
445
+ cause
446
+ )) {
447
+ 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.';
448
+ }
449
+ if (/authoritative observation did not change/i.test(cause)) {
450
+ 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.";
451
+ }
452
+ if (/No step callback was provided/i.test(cause)) {
453
+ 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.";
454
+ }
455
+ if (/outcome was not reached within \d+ steps/i.test(cause)) {
456
+ 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.";
457
+ }
458
+ if (/performInput|checkpoint/.test(cause)) {
459
+ 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").';
460
+ }
461
+ 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.";
462
+ }
309
463
  function assessReactPlaythroughReport(input) {
310
464
  const base = { file: input.expectedFile };
311
465
  if (!input.expectedFileScheduled) {
@@ -313,15 +467,15 @@ function assessReactPlaythroughReport(input) {
313
467
  return {
314
468
  ...base,
315
469
  status: "NOT_CHECKED",
316
- 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`,
470
+ next: `This focused run did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
317
471
  failsRun: false
318
472
  };
319
473
  }
320
474
  return {
321
475
  ...base,
322
476
  status: "FAILED",
323
- cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
324
- 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',
477
+ cause: "The required production playthrough test file does not exist.",
478
+ 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").',
325
479
  failsRun: true
326
480
  };
327
481
  }
@@ -335,7 +489,7 @@ function assessReactPlaythroughReport(input) {
335
489
  return {
336
490
  ...base,
337
491
  status: "NOT_CHECKED",
338
- 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`,
492
+ next: `The name or line filter did not execute the minimum production playthrough. Run pnpm test before submitting and make sure ${input.expectedFile} passes.`,
339
493
  failsRun: false
340
494
  };
341
495
  }
@@ -343,23 +497,20 @@ function assessReactPlaythroughReport(input) {
343
497
  return {
344
498
  ...base,
345
499
  status: "NOT_RUN",
346
- cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u672A\u5B8C\u6210\u6536\u96C6\u6216\u6267\u884C\u3002",
347
- 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",
500
+ cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "The production playthrough could not be collected or executed.",
501
+ 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.",
348
502
  failsRun: true
349
503
  };
350
504
  }
351
505
  const tests = input.modules.flatMap((module) => module.tests);
352
506
  const audit = auditReactPlaythroughRun(tests);
353
507
  if (!audit.passed) {
354
- 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";
355
- const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
356
- const missingStep = /No step callback was provided/i.test(cause);
357
- const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
508
+ const cause = productionModule.errors[0] ?? input.unhandledErrors?.[0] ?? audit.issues[0] ?? "The production playthrough did not leave complete gameplay evidence.";
358
509
  return {
359
510
  ...base,
360
511
  status: "FAILED",
361
512
  cause,
362
- next: 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",
513
+ next: repairGuidance(cause),
363
514
  failsRun: true
364
515
  };
365
516
  }
@@ -377,7 +528,7 @@ function formatReactPlaythroughReport(report) {
377
528
  const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
378
529
  if (report.cause) lines.push(`CAUSE: ${report.cause}`);
379
530
  if (report.waiverReasons?.length) {
380
- lines.push(`REASON: ${report.waiverReasons.join("\uFF1B")}`);
531
+ lines.push(`REASON: ${report.waiverReasons.join("; ")}`);
381
532
  }
382
533
  if (report.next) lines.push(`NEXT: ${report.next}`);
383
534
  return `
@@ -424,6 +575,14 @@ var ReactPlaythroughReporter = class {
424
575
  } else {
425
576
  console.log(output);
426
577
  }
578
+ const summary = formatFailureSummary(
579
+ testModules.map((module) => toModuleResult(module, this.projectRoot))
580
+ );
581
+ if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
582
+ summary[0] = "TEST_RESULT: FAIL";
583
+ }
584
+ console.log(`
585
+ ${summary.join("\n")}`);
427
586
  }
428
587
  };
429
588
 
@@ -452,6 +611,9 @@ function resolvePhaser3BrowserEntry(projectRoot) {
452
611
  function defineReactGameVitestConfig(options) {
453
612
  const phaser3BrowserEntry = resolvePhaser3BrowserEntry(options.projectRoot);
454
613
  return defineConfig({
614
+ // Keep discovery and dependency resolution anchored to the generated app even
615
+ // when an external runner invokes Vitest from a parent workspace directory.
616
+ root: options.projectRoot,
455
617
  resolve: {
456
618
  alias: {
457
619
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
@@ -472,6 +634,7 @@ function defineReactGameVitestConfig(options) {
472
634
  environmentOptions: {
473
635
  jsdom: { url: "http://localhost/", pretendToBeVisual: true }
474
636
  },
637
+ includeTaskLocation: true,
475
638
  setupFiles: [
476
639
  "miaoda-game-devkit/react/vitest-setup",
477
640
  ...options.additionalSetupFiles ?? []
@@ -479,8 +642,8 @@ function defineReactGameVitestConfig(options) {
479
642
  sequence: {
480
643
  setupFiles: "list"
481
644
  },
482
- // minimal 保留业务失败;附加 reporter 负责项目级最低可玩性门禁。
483
- reporters: ["minimal", new ReactPlaythroughReporter(options.projectRoot)],
645
+ // 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
646
+ reporters: [new ReactPlaythroughReporter(options.projectRoot)],
484
647
  restoreMocks: true,
485
648
  clearMocks: true,
486
649
  testTimeout: options.testTimeout,
@@ -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.2.21",
3
+ "version": "0.4.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",