miaoda-game-devkit 0.2.14 → 0.2.16

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.
@@ -1,8 +1,326 @@
1
1
  // src/react-vitest-config.ts
2
- import { existsSync, readFileSync } from "fs";
3
- import { dirname, resolve } from "path";
2
+ import { existsSync as existsSync2, readFileSync } from "fs";
3
+ import { dirname, resolve as resolve2 } from "path";
4
4
  import { defineConfig } from "vitest/config";
5
5
 
6
+ // src/react/react-playthrough-reporter.ts
7
+ import { existsSync } from "fs";
8
+ import { relative, resolve } from "path";
9
+
10
+ // src/react/react-playthrough.ts
11
+ import { render } from "@testing-library/react";
12
+ import { expect, test } from "vitest";
13
+
14
+ // src/react/react-playthrough-core.ts
15
+ import { act } from "@testing-library/react";
16
+ function normalizePlaythroughWaiverReason(waiverReason) {
17
+ if (waiverReason === void 0) return void 0;
18
+ const reason = waiverReason.trim();
19
+ if (reason.length < 20) {
20
+ throw new Error(
21
+ "playthroughTest.skip reason must contain at least 20 characters."
22
+ );
23
+ }
24
+ return reason;
25
+ }
26
+ async function runBoundedUntil(condition, options = {}) {
27
+ const maxSteps = options.maxSteps ?? 120;
28
+ if (!Number.isSafeInteger(maxSteps) || maxSteps < 0 || maxSteps > 1e4) {
29
+ throw new RangeError(
30
+ "stepUntil maxSteps must be a safe integer between 0 and 10000."
31
+ );
32
+ }
33
+ for (let step = 0; step <= maxSteps; step += 1) {
34
+ if (condition()) return step;
35
+ if (step < maxSteps) {
36
+ await act(async () => {
37
+ await options.step?.(step + 1);
38
+ });
39
+ }
40
+ }
41
+ throw new Error(
42
+ `Playthrough outcome was not reached within ${maxSteps} steps.`
43
+ );
44
+ }
45
+
46
+ // src/react/react-playthrough.ts
47
+ var INPUT_EVENTS = [
48
+ "click",
49
+ "keydown",
50
+ "keyup",
51
+ "pointerdown",
52
+ "pointerup",
53
+ "touchstart",
54
+ "touchend"
55
+ ];
56
+ function createMetadata(waiverReason) {
57
+ return {
58
+ version: 1,
59
+ waiverReason,
60
+ evidence: {
61
+ domInputEvents: 0,
62
+ boundedRuns: 0,
63
+ assertionsAfterOutcome: 0,
64
+ verified: false
65
+ }
66
+ };
67
+ }
68
+ function definePlaythrough(element, run, waiverReason) {
69
+ const reason = normalizePlaythroughWaiverReason(waiverReason);
70
+ const metadata = createMetadata(reason);
71
+ test("production game completes a bounded playthrough", {
72
+ skip: Boolean(reason),
73
+ meta: { reactPlaythrough: metadata }
74
+ }, async () => {
75
+ const evidence = metadata.evidence;
76
+ let assertionsAtOutcome;
77
+ const recordInput = () => {
78
+ evidence.domInputEvents += 1;
79
+ };
80
+ for (const event of INPUT_EVENTS) {
81
+ document.addEventListener(event, recordInput, true);
82
+ }
83
+ try {
84
+ const view = render(element);
85
+ if (view.container.childNodes.length === 0) {
86
+ throw new Error(
87
+ "playthroughTest must render the production game entry."
88
+ );
89
+ }
90
+ await run({
91
+ view,
92
+ async stepUntil(condition, options = {}) {
93
+ const steps = await runBoundedUntil(condition, options);
94
+ if (evidence.domInputEvents === 0) {
95
+ throw new Error(
96
+ "stepUntil reached an outcome before any production DOM input."
97
+ );
98
+ }
99
+ evidence.boundedRuns += 1;
100
+ assertionsAtOutcome = expect.getState().assertionCalls;
101
+ return steps;
102
+ }
103
+ });
104
+ const assertionCalls = expect.getState().assertionCalls;
105
+ evidence.assertionsAfterOutcome = assertionsAtOutcome === void 0 ? 0 : assertionCalls - assertionsAtOutcome;
106
+ if (evidence.domInputEvents === 0) {
107
+ throw new Error(
108
+ "playthroughTest did not observe production DOM input."
109
+ );
110
+ }
111
+ if (evidence.boundedRuns === 0) {
112
+ throw new Error("playthroughTest must complete one bounded stepUntil.");
113
+ }
114
+ if (evidence.assertionsAfterOutcome === 0) {
115
+ throw new Error(
116
+ "Assert an authoritative game outcome after stepUntil returns."
117
+ );
118
+ }
119
+ evidence.verified = true;
120
+ } finally {
121
+ for (const event of INPUT_EVENTS) {
122
+ document.removeEventListener(event, recordInput, true);
123
+ }
124
+ }
125
+ });
126
+ }
127
+ var playthroughTest = Object.assign(
128
+ (element, run) => definePlaythrough(element, run),
129
+ {
130
+ skip: (reason, element, run) => definePlaythrough(element, run, reason)
131
+ }
132
+ );
133
+ function auditReactPlaythroughRun(tests) {
134
+ const declared = tests.filter((candidate) => candidate.metadata);
135
+ const valid = declared.filter(({ state, metadata }) => {
136
+ const evidence = metadata?.evidence;
137
+ return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0;
138
+ });
139
+ const waivers = declared.filter(
140
+ ({ state, metadata }) => state === "skipped" && (metadata?.waiverReason?.trim().length ?? 0) >= 20
141
+ );
142
+ const issues = [];
143
+ if (declared.length === 0) {
144
+ issues.push(
145
+ "\u7F3A\u5C11\u751F\u4EA7\u6E38\u620F\u53EF\u73A9\u6027\u9A8C\u8BC1\uFF1A\u4F7F\u7528 playthroughTest \u6E32\u67D3 <App />\uFF0C\u901A\u8FC7\u771F\u5B9E DOM \u8F93\u5165\u5B8C\u6210\u4E00\u4E2A\u6709\u6B65\u6570\u4E0A\u9650\u7684\u5173\u952E\u6D41\u7A0B\u3002"
146
+ );
147
+ } else {
148
+ for (const candidate of declared) {
149
+ const isValid = valid.includes(candidate);
150
+ const isWaived = waivers.includes(candidate);
151
+ if (isValid || isWaived) continue;
152
+ if (candidate.state === "skipped") {
153
+ issues.push(
154
+ `\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`
155
+ );
156
+ } else if (candidate.state !== "passed") {
157
+ issues.push(`\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u7684\u72B6\u6001\u4E3A ${candidate.state}\u3002`);
158
+ } else {
159
+ issues.push(
160
+ `\u73A9\u6CD5\u9A8C\u8BC1\u201C${candidate.name}\u201D\u6CA1\u6709\u7559\u4E0B\u5B8C\u6574\u7684\u8F93\u5165\u3001\u6709\u9650\u63A8\u8FDB\u548C\u7ED3\u679C\u65AD\u8A00\u8BC1\u636E\u3002`
161
+ );
162
+ }
163
+ }
164
+ }
165
+ if (issues.length > 0) return { passed: false, waived: false, issues };
166
+ if (valid.length > 0) return { passed: true, waived: false, issues: [] };
167
+ if (waivers.length > 0) return { passed: true, waived: true, issues: [] };
168
+ return { passed: false, waived: false, issues };
169
+ }
170
+
171
+ // src/react/react-playthrough-reporter.ts
172
+ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
173
+ function isMetadata(value) {
174
+ if (!value || typeof value !== "object") return false;
175
+ const metadata = value;
176
+ return metadata.version === 1 && Boolean(metadata.evidence);
177
+ }
178
+ function toAuditInput(test2) {
179
+ const metadata = test2.meta().reactPlaythrough;
180
+ return {
181
+ name: test2.fullName,
182
+ state: test2.result().state,
183
+ metadata: isMetadata(metadata) ? metadata : void 0
184
+ };
185
+ }
186
+ function firstLine(value) {
187
+ if (typeof value !== "string") return void 0;
188
+ return value.split("\n").map((line) => line.trim()).find(Boolean);
189
+ }
190
+ function toModuleResult(module, projectRoot) {
191
+ const tests = [...module.children.allTests()];
192
+ const errors = [
193
+ ...module.errors().map((error) => firstLine(error.message)),
194
+ ...tests.flatMap(
195
+ (test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
196
+ )
197
+ ].filter((message) => Boolean(message));
198
+ return {
199
+ file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
200
+ state: module.state(),
201
+ errors,
202
+ tests: tests.map(toAuditInput)
203
+ };
204
+ }
205
+ function assessReactPlaythroughReport(input) {
206
+ const base = { file: input.expectedFile };
207
+ if (!input.expectedFileScheduled) {
208
+ if (input.expectedFileExists) {
209
+ return {
210
+ ...base,
211
+ status: "NOT_CHECKED",
212
+ 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`,
213
+ failsRun: false
214
+ };
215
+ }
216
+ return {
217
+ ...base,
218
+ status: "FAILED",
219
+ cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
220
+ next: "\u521B\u5EFA\u8BE5\u6587\u4EF6\uFF1A\u4ECE <App /> \u89E6\u53D1\u771F\u5B9E DOM \u8F93\u5165\uFF0C\u7528 stepUntil \u6709\u754C\u63A8\u8FDB\u5230\u73A9\u5BB6\u53EF\u89C1\u7ED3\u679C\u6216\u6E38\u620F\u771F\u5B9E\u72B6\u6001\u7684\u53EA\u8BFB snapshot\uFF0C\u518D\u65AD\u8A00\u7ED3\u679C\u3002",
221
+ failsRun: true
222
+ };
223
+ }
224
+ const productionModule = input.modules.find(
225
+ (module) => module.file === input.expectedFile
226
+ );
227
+ const executedProductionFlow = productionModule?.tests.some(
228
+ (test2) => test2.state !== "skipped" && test2.state !== "pending"
229
+ );
230
+ if (input.focusedSelection && !executedProductionFlow) {
231
+ return {
232
+ ...base,
233
+ status: "NOT_CHECKED",
234
+ 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`,
235
+ failsRun: false
236
+ };
237
+ }
238
+ if (!productionModule || productionModule.tests.length === 0) {
239
+ return {
240
+ ...base,
241
+ status: "NOT_RUN",
242
+ cause: productionModule?.errors[0] ?? input.unhandledErrors?.[0] ?? "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u672A\u5B8C\u6210\u6536\u96C6\u6216\u6267\u884C\u3002",
243
+ 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",
244
+ failsRun: true
245
+ };
246
+ }
247
+ const tests = input.modules.flatMap((module) => module.tests);
248
+ const audit = auditReactPlaythroughRun(tests);
249
+ if (!audit.passed) {
250
+ 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";
251
+ const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
252
+ return {
253
+ ...base,
254
+ status: "FAILED",
255
+ cause,
256
+ next: 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\u70B9\u51FB\u6216\u6309\u952E\u662F\u5426\u771F\u7684\u8C03\u7528\u6E38\u620F\u7684\u751F\u4EA7\u63A7\u5236\u65B9\u6CD5\uFF0C\u4EE5\u53CA\u6E38\u620F\u771F\u5B9E\u72B6\u6001\u662F\u5426\u5230\u8FBE\u5E76\u663E\u793A\u7ED3\u679C\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\u771F\u5B9E\u72B6\u6001\u7684\u53EA\u8BFB snapshot\uFF0C\u5E76\u5728\u5176\u540E\u65AD\u8A00\uFF1B\u4E0D\u8981\u76F4\u8FBE\u5185\u90E8\u5173\u5361\u6216\u4FEE\u6539\u73A9\u6CD5\u72B6\u6001\u3002",
257
+ failsRun: true
258
+ };
259
+ }
260
+ if (audit.waived) {
261
+ return {
262
+ ...base,
263
+ status: "WAIVED",
264
+ waiverReasons: tests.map((test2) => test2.metadata?.waiverReason).filter((reason) => Boolean(reason)),
265
+ failsRun: false
266
+ };
267
+ }
268
+ return { ...base, status: "PASS", failsRun: false };
269
+ }
270
+ function formatReactPlaythroughReport(report) {
271
+ const lines = [`REACT_PLAYTHROUGH: ${report.status}`, `FILE: ${report.file}`];
272
+ if (report.cause) lines.push(`CAUSE: ${report.cause}`);
273
+ if (report.waiverReasons?.length) {
274
+ lines.push(`REASON: ${report.waiverReasons.join("\uFF1B")}`);
275
+ }
276
+ if (report.next) lines.push(`NEXT: ${report.next}`);
277
+ return `
278
+ ${lines.join("\n")}`;
279
+ }
280
+ var ReactPlaythroughReporter = class {
281
+ /** 绑定模板根目录,以稳定识别生产可玩性测试而不依赖测试名称。 */
282
+ constructor(projectRoot) {
283
+ this.projectRoot = projectRoot;
284
+ this.expectedModuleId = resolve(projectRoot, this.expectedFile);
285
+ }
286
+ projectRoot;
287
+ expectedFile = PRODUCTION_PLAYTHROUGH_FILE;
288
+ expectedModuleId;
289
+ expectedFileScheduled = false;
290
+ focusedSelection = false;
291
+ /** 记录本轮是否实际选择了生产流程文件,用于区分聚焦运行与门禁失败。 */
292
+ onTestRunStart(specifications) {
293
+ this.expectedFileScheduled = specifications.some(
294
+ (specification) => resolve(specification.moduleId) === this.expectedModuleId
295
+ );
296
+ this.focusedSelection = specifications.some(
297
+ (specification) => Boolean(specification.project.globalConfig.testNamePattern) || Boolean(specification.testNamePattern) || Boolean(specification.testLines?.length)
298
+ );
299
+ }
300
+ /** 测试运行结束后执行项目级主流程门禁,并写入最终退出码。 */
301
+ onTestRunEnd(testModules, unhandledErrors) {
302
+ const report = assessReactPlaythroughReport({
303
+ expectedFile: this.expectedFile,
304
+ expectedFileExists: existsSync(this.expectedModuleId),
305
+ expectedFileScheduled: this.expectedFileScheduled,
306
+ focusedSelection: this.focusedSelection,
307
+ modules: testModules.map(
308
+ (module) => toModuleResult(module, this.projectRoot)
309
+ ),
310
+ unhandledErrors: unhandledErrors.map((error) => firstLine(error.message)).filter((message) => Boolean(message))
311
+ });
312
+ const output = formatReactPlaythroughReport(report);
313
+ if (report.failsRun) {
314
+ console.error(output);
315
+ process.exitCode = 1;
316
+ } else if (report.status === "WAIVED" || report.status === "NOT_CHECKED") {
317
+ console.warn(output);
318
+ } else {
319
+ console.log(output);
320
+ }
321
+ }
322
+ };
323
+
6
324
  // src/testing/vitest-node-args.ts
7
325
  function getJSDOMWorkerExecArgv() {
8
326
  const nodeMajor = Number.parseInt(process.versions.node, 10);
@@ -11,16 +329,16 @@ function getJSDOMWorkerExecArgv() {
11
329
 
12
330
  // src/react-vitest-config.ts
13
331
  function resolvePhaser3BrowserEntry(projectRoot) {
14
- const manifestPath = resolve(projectRoot, "node_modules/phaser/package.json");
15
- if (!existsSync(manifestPath)) return void 0;
332
+ const manifestPath = resolve2(projectRoot, "node_modules/phaser/package.json");
333
+ if (!existsSync2(manifestPath)) return void 0;
16
334
  try {
17
335
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
18
336
  if (!manifest.version?.startsWith("3.")) return void 0;
19
- const browserEntry = resolve(
337
+ const browserEntry = resolve2(
20
338
  dirname(manifestPath),
21
339
  manifest.browser ?? "dist/phaser.js"
22
340
  );
23
- return existsSync(browserEntry) ? browserEntry : void 0;
341
+ return existsSync2(browserEntry) ? browserEntry : void 0;
24
342
  } catch {
25
343
  return void 0;
26
344
  }
@@ -32,7 +350,7 @@ function defineReactGameVitestConfig(options) {
32
350
  alias: {
33
351
  ...phaser3BrowserEntry ? { phaser: phaser3BrowserEntry } : {},
34
352
  ...options.aliases,
35
- "@": resolve(options.projectRoot, "src")
353
+ "@": resolve2(options.projectRoot, "src")
36
354
  }
37
355
  },
38
356
  test: {
@@ -55,7 +373,8 @@ function defineReactGameVitestConfig(options) {
55
373
  sequence: {
56
374
  setupFiles: "list"
57
375
  },
58
- reporters: ["minimal"],
376
+ // minimal 保留业务失败;附加 reporter 负责项目级最低可玩性门禁。
377
+ reporters: ["minimal", new ReactPlaythroughReporter(options.projectRoot)],
59
378
  restoreMocks: true,
60
379
  clearMocks: true,
61
380
  testTimeout: options.testTimeout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "miaoda-game-devkit",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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",
@@ -108,7 +108,7 @@
108
108
  "build"
109
109
  ],
110
110
  "options": {
111
- "command": "vitest run --config dist/lint/contracts.config.mjs",
111
+ "command": "pnpm run test:contracts && pnpm run test:published",
112
112
  "cwd": "packages/game-devkit"
113
113
  }
114
114
  }
@@ -121,7 +121,7 @@
121
121
  "oxc-resolver": "11.24.2",
122
122
  "oxlint": "1.76.0",
123
123
  "tailwindcss": "3.4.19",
124
- "vitest": "^4.1.10"
124
+ "vitest": "4.1.10"
125
125
  },
126
126
  "peerDependencies": {
127
127
  "@testing-library/jest-dom": ">=6 <8",