miaoda-game-devkit 0.2.20 → 0.3.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.
- package/README.md +7 -6
- package/dist/react/index.d.mts +19 -1
- package/dist/react/index.d.ts +19 -1
- package/dist/react/index.js +50 -0
- package/dist/react/index.mjs +49 -0
- package/dist/react/testing.d.mts +15 -7
- package/dist/react/testing.d.ts +15 -7
- package/dist/react/testing.js +29 -34
- package/dist/react/testing.mjs +29 -34
- package/dist/react/vitest-config.js +94 -41
- package/dist/react/vitest-config.mjs +94 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
Vitest 配置和自动收集运行错误的 Testing Library setup,分别从 `miaoda-game-devkit/react`、
|
|
9
9
|
`miaoda-game-devkit/react/testing` 和 `miaoda-game-devkit/react/vitest-config` 导入;
|
|
10
10
|
- React 的 `playthroughTest` 与项目级 reporter 自动验证独立的 entry/primary DOM
|
|
11
|
-
|
|
11
|
+
输入、有界推进、结果断言及至少两次有序 checkpoint 签到;聚焦运行只提示未检查,
|
|
12
12
|
完整运行缺少有效流程时失败;
|
|
13
13
|
|
|
14
14
|
`playthroughTest` 的主流程回调从参数接收 `user`、`performInput`、`checkpoint`、
|
|
15
|
-
`stepUntil` 和绑定当前测试的 `expect`。按 entry
|
|
16
|
-
有界等待、使用回调提供的 `expect`
|
|
17
|
-
progress
|
|
15
|
+
`stepUntil` 和绑定当前测试的 `expect`。按 entry 输入、`checkpoint("entered")`、primary 输入、
|
|
16
|
+
有界等待、使用回调提供的 `expect` 断言权威结果、
|
|
17
|
+
`checkpoint("progress")` 或 `checkpoint("terminal")` 的顺序记录最低证据。每条流程至少需要
|
|
18
|
+
这两次 checkpoint;checkpoint 只负责签到,不携带或验证状态。大型游戏仍只保留一条最短关键流程;
|
|
18
19
|
分支、关卡规则和恢复清理由专门测试覆盖。不要再次调用 `userEvent.setup()`。
|
|
19
20
|
时间、帧或自定义 scheduler 流程必须显式传入确定性 `step`;精确物理时间、暂停恢复
|
|
20
21
|
或调度清理测试可使用 `ManualGameClock`。
|
|
@@ -153,8 +154,8 @@ DOM 到 Phaser 的输入、命中测试和游戏状态变化,不验证 Canvas/
|
|
|
153
154
|
给出直接修复步骤;测试文件无法加载或配置要求的测试尚未建立时,不再继续报告 Scene
|
|
154
155
|
注册等派生问题。先处理第一类错误,再重新运行 `pnpm test`。
|
|
155
156
|
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
Phaser Vitest 配置使用 `minimal` reporter;React Vitest 配置使用无 ANSI 的精简 reporter。
|
|
158
|
+
两者都保留失败定位、coverage 摘要和最终机器标识,同时避免输出完整 DOM 快照、测试列表和逐文件噪声。
|
|
158
159
|
|
|
159
160
|
## 发布顺序
|
|
160
161
|
|
package/dist/react/index.d.mts
CHANGED
|
@@ -10,4 +10,22 @@ interface DisposableGameController {
|
|
|
10
10
|
*/
|
|
11
11
|
declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** 同时提供订阅与权威快照读取的游戏 Controller,是最小可渲染运行时。 */
|
|
14
|
+
interface SubscribableGameController<TState> extends DisposableGameController {
|
|
15
|
+
subscribe(listener: () => void): () => void;
|
|
16
|
+
snapshot(): TState;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
|
|
20
|
+
* useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
|
|
21
|
+
*
|
|
22
|
+
* 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
|
|
23
|
+
* 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
|
|
24
|
+
* 任何报错。检测到这种情况就立刻抛错,并直接告诉你怎么修。
|
|
25
|
+
*/
|
|
26
|
+
declare function useGameController<TController extends SubscribableGameController<unknown>>(createController: () => TController): {
|
|
27
|
+
game: TController;
|
|
28
|
+
state: ReturnType<TController["snapshot"]>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
|
package/dist/react/index.d.ts
CHANGED
|
@@ -10,4 +10,22 @@ interface DisposableGameController {
|
|
|
10
10
|
*/
|
|
11
11
|
declare function useOwnedGameController<TController extends DisposableGameController>(createController: () => TController): TController;
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/** 同时提供订阅与权威快照读取的游戏 Controller,是最小可渲染运行时。 */
|
|
14
|
+
interface SubscribableGameController<TState> extends DisposableGameController {
|
|
15
|
+
subscribe(listener: () => void): () => void;
|
|
16
|
+
snapshot(): TState;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 一次调用接管游戏 Controller 的创建、销毁与状态订阅:
|
|
20
|
+
* useOwnedGameController 的 ownership,加上 useSyncExternalStore 的渲染桥。
|
|
21
|
+
*
|
|
22
|
+
* 附带一道开发期检查:状态内容变了、但 snapshot() 返回的还是原来那个
|
|
23
|
+
* 对象时,React 会认为"什么都没变"而不刷新界面——游戏悄悄卡死,没有
|
|
24
|
+
* 任何报错。检测到这种情况就立刻抛错,并直接告诉你怎么修。
|
|
25
|
+
*/
|
|
26
|
+
declare function useGameController<TController extends SubscribableGameController<unknown>>(createController: () => TController): {
|
|
27
|
+
game: TController;
|
|
28
|
+
state: ReturnType<TController["snapshot"]>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export { type DisposableGameController, type SubscribableGameController, useGameController, useOwnedGameController };
|
package/dist/react/index.js
CHANGED
|
@@ -21,6 +21,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var react_exports = {};
|
|
22
22
|
__export(react_exports, {
|
|
23
23
|
browserGameClock: () => browserGameClock,
|
|
24
|
+
useGameController: () => useGameController,
|
|
24
25
|
useOwnedGameController: () => useOwnedGameController
|
|
25
26
|
});
|
|
26
27
|
module.exports = __toCommonJS(react_exports);
|
|
@@ -50,8 +51,57 @@ function useOwnedGameController(createController) {
|
|
|
50
51
|
}, [controller]);
|
|
51
52
|
return controller;
|
|
52
53
|
}
|
|
54
|
+
|
|
55
|
+
// src/react/use-game-controller.ts
|
|
56
|
+
var import_react2 = require("react");
|
|
57
|
+
function useGameController(createController) {
|
|
58
|
+
const game = useOwnedGameController(createController);
|
|
59
|
+
useSnapshotIntegrityCheck(game);
|
|
60
|
+
const state = (0, import_react2.useSyncExternalStore)(
|
|
61
|
+
game.subscribe,
|
|
62
|
+
game.snapshot,
|
|
63
|
+
game.snapshot
|
|
64
|
+
);
|
|
65
|
+
return { game, state };
|
|
66
|
+
}
|
|
67
|
+
function useSnapshotIntegrityCheck(game) {
|
|
68
|
+
const reported = (0, import_react2.useRef)(false);
|
|
69
|
+
(0, import_react2.useEffect)(() => {
|
|
70
|
+
if (process.env.NODE_ENV === "production") return void 0;
|
|
71
|
+
let previous = game.snapshot();
|
|
72
|
+
let previousJson;
|
|
73
|
+
try {
|
|
74
|
+
previousJson = JSON.stringify(previous);
|
|
75
|
+
} catch {
|
|
76
|
+
previousJson = void 0;
|
|
77
|
+
}
|
|
78
|
+
return game.subscribe(() => {
|
|
79
|
+
if (reported.current) return;
|
|
80
|
+
const next = game.snapshot();
|
|
81
|
+
if (!Object.is(previous, next)) {
|
|
82
|
+
previous = next;
|
|
83
|
+
previousJson = void 0;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
let nextJson;
|
|
87
|
+
try {
|
|
88
|
+
nextJson = JSON.stringify(next);
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (previousJson !== void 0 && nextJson !== previousJson) {
|
|
93
|
+
reported.current = true;
|
|
94
|
+
throw new Error(
|
|
95
|
+
"Game state changed while snapshot() returned the same reference \u2014 React compares snapshots by reference and skips the re-render, so the UI freezes. \u539F\u56E0\uFF1AReact \u9760\u6BD4\u8F83\u5F15\u7528\u5224\u65AD\u72B6\u6001\u53D8\u6CA1\u53D8\uFF0C\u5F15\u7528\u76F8\u540C\u5C31\u5F53\u4F5C\u6CA1\u53D8\u5316\u3002\u4FEE\u590D\uFF1A\u6BCF\u6B21 notify \u524D\u6362\u4E00\u4E2A\u65B0\u5BF9\u8C61\uFF0C\u4F8B\u5982\u5728 controller \u91CC cachedSnapshot = { ...state }\uFF1B\u4E0D\u8981\u628A\u88AB\u539F\u5730\u4FEE\u6539\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u7ED9 React\u3002"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
previousJson = nextJson;
|
|
99
|
+
});
|
|
100
|
+
}, [game]);
|
|
101
|
+
}
|
|
53
102
|
// Annotate the CommonJS export names for ESM import in node:
|
|
54
103
|
0 && (module.exports = {
|
|
55
104
|
browserGameClock,
|
|
105
|
+
useGameController,
|
|
56
106
|
useOwnedGameController
|
|
57
107
|
});
|
package/dist/react/index.mjs
CHANGED
|
@@ -23,7 +23,56 @@ function useOwnedGameController(createController) {
|
|
|
23
23
|
}, [controller]);
|
|
24
24
|
return controller;
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
// src/react/use-game-controller.ts
|
|
28
|
+
import { useEffect as useEffect2, useRef as useRef2, useSyncExternalStore } from "react";
|
|
29
|
+
function useGameController(createController) {
|
|
30
|
+
const game = useOwnedGameController(createController);
|
|
31
|
+
useSnapshotIntegrityCheck(game);
|
|
32
|
+
const state = useSyncExternalStore(
|
|
33
|
+
game.subscribe,
|
|
34
|
+
game.snapshot,
|
|
35
|
+
game.snapshot
|
|
36
|
+
);
|
|
37
|
+
return { game, state };
|
|
38
|
+
}
|
|
39
|
+
function useSnapshotIntegrityCheck(game) {
|
|
40
|
+
const reported = useRef2(false);
|
|
41
|
+
useEffect2(() => {
|
|
42
|
+
if (process.env.NODE_ENV === "production") return void 0;
|
|
43
|
+
let previous = game.snapshot();
|
|
44
|
+
let previousJson;
|
|
45
|
+
try {
|
|
46
|
+
previousJson = JSON.stringify(previous);
|
|
47
|
+
} catch {
|
|
48
|
+
previousJson = void 0;
|
|
49
|
+
}
|
|
50
|
+
return game.subscribe(() => {
|
|
51
|
+
if (reported.current) return;
|
|
52
|
+
const next = game.snapshot();
|
|
53
|
+
if (!Object.is(previous, next)) {
|
|
54
|
+
previous = next;
|
|
55
|
+
previousJson = void 0;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let nextJson;
|
|
59
|
+
try {
|
|
60
|
+
nextJson = JSON.stringify(next);
|
|
61
|
+
} catch {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (previousJson !== void 0 && nextJson !== previousJson) {
|
|
65
|
+
reported.current = true;
|
|
66
|
+
throw new Error(
|
|
67
|
+
"Game state changed while snapshot() returned the same reference \u2014 React compares snapshots by reference and skips the re-render, so the UI freezes. \u539F\u56E0\uFF1AReact \u9760\u6BD4\u8F83\u5F15\u7528\u5224\u65AD\u72B6\u6001\u53D8\u6CA1\u53D8\uFF0C\u5F15\u7528\u76F8\u540C\u5C31\u5F53\u4F5C\u6CA1\u53D8\u5316\u3002\u4FEE\u590D\uFF1A\u6BCF\u6B21 notify \u524D\u6362\u4E00\u4E2A\u65B0\u5BF9\u8C61\uFF0C\u4F8B\u5982\u5728 controller \u91CC cachedSnapshot = { ...state }\uFF1B\u4E0D\u8981\u628A\u88AB\u539F\u5730\u4FEE\u6539\u7684\u5185\u90E8\u5BF9\u8C61\u76F4\u63A5\u7ED9 React\u3002"
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
previousJson = nextJson;
|
|
71
|
+
});
|
|
72
|
+
}, [game]);
|
|
73
|
+
}
|
|
26
74
|
export {
|
|
27
75
|
browserGameClock,
|
|
76
|
+
useGameController,
|
|
28
77
|
useOwnedGameController
|
|
29
78
|
};
|
package/dist/react/testing.d.mts
CHANGED
|
@@ -33,6 +33,16 @@ interface StepUntilOptions {
|
|
|
33
33
|
* The value is sampled only when the bound is exhausted.
|
|
34
34
|
*/
|
|
35
35
|
diagnostics?: () => unknown;
|
|
36
|
+
/**
|
|
37
|
+
* 默认有一道检查:如果等待的结果"不用玩就已经成立"(第 0 步即成立,
|
|
38
|
+
* 且页面文本从进入游戏起一个字都没变),说明这个结果跟游戏过程无关,
|
|
39
|
+
* 判为失败——它能抓住输入没接上、界面卡死、断言了静态标题这类假通过。
|
|
40
|
+
*
|
|
41
|
+
* 例外:Canvas 游戏的结果画在画布上,页面文本本来就不会变。这类游戏
|
|
42
|
+
* 观察的是 Telemetry 等页面外的权威状态,此时置 true 跳过该检查。
|
|
43
|
+
* 结果通过 DOM 呈现的游戏不要传。
|
|
44
|
+
*/
|
|
45
|
+
allowStaticDom?: boolean;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** 单条 React 主流程测试留下的可序列化运行期证据。 */
|
|
@@ -47,8 +57,6 @@ interface ReactPlaythroughEvidence {
|
|
|
47
57
|
boundedRuns: number;
|
|
48
58
|
/** 最后一次 stepUntil 成功后新增的 Vitest 断言数。 */
|
|
49
59
|
assertionsAfterOutcome: number;
|
|
50
|
-
/** 已验证相对 entered snapshot 发生变化的进展或终局 checkpoint 次数。 */
|
|
51
|
-
changedOutcomeCheckpoints: number;
|
|
52
60
|
/** 已记录的 checkpoint 类型,供 reporter 给出精确提示。 */
|
|
53
61
|
checkpoints: ReactPlaythroughCheckpointKind[];
|
|
54
62
|
/** helper 是否已经完成全部证据校验。 */
|
|
@@ -57,7 +65,7 @@ interface ReactPlaythroughEvidence {
|
|
|
57
65
|
/** 通过 Vitest task metadata 从 worker 传递给主线程 reporter 的数据。 */
|
|
58
66
|
interface ReactPlaythroughMetadata {
|
|
59
67
|
/** metadata 结构版本,用于拒绝无法识别的旧数据。 */
|
|
60
|
-
version:
|
|
68
|
+
version: 3;
|
|
61
69
|
/** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
|
|
62
70
|
waiverReason?: string;
|
|
63
71
|
/** 测试执行期间持续更新的客观证据。 */
|
|
@@ -66,7 +74,7 @@ interface ReactPlaythroughMetadata {
|
|
|
66
74
|
|
|
67
75
|
/** 玩家输入在最低可玩流程中的语义阶段。 */
|
|
68
76
|
type ReactPlaythroughInputKind = "entry" | "primary";
|
|
69
|
-
/**
|
|
77
|
+
/** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
|
|
70
78
|
type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
|
|
71
79
|
/** 主流程回调唯一需要学习的测试工具。 */
|
|
72
80
|
interface ReactPlaythroughArguments {
|
|
@@ -82,10 +90,10 @@ interface ReactPlaythroughArguments {
|
|
|
82
90
|
*/
|
|
83
91
|
performInput(kind: ReactPlaythroughInputKind, input: () => void | Promise<void>): Promise<void>;
|
|
84
92
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
93
|
+
* 记录流程签到。先在 entry 后记录 entered,再在 primary、stepUntil 和
|
|
94
|
+
* 结果断言后记录 progress 或 terminal。
|
|
87
95
|
*/
|
|
88
|
-
checkpoint(kind: ReactPlaythroughCheckpointKind
|
|
96
|
+
checkpoint(kind: ReactPlaythroughCheckpointKind): void;
|
|
89
97
|
/**
|
|
90
98
|
* 在硬性步数上限内等待权威结果。
|
|
91
99
|
* 返回后必须再断言该结果,进入中间 active/running 状态不算完成。
|
package/dist/react/testing.d.ts
CHANGED
|
@@ -33,6 +33,16 @@ interface StepUntilOptions {
|
|
|
33
33
|
* The value is sampled only when the bound is exhausted.
|
|
34
34
|
*/
|
|
35
35
|
diagnostics?: () => unknown;
|
|
36
|
+
/**
|
|
37
|
+
* 默认有一道检查:如果等待的结果"不用玩就已经成立"(第 0 步即成立,
|
|
38
|
+
* 且页面文本从进入游戏起一个字都没变),说明这个结果跟游戏过程无关,
|
|
39
|
+
* 判为失败——它能抓住输入没接上、界面卡死、断言了静态标题这类假通过。
|
|
40
|
+
*
|
|
41
|
+
* 例外:Canvas 游戏的结果画在画布上,页面文本本来就不会变。这类游戏
|
|
42
|
+
* 观察的是 Telemetry 等页面外的权威状态,此时置 true 跳过该检查。
|
|
43
|
+
* 结果通过 DOM 呈现的游戏不要传。
|
|
44
|
+
*/
|
|
45
|
+
allowStaticDom?: boolean;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/** 单条 React 主流程测试留下的可序列化运行期证据。 */
|
|
@@ -47,8 +57,6 @@ interface ReactPlaythroughEvidence {
|
|
|
47
57
|
boundedRuns: number;
|
|
48
58
|
/** 最后一次 stepUntil 成功后新增的 Vitest 断言数。 */
|
|
49
59
|
assertionsAfterOutcome: number;
|
|
50
|
-
/** 已验证相对 entered snapshot 发生变化的进展或终局 checkpoint 次数。 */
|
|
51
|
-
changedOutcomeCheckpoints: number;
|
|
52
60
|
/** 已记录的 checkpoint 类型,供 reporter 给出精确提示。 */
|
|
53
61
|
checkpoints: ReactPlaythroughCheckpointKind[];
|
|
54
62
|
/** helper 是否已经完成全部证据校验。 */
|
|
@@ -57,7 +65,7 @@ interface ReactPlaythroughEvidence {
|
|
|
57
65
|
/** 通过 Vitest task metadata 从 worker 传递给主线程 reporter 的数据。 */
|
|
58
66
|
interface ReactPlaythroughMetadata {
|
|
59
67
|
/** metadata 结构版本,用于拒绝无法识别的旧数据。 */
|
|
60
|
-
version:
|
|
68
|
+
version: 3;
|
|
61
69
|
/** 静态、非交互项目跳过主流程验证时必须提供的理由。 */
|
|
62
70
|
waiverReason?: string;
|
|
63
71
|
/** 测试执行期间持续更新的客观证据。 */
|
|
@@ -66,7 +74,7 @@ interface ReactPlaythroughMetadata {
|
|
|
66
74
|
|
|
67
75
|
/** 玩家输入在最低可玩流程中的语义阶段。 */
|
|
68
76
|
type ReactPlaythroughInputKind = "entry" | "primary";
|
|
69
|
-
/**
|
|
77
|
+
/** 可审计流程签到;entered 是入口,progress/terminal 是有效结果。 */
|
|
70
78
|
type ReactPlaythroughCheckpointKind = "entered" | "progress" | "terminal";
|
|
71
79
|
/** 主流程回调唯一需要学习的测试工具。 */
|
|
72
80
|
interface ReactPlaythroughArguments {
|
|
@@ -82,10 +90,10 @@ interface ReactPlaythroughArguments {
|
|
|
82
90
|
*/
|
|
83
91
|
performInput(kind: ReactPlaythroughInputKind, input: () => void | Promise<void>): Promise<void>;
|
|
84
92
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
93
|
+
* 记录流程签到。先在 entry 后记录 entered,再在 primary、stepUntil 和
|
|
94
|
+
* 结果断言后记录 progress 或 terminal。
|
|
87
95
|
*/
|
|
88
|
-
checkpoint(kind: ReactPlaythroughCheckpointKind
|
|
96
|
+
checkpoint(kind: ReactPlaythroughCheckpointKind): void;
|
|
89
97
|
/**
|
|
90
98
|
* 在硬性步数上限内等待权威结果。
|
|
91
99
|
* 返回后必须再断言该结果,进入中间 active/running 状态不算完成。
|
package/dist/react/testing.js
CHANGED
|
@@ -151,20 +151,25 @@ var INPUT_EVENTS = [
|
|
|
151
151
|
"touchstart",
|
|
152
152
|
"touchend"
|
|
153
153
|
];
|
|
154
|
+
var MIN_CHECKPOINTS = 2;
|
|
154
155
|
function describeMissingEvidence(evidence) {
|
|
155
156
|
if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
|
|
156
157
|
if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
|
|
157
158
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
158
159
|
if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
|
|
159
160
|
if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
|
|
160
|
-
if (evidence.
|
|
161
|
-
return
|
|
161
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
162
|
+
return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
|
|
163
|
+
if (!evidence.checkpoints.some(
|
|
164
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
165
|
+
)) {
|
|
166
|
+
return "progress/terminal checkpoint";
|
|
162
167
|
}
|
|
163
168
|
return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
|
|
164
169
|
}
|
|
165
170
|
function createMetadata(waiverReason) {
|
|
166
171
|
return {
|
|
167
|
-
version:
|
|
172
|
+
version: 3,
|
|
168
173
|
waiverReason,
|
|
169
174
|
evidence: {
|
|
170
175
|
domInputEvents: 0,
|
|
@@ -172,23 +177,11 @@ function createMetadata(waiverReason) {
|
|
|
172
177
|
primaryInputs: 0,
|
|
173
178
|
boundedRuns: 0,
|
|
174
179
|
assertionsAfterOutcome: 0,
|
|
175
|
-
changedOutcomeCheckpoints: 0,
|
|
176
180
|
checkpoints: [],
|
|
177
181
|
verified: false
|
|
178
182
|
}
|
|
179
183
|
};
|
|
180
184
|
}
|
|
181
|
-
function snapshotFingerprint(snapshot) {
|
|
182
|
-
try {
|
|
183
|
-
const serialized = JSON.stringify(snapshot);
|
|
184
|
-
if (serialized === void 0) throw new Error("unsupported value");
|
|
185
|
-
return serialized;
|
|
186
|
-
} catch {
|
|
187
|
-
throw new Error(
|
|
188
|
-
"checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
185
|
function definePlaythrough(element, run, waiverReason) {
|
|
193
186
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
194
187
|
const metadata = createMetadata(reason);
|
|
@@ -198,7 +191,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
198
191
|
}, async ({ expect }) => {
|
|
199
192
|
const evidence = metadata.evidence;
|
|
200
193
|
let assertionsAtOutcome;
|
|
201
|
-
let
|
|
194
|
+
let enteredRecorded = false;
|
|
195
|
+
let domTextAtEntered;
|
|
202
196
|
const recordInput = () => {
|
|
203
197
|
evidence.domInputEvents += 1;
|
|
204
198
|
};
|
|
@@ -218,14 +212,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
218
212
|
user,
|
|
219
213
|
expect,
|
|
220
214
|
async performInput(kind, input) {
|
|
221
|
-
if (kind === "entry" &&
|
|
215
|
+
if (kind === "entry" && enteredRecorded) {
|
|
222
216
|
throw new Error(
|
|
223
217
|
'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
|
|
224
218
|
);
|
|
225
219
|
}
|
|
226
|
-
if (kind === "primary" &&
|
|
220
|
+
if (kind === "primary" && !enteredRecorded) {
|
|
227
221
|
throw new Error(
|
|
228
|
-
'Before performInput("primary"), run an entry input and checkpoint("entered"
|
|
222
|
+
'Before performInput("primary"), run an entry input and checkpoint("entered").'
|
|
229
223
|
);
|
|
230
224
|
}
|
|
231
225
|
const inputsBefore = evidence.domInputEvents;
|
|
@@ -238,10 +232,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
238
232
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
239
233
|
else evidence.primaryInputs += 1;
|
|
240
234
|
},
|
|
241
|
-
checkpoint(kind
|
|
242
|
-
const fingerprint = snapshotFingerprint(snapshot);
|
|
235
|
+
checkpoint(kind) {
|
|
243
236
|
if (kind === "entered") {
|
|
244
|
-
if (
|
|
237
|
+
if (enteredRecorded) {
|
|
245
238
|
throw new Error(
|
|
246
239
|
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
247
240
|
);
|
|
@@ -251,7 +244,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
251
244
|
'checkpoint("entered") must follow performInput("entry", ...).'
|
|
252
245
|
);
|
|
253
246
|
}
|
|
254
|
-
|
|
247
|
+
enteredRecorded = true;
|
|
248
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
255
249
|
evidence.checkpoints.push(kind);
|
|
256
250
|
return;
|
|
257
251
|
}
|
|
@@ -267,15 +261,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
267
261
|
}
|
|
268
262
|
if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
|
|
269
263
|
throw new Error(
|
|
270
|
-
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}"
|
|
264
|
+
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
|
|
271
265
|
);
|
|
272
266
|
}
|
|
273
|
-
if (fingerprint === enteredSnapshot) {
|
|
274
|
-
throw new Error(
|
|
275
|
-
`checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
evidence.changedOutcomeCheckpoints += 1;
|
|
279
267
|
evidence.checkpoints.push(kind);
|
|
280
268
|
},
|
|
281
269
|
async stepUntil(condition, options = {}) {
|
|
@@ -285,6 +273,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
285
273
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
286
274
|
);
|
|
287
275
|
}
|
|
276
|
+
if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
'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'
|
|
279
|
+
);
|
|
280
|
+
}
|
|
288
281
|
evidence.boundedRuns += 1;
|
|
289
282
|
assertionsAtOutcome = expect.getState().assertionCalls;
|
|
290
283
|
return steps;
|
|
@@ -310,9 +303,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
310
303
|
"Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
|
|
311
304
|
);
|
|
312
305
|
}
|
|
313
|
-
if (evidence.
|
|
306
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
|
|
307
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
308
|
+
)) {
|
|
314
309
|
throw new Error(
|
|
315
|
-
|
|
310
|
+
`playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
|
|
316
311
|
);
|
|
317
312
|
}
|
|
318
313
|
evidence.verified = true;
|
|
@@ -333,7 +328,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
333
328
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
334
329
|
const valid = declared.filter(({ state, metadata }) => {
|
|
335
330
|
const evidence = metadata?.evidence;
|
|
336
|
-
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.
|
|
331
|
+
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
|
|
337
332
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
338
333
|
);
|
|
339
334
|
});
|
|
@@ -343,7 +338,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
343
338
|
const issues = [];
|
|
344
339
|
if (declared.length === 0) {
|
|
345
340
|
issues.push(
|
|
346
|
-
'\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress
|
|
341
|
+
'\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'
|
|
347
342
|
);
|
|
348
343
|
} else {
|
|
349
344
|
for (const candidate of declared) {
|
package/dist/react/testing.mjs
CHANGED
|
@@ -113,20 +113,25 @@ var INPUT_EVENTS = [
|
|
|
113
113
|
"touchstart",
|
|
114
114
|
"touchend"
|
|
115
115
|
];
|
|
116
|
+
var MIN_CHECKPOINTS = 2;
|
|
116
117
|
function describeMissingEvidence(evidence) {
|
|
117
118
|
if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
|
|
118
119
|
if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
|
|
119
120
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
120
121
|
if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
|
|
121
122
|
if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
|
|
122
|
-
if (evidence.
|
|
123
|
-
return
|
|
123
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
124
|
+
return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
|
|
125
|
+
if (!evidence.checkpoints.some(
|
|
126
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
127
|
+
)) {
|
|
128
|
+
return "progress/terminal checkpoint";
|
|
124
129
|
}
|
|
125
130
|
return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
|
|
126
131
|
}
|
|
127
132
|
function createMetadata(waiverReason) {
|
|
128
133
|
return {
|
|
129
|
-
version:
|
|
134
|
+
version: 3,
|
|
130
135
|
waiverReason,
|
|
131
136
|
evidence: {
|
|
132
137
|
domInputEvents: 0,
|
|
@@ -134,23 +139,11 @@ function createMetadata(waiverReason) {
|
|
|
134
139
|
primaryInputs: 0,
|
|
135
140
|
boundedRuns: 0,
|
|
136
141
|
assertionsAfterOutcome: 0,
|
|
137
|
-
changedOutcomeCheckpoints: 0,
|
|
138
142
|
checkpoints: [],
|
|
139
143
|
verified: false
|
|
140
144
|
}
|
|
141
145
|
};
|
|
142
146
|
}
|
|
143
|
-
function snapshotFingerprint(snapshot) {
|
|
144
|
-
try {
|
|
145
|
-
const serialized = JSON.stringify(snapshot);
|
|
146
|
-
if (serialized === void 0) throw new Error("unsupported value");
|
|
147
|
-
return serialized;
|
|
148
|
-
} catch {
|
|
149
|
-
throw new Error(
|
|
150
|
-
"checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
147
|
function definePlaythrough(element, run, waiverReason) {
|
|
155
148
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
156
149
|
const metadata = createMetadata(reason);
|
|
@@ -160,7 +153,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
160
153
|
}, async ({ expect }) => {
|
|
161
154
|
const evidence = metadata.evidence;
|
|
162
155
|
let assertionsAtOutcome;
|
|
163
|
-
let
|
|
156
|
+
let enteredRecorded = false;
|
|
157
|
+
let domTextAtEntered;
|
|
164
158
|
const recordInput = () => {
|
|
165
159
|
evidence.domInputEvents += 1;
|
|
166
160
|
};
|
|
@@ -180,14 +174,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
180
174
|
user,
|
|
181
175
|
expect,
|
|
182
176
|
async performInput(kind, input) {
|
|
183
|
-
if (kind === "entry" &&
|
|
177
|
+
if (kind === "entry" && enteredRecorded) {
|
|
184
178
|
throw new Error(
|
|
185
179
|
'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
|
|
186
180
|
);
|
|
187
181
|
}
|
|
188
|
-
if (kind === "primary" &&
|
|
182
|
+
if (kind === "primary" && !enteredRecorded) {
|
|
189
183
|
throw new Error(
|
|
190
|
-
'Before performInput("primary"), run an entry input and checkpoint("entered"
|
|
184
|
+
'Before performInput("primary"), run an entry input and checkpoint("entered").'
|
|
191
185
|
);
|
|
192
186
|
}
|
|
193
187
|
const inputsBefore = evidence.domInputEvents;
|
|
@@ -200,10 +194,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
200
194
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
201
195
|
else evidence.primaryInputs += 1;
|
|
202
196
|
},
|
|
203
|
-
checkpoint(kind
|
|
204
|
-
const fingerprint = snapshotFingerprint(snapshot);
|
|
197
|
+
checkpoint(kind) {
|
|
205
198
|
if (kind === "entered") {
|
|
206
|
-
if (
|
|
199
|
+
if (enteredRecorded) {
|
|
207
200
|
throw new Error(
|
|
208
201
|
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
209
202
|
);
|
|
@@ -213,7 +206,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
213
206
|
'checkpoint("entered") must follow performInput("entry", ...).'
|
|
214
207
|
);
|
|
215
208
|
}
|
|
216
|
-
|
|
209
|
+
enteredRecorded = true;
|
|
210
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
217
211
|
evidence.checkpoints.push(kind);
|
|
218
212
|
return;
|
|
219
213
|
}
|
|
@@ -229,15 +223,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
229
223
|
}
|
|
230
224
|
if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
|
|
231
225
|
throw new Error(
|
|
232
|
-
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}"
|
|
226
|
+
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
|
|
233
227
|
);
|
|
234
228
|
}
|
|
235
|
-
if (fingerprint === enteredSnapshot) {
|
|
236
|
-
throw new Error(
|
|
237
|
-
`checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
evidence.changedOutcomeCheckpoints += 1;
|
|
241
229
|
evidence.checkpoints.push(kind);
|
|
242
230
|
},
|
|
243
231
|
async stepUntil(condition, options = {}) {
|
|
@@ -247,6 +235,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
247
235
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
248
236
|
);
|
|
249
237
|
}
|
|
238
|
+
if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
'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'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
250
243
|
evidence.boundedRuns += 1;
|
|
251
244
|
assertionsAtOutcome = expect.getState().assertionCalls;
|
|
252
245
|
return steps;
|
|
@@ -272,9 +265,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
272
265
|
"Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
|
|
273
266
|
);
|
|
274
267
|
}
|
|
275
|
-
if (evidence.
|
|
268
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
|
|
269
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
270
|
+
)) {
|
|
276
271
|
throw new Error(
|
|
277
|
-
|
|
272
|
+
`playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
|
|
278
273
|
);
|
|
279
274
|
}
|
|
280
275
|
evidence.verified = true;
|
|
@@ -295,7 +290,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
295
290
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
296
291
|
const valid = declared.filter(({ state, metadata }) => {
|
|
297
292
|
const evidence = metadata?.evidence;
|
|
298
|
-
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.
|
|
293
|
+
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
|
|
299
294
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
300
295
|
);
|
|
301
296
|
});
|
|
@@ -305,7 +300,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
305
300
|
const issues = [];
|
|
306
301
|
if (declared.length === 0) {
|
|
307
302
|
issues.push(
|
|
308
|
-
'\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress
|
|
303
|
+
'\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'
|
|
309
304
|
);
|
|
310
305
|
} else {
|
|
311
306
|
for (const candidate of declared) {
|
|
@@ -40,6 +40,7 @@ var import_config = require("vitest/config");
|
|
|
40
40
|
// src/react/react-playthrough-reporter.ts
|
|
41
41
|
var import_node_fs = require("fs");
|
|
42
42
|
var import_node_path = require("path");
|
|
43
|
+
var import_node_util = require("util");
|
|
43
44
|
|
|
44
45
|
// src/react/react-playthrough.ts
|
|
45
46
|
var import_react2 = require("@testing-library/react");
|
|
@@ -101,20 +102,25 @@ var INPUT_EVENTS = [
|
|
|
101
102
|
"touchstart",
|
|
102
103
|
"touchend"
|
|
103
104
|
];
|
|
105
|
+
var MIN_CHECKPOINTS = 2;
|
|
104
106
|
function describeMissingEvidence(evidence) {
|
|
105
107
|
if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
|
|
106
108
|
if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
|
|
107
109
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
108
110
|
if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
|
|
109
111
|
if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
|
|
110
|
-
if (evidence.
|
|
111
|
-
return
|
|
112
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
113
|
+
return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
|
|
114
|
+
if (!evidence.checkpoints.some(
|
|
115
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
116
|
+
)) {
|
|
117
|
+
return "progress/terminal checkpoint";
|
|
112
118
|
}
|
|
113
119
|
return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
|
|
114
120
|
}
|
|
115
121
|
function createMetadata(waiverReason) {
|
|
116
122
|
return {
|
|
117
|
-
version:
|
|
123
|
+
version: 3,
|
|
118
124
|
waiverReason,
|
|
119
125
|
evidence: {
|
|
120
126
|
domInputEvents: 0,
|
|
@@ -122,23 +128,11 @@ function createMetadata(waiverReason) {
|
|
|
122
128
|
primaryInputs: 0,
|
|
123
129
|
boundedRuns: 0,
|
|
124
130
|
assertionsAfterOutcome: 0,
|
|
125
|
-
changedOutcomeCheckpoints: 0,
|
|
126
131
|
checkpoints: [],
|
|
127
132
|
verified: false
|
|
128
133
|
}
|
|
129
134
|
};
|
|
130
135
|
}
|
|
131
|
-
function snapshotFingerprint(snapshot) {
|
|
132
|
-
try {
|
|
133
|
-
const serialized = JSON.stringify(snapshot);
|
|
134
|
-
if (serialized === void 0) throw new Error("unsupported value");
|
|
135
|
-
return serialized;
|
|
136
|
-
} catch {
|
|
137
|
-
throw new Error(
|
|
138
|
-
"checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
136
|
function definePlaythrough(element, run, waiverReason) {
|
|
143
137
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
144
138
|
const metadata = createMetadata(reason);
|
|
@@ -148,7 +142,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
148
142
|
}, async ({ expect }) => {
|
|
149
143
|
const evidence = metadata.evidence;
|
|
150
144
|
let assertionsAtOutcome;
|
|
151
|
-
let
|
|
145
|
+
let enteredRecorded = false;
|
|
146
|
+
let domTextAtEntered;
|
|
152
147
|
const recordInput = () => {
|
|
153
148
|
evidence.domInputEvents += 1;
|
|
154
149
|
};
|
|
@@ -168,14 +163,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
168
163
|
user,
|
|
169
164
|
expect,
|
|
170
165
|
async performInput(kind, input) {
|
|
171
|
-
if (kind === "entry" &&
|
|
166
|
+
if (kind === "entry" && enteredRecorded) {
|
|
172
167
|
throw new Error(
|
|
173
168
|
'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
|
|
174
169
|
);
|
|
175
170
|
}
|
|
176
|
-
if (kind === "primary" &&
|
|
171
|
+
if (kind === "primary" && !enteredRecorded) {
|
|
177
172
|
throw new Error(
|
|
178
|
-
'Before performInput("primary"), run an entry input and checkpoint("entered"
|
|
173
|
+
'Before performInput("primary"), run an entry input and checkpoint("entered").'
|
|
179
174
|
);
|
|
180
175
|
}
|
|
181
176
|
const inputsBefore = evidence.domInputEvents;
|
|
@@ -188,10 +183,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
188
183
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
189
184
|
else evidence.primaryInputs += 1;
|
|
190
185
|
},
|
|
191
|
-
checkpoint(kind
|
|
192
|
-
const fingerprint = snapshotFingerprint(snapshot);
|
|
186
|
+
checkpoint(kind) {
|
|
193
187
|
if (kind === "entered") {
|
|
194
|
-
if (
|
|
188
|
+
if (enteredRecorded) {
|
|
195
189
|
throw new Error(
|
|
196
190
|
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
197
191
|
);
|
|
@@ -201,7 +195,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
201
195
|
'checkpoint("entered") must follow performInput("entry", ...).'
|
|
202
196
|
);
|
|
203
197
|
}
|
|
204
|
-
|
|
198
|
+
enteredRecorded = true;
|
|
199
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
205
200
|
evidence.checkpoints.push(kind);
|
|
206
201
|
return;
|
|
207
202
|
}
|
|
@@ -217,15 +212,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
217
212
|
}
|
|
218
213
|
if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
|
|
219
214
|
throw new Error(
|
|
220
|
-
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}"
|
|
215
|
+
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
|
|
221
216
|
);
|
|
222
217
|
}
|
|
223
|
-
if (fingerprint === enteredSnapshot) {
|
|
224
|
-
throw new Error(
|
|
225
|
-
`checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
|
|
226
|
-
);
|
|
227
|
-
}
|
|
228
|
-
evidence.changedOutcomeCheckpoints += 1;
|
|
229
218
|
evidence.checkpoints.push(kind);
|
|
230
219
|
},
|
|
231
220
|
async stepUntil(condition, options = {}) {
|
|
@@ -235,6 +224,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
235
224
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
236
225
|
);
|
|
237
226
|
}
|
|
227
|
+
if (steps === 0 && options.allowStaticDom !== true && domTextAtEntered !== void 0 && (document.body.textContent ?? "") === domTextAtEntered) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
'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'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
238
232
|
evidence.boundedRuns += 1;
|
|
239
233
|
assertionsAtOutcome = expect.getState().assertionCalls;
|
|
240
234
|
return steps;
|
|
@@ -260,9 +254,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
260
254
|
"Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
|
|
261
255
|
);
|
|
262
256
|
}
|
|
263
|
-
if (evidence.
|
|
257
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
|
|
258
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
259
|
+
)) {
|
|
264
260
|
throw new Error(
|
|
265
|
-
|
|
261
|
+
`playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
|
|
266
262
|
);
|
|
267
263
|
}
|
|
268
264
|
evidence.verified = true;
|
|
@@ -283,7 +279,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
283
279
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
284
280
|
const valid = declared.filter(({ state, metadata }) => {
|
|
285
281
|
const evidence = metadata?.evidence;
|
|
286
|
-
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.
|
|
282
|
+
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
|
|
287
283
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
288
284
|
);
|
|
289
285
|
});
|
|
@@ -293,7 +289,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
293
289
|
const issues = [];
|
|
294
290
|
if (declared.length === 0) {
|
|
295
291
|
issues.push(
|
|
296
|
-
'\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress
|
|
292
|
+
'\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'
|
|
297
293
|
);
|
|
298
294
|
} else {
|
|
299
295
|
for (const candidate of declared) {
|
|
@@ -323,7 +319,7 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
|
323
319
|
function isMetadata(value) {
|
|
324
320
|
if (!value || typeof value !== "object") return false;
|
|
325
321
|
const metadata = value;
|
|
326
|
-
return metadata.version ===
|
|
322
|
+
return metadata.version === 3 && Boolean(metadata.evidence);
|
|
327
323
|
}
|
|
328
324
|
function toAuditInput(test2) {
|
|
329
325
|
const metadata = test2.meta().reactPlaythrough;
|
|
@@ -335,7 +331,18 @@ function toAuditInput(test2) {
|
|
|
335
331
|
}
|
|
336
332
|
function firstLine(value) {
|
|
337
333
|
if (typeof value !== "string") return void 0;
|
|
338
|
-
return value.split("\n").map((line) => line.trim()).find(Boolean);
|
|
334
|
+
return (0, import_node_util.stripVTControlCharacters)(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
335
|
+
}
|
|
336
|
+
function failureHint(value) {
|
|
337
|
+
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
338
|
+
(match) => match[1]
|
|
339
|
+
);
|
|
340
|
+
if (names.length === 0) return void 0;
|
|
341
|
+
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
342
|
+
}
|
|
343
|
+
function errorLocation(value) {
|
|
344
|
+
const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
|
|
345
|
+
return match?.[1];
|
|
339
346
|
}
|
|
340
347
|
function toModuleResult(module2, projectRoot) {
|
|
341
348
|
const tests = [...module2.children.allTests()];
|
|
@@ -345,13 +352,48 @@ function toModuleResult(module2, projectRoot) {
|
|
|
345
352
|
(test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
|
|
346
353
|
)
|
|
347
354
|
].filter((message) => Boolean(message));
|
|
355
|
+
const failures = tests.filter((test2) => test2.result().state === "failed").map((test2) => {
|
|
356
|
+
const raw = test2.result().errors?.[0]?.message ?? module2.errors()[0]?.message ?? "Unknown failure";
|
|
357
|
+
return {
|
|
358
|
+
test: test2.fullName,
|
|
359
|
+
cause: firstLine(raw) ?? "Unknown failure",
|
|
360
|
+
location: test2.location ? `${(0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
|
|
361
|
+
hint: failureHint(raw)
|
|
362
|
+
};
|
|
363
|
+
});
|
|
364
|
+
if (failures.length === 0 && tests.length === 0 && module2.errors().length > 0) {
|
|
365
|
+
const raw = module2.errors()[0]?.message ?? "Module failed to load";
|
|
366
|
+
failures.push({
|
|
367
|
+
test: "<collection>",
|
|
368
|
+
cause: firstLine(raw) ?? "Module failed to load",
|
|
369
|
+
location: errorLocation(raw),
|
|
370
|
+
hint: failureHint(raw)
|
|
371
|
+
});
|
|
372
|
+
}
|
|
348
373
|
return {
|
|
349
374
|
file: (0, import_node_path.relative)(projectRoot, module2.moduleId).replaceAll("\\", "/"),
|
|
350
375
|
state: module2.state(),
|
|
351
376
|
errors,
|
|
352
|
-
tests: tests.map(toAuditInput)
|
|
377
|
+
tests: tests.map(toAuditInput),
|
|
378
|
+
failures
|
|
353
379
|
};
|
|
354
380
|
}
|
|
381
|
+
function formatFailureSummary(modules) {
|
|
382
|
+
const failures = modules.flatMap(
|
|
383
|
+
(module2) => (module2.failures ?? []).map((failure) => ({ ...failure, file: module2.file }))
|
|
384
|
+
);
|
|
385
|
+
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
386
|
+
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
387
|
+
for (const [index, failure] of failures.entries()) {
|
|
388
|
+
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
389
|
+
lines.push(`TEST: ${failure.test}`);
|
|
390
|
+
lines.push(`CAUSE: ${failure.cause}`);
|
|
391
|
+
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
392
|
+
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
393
|
+
}
|
|
394
|
+
lines.push("TEST_RESULT: FAIL");
|
|
395
|
+
return lines;
|
|
396
|
+
}
|
|
355
397
|
function assessReactPlaythroughReport(input) {
|
|
356
398
|
const base = { file: input.expectedFile };
|
|
357
399
|
if (!input.expectedFileScheduled) {
|
|
@@ -367,7 +409,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
367
409
|
...base,
|
|
368
410
|
status: "FAILED",
|
|
369
411
|
cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
|
|
370
|
-
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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress
|
|
412
|
+
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',
|
|
371
413
|
failsRun: true
|
|
372
414
|
};
|
|
373
415
|
}
|
|
@@ -400,12 +442,14 @@ function assessReactPlaythroughReport(input) {
|
|
|
400
442
|
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";
|
|
401
443
|
const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
|
|
402
444
|
const missingStep = /No step callback was provided/i.test(cause);
|
|
445
|
+
const staticOutcome = /already true at step 0 and the DOM has not changed/i.test(cause);
|
|
446
|
+
const staleSnapshot = /snapshot\(\) returned the same reference/i.test(cause);
|
|
403
447
|
const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
|
|
404
448
|
return {
|
|
405
449
|
...base,
|
|
406
450
|
status: "FAILED",
|
|
407
451
|
cause,
|
|
408
|
-
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\uFF1AperformInput("entry") \u540E\
|
|
452
|
+
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",
|
|
409
453
|
failsRun: true
|
|
410
454
|
};
|
|
411
455
|
}
|
|
@@ -470,6 +514,14 @@ var ReactPlaythroughReporter = class {
|
|
|
470
514
|
} else {
|
|
471
515
|
console.log(output);
|
|
472
516
|
}
|
|
517
|
+
const summary = formatFailureSummary(
|
|
518
|
+
testModules.map((module2) => toModuleResult(module2, this.projectRoot))
|
|
519
|
+
);
|
|
520
|
+
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
521
|
+
summary[0] = "TEST_RESULT: FAIL";
|
|
522
|
+
}
|
|
523
|
+
console.log(`
|
|
524
|
+
${summary.join("\n")}`);
|
|
473
525
|
}
|
|
474
526
|
};
|
|
475
527
|
|
|
@@ -518,6 +570,7 @@ function defineReactGameVitestConfig(options) {
|
|
|
518
570
|
environmentOptions: {
|
|
519
571
|
jsdom: { url: "http://localhost/", pretendToBeVisual: true }
|
|
520
572
|
},
|
|
573
|
+
includeTaskLocation: true,
|
|
521
574
|
setupFiles: [
|
|
522
575
|
"miaoda-game-devkit/react/vitest-setup",
|
|
523
576
|
...options.additionalSetupFiles ?? []
|
|
@@ -525,8 +578,8 @@ function defineReactGameVitestConfig(options) {
|
|
|
525
578
|
sequence: {
|
|
526
579
|
setupFiles: "list"
|
|
527
580
|
},
|
|
528
|
-
//
|
|
529
|
-
reporters: [
|
|
581
|
+
// 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
|
|
582
|
+
reporters: [new ReactPlaythroughReporter(options.projectRoot)],
|
|
530
583
|
restoreMocks: true,
|
|
531
584
|
clearMocks: true,
|
|
532
585
|
testTimeout: options.testTimeout,
|
|
@@ -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";
|
|
@@ -67,20 +68,25 @@ var INPUT_EVENTS = [
|
|
|
67
68
|
"touchstart",
|
|
68
69
|
"touchend"
|
|
69
70
|
];
|
|
71
|
+
var MIN_CHECKPOINTS = 2;
|
|
70
72
|
function describeMissingEvidence(evidence) {
|
|
71
73
|
if (!evidence || evidence.entryInputs === 0) return "entry \u8F93\u5165";
|
|
72
74
|
if (evidence.primaryInputs === 0) return "primary \u8F93\u5165";
|
|
73
75
|
if (!evidence.checkpoints.includes("entered")) return "entered checkpoint";
|
|
74
76
|
if (evidence.boundedRuns === 0) return "\u6709\u754C stepUntil";
|
|
75
77
|
if (evidence.assertionsAfterOutcome === 0) return "stepUntil \u540E\u7684\u7ED3\u679C\u65AD\u8A00";
|
|
76
|
-
if (evidence.
|
|
77
|
-
return
|
|
78
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS)
|
|
79
|
+
return `\u81F3\u5C11 ${MIN_CHECKPOINTS} \u4E2A checkpoint`;
|
|
80
|
+
if (!evidence.checkpoints.some(
|
|
81
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
82
|
+
)) {
|
|
83
|
+
return "progress/terminal checkpoint";
|
|
78
84
|
}
|
|
79
85
|
return "\u5B8C\u6574\u7684 playthrough \u6821\u9A8C\u6807\u8BB0";
|
|
80
86
|
}
|
|
81
87
|
function createMetadata(waiverReason) {
|
|
82
88
|
return {
|
|
83
|
-
version:
|
|
89
|
+
version: 3,
|
|
84
90
|
waiverReason,
|
|
85
91
|
evidence: {
|
|
86
92
|
domInputEvents: 0,
|
|
@@ -88,23 +94,11 @@ function createMetadata(waiverReason) {
|
|
|
88
94
|
primaryInputs: 0,
|
|
89
95
|
boundedRuns: 0,
|
|
90
96
|
assertionsAfterOutcome: 0,
|
|
91
|
-
changedOutcomeCheckpoints: 0,
|
|
92
97
|
checkpoints: [],
|
|
93
98
|
verified: false
|
|
94
99
|
}
|
|
95
100
|
};
|
|
96
101
|
}
|
|
97
|
-
function snapshotFingerprint(snapshot) {
|
|
98
|
-
try {
|
|
99
|
-
const serialized = JSON.stringify(snapshot);
|
|
100
|
-
if (serialized === void 0) throw new Error("unsupported value");
|
|
101
|
-
return serialized;
|
|
102
|
-
} catch {
|
|
103
|
-
throw new Error(
|
|
104
|
-
"checkpoint snapshot must be JSON-serializable. Pass a read-only Telemetry snapshot or a small visible-state object."
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
102
|
function definePlaythrough(element, run, waiverReason) {
|
|
109
103
|
const reason = normalizePlaythroughWaiverReason(waiverReason);
|
|
110
104
|
const metadata = createMetadata(reason);
|
|
@@ -114,7 +108,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
114
108
|
}, async ({ expect }) => {
|
|
115
109
|
const evidence = metadata.evidence;
|
|
116
110
|
let assertionsAtOutcome;
|
|
117
|
-
let
|
|
111
|
+
let enteredRecorded = false;
|
|
112
|
+
let domTextAtEntered;
|
|
118
113
|
const recordInput = () => {
|
|
119
114
|
evidence.domInputEvents += 1;
|
|
120
115
|
};
|
|
@@ -134,14 +129,14 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
134
129
|
user,
|
|
135
130
|
expect,
|
|
136
131
|
async performInput(kind, input) {
|
|
137
|
-
if (kind === "entry" &&
|
|
132
|
+
if (kind === "entry" && enteredRecorded) {
|
|
138
133
|
throw new Error(
|
|
139
134
|
'performInput("entry") must run before checkpoint("entered"). Group multiple setup actions in the same callback.'
|
|
140
135
|
);
|
|
141
136
|
}
|
|
142
|
-
if (kind === "primary" &&
|
|
137
|
+
if (kind === "primary" && !enteredRecorded) {
|
|
143
138
|
throw new Error(
|
|
144
|
-
'Before performInput("primary"), run an entry input and checkpoint("entered"
|
|
139
|
+
'Before performInput("primary"), run an entry input and checkpoint("entered").'
|
|
145
140
|
);
|
|
146
141
|
}
|
|
147
142
|
const inputsBefore = evidence.domInputEvents;
|
|
@@ -154,10 +149,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
154
149
|
if (kind === "entry") evidence.entryInputs += 1;
|
|
155
150
|
else evidence.primaryInputs += 1;
|
|
156
151
|
},
|
|
157
|
-
checkpoint(kind
|
|
158
|
-
const fingerprint = snapshotFingerprint(snapshot);
|
|
152
|
+
checkpoint(kind) {
|
|
159
153
|
if (kind === "entered") {
|
|
160
|
-
if (
|
|
154
|
+
if (enteredRecorded) {
|
|
161
155
|
throw new Error(
|
|
162
156
|
'checkpoint("entered") may only be recorded once, before the primary input.'
|
|
163
157
|
);
|
|
@@ -167,7 +161,8 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
167
161
|
'checkpoint("entered") must follow performInput("entry", ...).'
|
|
168
162
|
);
|
|
169
163
|
}
|
|
170
|
-
|
|
164
|
+
enteredRecorded = true;
|
|
165
|
+
domTextAtEntered = document.body.textContent ?? "";
|
|
171
166
|
evidence.checkpoints.push(kind);
|
|
172
167
|
return;
|
|
173
168
|
}
|
|
@@ -183,15 +178,9 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
183
178
|
}
|
|
184
179
|
if (assertionsAtOutcome === void 0 || expect.getState().assertionCalls <= assertionsAtOutcome) {
|
|
185
180
|
throw new Error(
|
|
186
|
-
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}"
|
|
181
|
+
`Use the expect provided by playthroughTest to assert the authoritative result after stepUntil, then record checkpoint("${kind}").`
|
|
187
182
|
);
|
|
188
183
|
}
|
|
189
|
-
if (fingerprint === enteredSnapshot) {
|
|
190
|
-
throw new Error(
|
|
191
|
-
`checkpoint("${kind}") matches the entered snapshot. Assert a production state change caused by the primary input.`
|
|
192
|
-
);
|
|
193
|
-
}
|
|
194
|
-
evidence.changedOutcomeCheckpoints += 1;
|
|
195
184
|
evidence.checkpoints.push(kind);
|
|
196
185
|
},
|
|
197
186
|
async stepUntil(condition, options = {}) {
|
|
@@ -201,6 +190,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
201
190
|
'stepUntil must follow performInput("primary", ...). A menu/help click is not gameplay evidence.'
|
|
202
191
|
);
|
|
203
192
|
}
|
|
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'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
204
198
|
evidence.boundedRuns += 1;
|
|
205
199
|
assertionsAtOutcome = expect.getState().assertionCalls;
|
|
206
200
|
return steps;
|
|
@@ -226,9 +220,11 @@ function definePlaythrough(element, run, waiverReason) {
|
|
|
226
220
|
"Use the expect provided by playthroughTest to assert an authoritative game outcome after stepUntil returns."
|
|
227
221
|
);
|
|
228
222
|
}
|
|
229
|
-
if (evidence.
|
|
223
|
+
if (evidence.checkpoints.length < MIN_CHECKPOINTS || !evidence.checkpoints.some(
|
|
224
|
+
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
225
|
+
)) {
|
|
230
226
|
throw new Error(
|
|
231
|
-
|
|
227
|
+
`playthroughTest requires at least ${MIN_CHECKPOINTS} checkpoints: checkpoint("entered") and, after asserting the result, checkpoint("progress") or checkpoint("terminal").`
|
|
232
228
|
);
|
|
233
229
|
}
|
|
234
230
|
evidence.verified = true;
|
|
@@ -249,7 +245,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
249
245
|
const declared = tests.filter((candidate) => candidate.metadata);
|
|
250
246
|
const valid = declared.filter(({ state, metadata }) => {
|
|
251
247
|
const evidence = metadata?.evidence;
|
|
252
|
-
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.
|
|
248
|
+
return state === "passed" && evidence?.verified === true && evidence.domInputEvents > 0 && evidence.entryInputs > 0 && evidence.primaryInputs > 0 && evidence.boundedRuns > 0 && evidence.assertionsAfterOutcome > 0 && evidence.checkpoints.length >= MIN_CHECKPOINTS && evidence.checkpoints.includes("entered") && evidence.checkpoints.some(
|
|
253
249
|
(checkpoint) => checkpoint === "progress" || checkpoint === "terminal"
|
|
254
250
|
);
|
|
255
251
|
});
|
|
@@ -259,7 +255,7 @@ function auditReactPlaythroughRun(tests) {
|
|
|
259
255
|
const issues = [];
|
|
260
256
|
if (declared.length === 0) {
|
|
261
257
|
issues.push(
|
|
262
|
-
'\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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress
|
|
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'
|
|
263
259
|
);
|
|
264
260
|
} else {
|
|
265
261
|
for (const candidate of declared) {
|
|
@@ -289,7 +285,7 @@ var PRODUCTION_PLAYTHROUGH_FILE = "tests/production-playthrough.test.tsx";
|
|
|
289
285
|
function isMetadata(value) {
|
|
290
286
|
if (!value || typeof value !== "object") return false;
|
|
291
287
|
const metadata = value;
|
|
292
|
-
return metadata.version ===
|
|
288
|
+
return metadata.version === 3 && Boolean(metadata.evidence);
|
|
293
289
|
}
|
|
294
290
|
function toAuditInput(test2) {
|
|
295
291
|
const metadata = test2.meta().reactPlaythrough;
|
|
@@ -301,7 +297,18 @@ function toAuditInput(test2) {
|
|
|
301
297
|
}
|
|
302
298
|
function firstLine(value) {
|
|
303
299
|
if (typeof value !== "string") return void 0;
|
|
304
|
-
return value.split("\n").map((line) => line.trim()).find(Boolean);
|
|
300
|
+
return stripVTControlCharacters(value).split("\n").map((line) => line.trim()).find(Boolean);
|
|
301
|
+
}
|
|
302
|
+
function failureHint(value) {
|
|
303
|
+
const names = [...value.matchAll(/button\s*\n\s*Name "([^"]+)"/g)].map(
|
|
304
|
+
(match) => match[1]
|
|
305
|
+
);
|
|
306
|
+
if (names.length === 0) return void 0;
|
|
307
|
+
return `Available button names: ${names.map((name) => JSON.stringify(name)).join(", ")}`;
|
|
308
|
+
}
|
|
309
|
+
function errorLocation(value) {
|
|
310
|
+
const match = value.match(/(?:^|\n)\s*at\s+(.*?\.(?:test|spec)\.[^\n]+:\d+:\d+)/);
|
|
311
|
+
return match?.[1];
|
|
305
312
|
}
|
|
306
313
|
function toModuleResult(module, projectRoot) {
|
|
307
314
|
const tests = [...module.children.allTests()];
|
|
@@ -311,13 +318,48 @@ function toModuleResult(module, projectRoot) {
|
|
|
311
318
|
(test2) => (test2.result().errors ?? []).map((error) => firstLine(error.message))
|
|
312
319
|
)
|
|
313
320
|
].filter((message) => Boolean(message));
|
|
321
|
+
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";
|
|
323
|
+
return {
|
|
324
|
+
test: test2.fullName,
|
|
325
|
+
cause: firstLine(raw) ?? "Unknown failure",
|
|
326
|
+
location: test2.location ? `${relative(projectRoot, module.moduleId).replaceAll("\\", "/")}:${test2.location.line}:${test2.location.column}` : errorLocation(raw),
|
|
327
|
+
hint: failureHint(raw)
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
if (failures.length === 0 && tests.length === 0 && module.errors().length > 0) {
|
|
331
|
+
const raw = module.errors()[0]?.message ?? "Module failed to load";
|
|
332
|
+
failures.push({
|
|
333
|
+
test: "<collection>",
|
|
334
|
+
cause: firstLine(raw) ?? "Module failed to load",
|
|
335
|
+
location: errorLocation(raw),
|
|
336
|
+
hint: failureHint(raw)
|
|
337
|
+
});
|
|
338
|
+
}
|
|
314
339
|
return {
|
|
315
340
|
file: relative(projectRoot, module.moduleId).replaceAll("\\", "/"),
|
|
316
341
|
state: module.state(),
|
|
317
342
|
errors,
|
|
318
|
-
tests: tests.map(toAuditInput)
|
|
343
|
+
tests: tests.map(toAuditInput),
|
|
344
|
+
failures
|
|
319
345
|
};
|
|
320
346
|
}
|
|
347
|
+
function formatFailureSummary(modules) {
|
|
348
|
+
const failures = modules.flatMap(
|
|
349
|
+
(module) => (module.failures ?? []).map((failure) => ({ ...failure, file: module.file }))
|
|
350
|
+
);
|
|
351
|
+
if (failures.length === 0) return ["TEST_RESULT: PASS"];
|
|
352
|
+
const lines = [`FAILED_TESTS: ${failures.length}`];
|
|
353
|
+
for (const [index, failure] of failures.entries()) {
|
|
354
|
+
lines.push(`FAILURE_${index + 1}: ${failure.file}`);
|
|
355
|
+
lines.push(`TEST: ${failure.test}`);
|
|
356
|
+
lines.push(`CAUSE: ${failure.cause}`);
|
|
357
|
+
if (failure.location) lines.push(`AT: ${failure.location}`);
|
|
358
|
+
if (failure.hint) lines.push(`HINT: ${failure.hint}`);
|
|
359
|
+
}
|
|
360
|
+
lines.push("TEST_RESULT: FAIL");
|
|
361
|
+
return lines;
|
|
362
|
+
}
|
|
321
363
|
function assessReactPlaythroughReport(input) {
|
|
322
364
|
const base = { file: input.expectedFile };
|
|
323
365
|
if (!input.expectedFileScheduled) {
|
|
@@ -333,7 +375,7 @@ function assessReactPlaythroughReport(input) {
|
|
|
333
375
|
...base,
|
|
334
376
|
status: "FAILED",
|
|
335
377
|
cause: "\u751F\u4EA7\u53EF\u73A9\u6027\u6D4B\u8BD5\u6587\u4EF6\u4E0D\u5B58\u5728\u3002",
|
|
336
|
-
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\u7ED3\u679C\uFF0C\u5E76\u8BB0\u5F55 progress
|
|
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',
|
|
337
379
|
failsRun: true
|
|
338
380
|
};
|
|
339
381
|
}
|
|
@@ -366,12 +408,14 @@ function assessReactPlaythroughReport(input) {
|
|
|
366
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";
|
|
367
409
|
const timedOut = /outcome was not reached within \d+ steps/i.test(cause);
|
|
368
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);
|
|
369
413
|
const missingStructuredEvidence = /performInput|checkpoint/.test(cause);
|
|
370
414
|
return {
|
|
371
415
|
...base,
|
|
372
416
|
status: "FAILED",
|
|
373
417
|
cause,
|
|
374
|
-
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\uFF1AperformInput("entry") \u540E\
|
|
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",
|
|
375
419
|
failsRun: true
|
|
376
420
|
};
|
|
377
421
|
}
|
|
@@ -436,6 +480,14 @@ var ReactPlaythroughReporter = class {
|
|
|
436
480
|
} else {
|
|
437
481
|
console.log(output);
|
|
438
482
|
}
|
|
483
|
+
const summary = formatFailureSummary(
|
|
484
|
+
testModules.map((module) => toModuleResult(module, this.projectRoot))
|
|
485
|
+
);
|
|
486
|
+
if (report.failsRun && summary[0] === "TEST_RESULT: PASS") {
|
|
487
|
+
summary[0] = "TEST_RESULT: FAIL";
|
|
488
|
+
}
|
|
489
|
+
console.log(`
|
|
490
|
+
${summary.join("\n")}`);
|
|
439
491
|
}
|
|
440
492
|
};
|
|
441
493
|
|
|
@@ -484,6 +536,7 @@ function defineReactGameVitestConfig(options) {
|
|
|
484
536
|
environmentOptions: {
|
|
485
537
|
jsdom: { url: "http://localhost/", pretendToBeVisual: true }
|
|
486
538
|
},
|
|
539
|
+
includeTaskLocation: true,
|
|
487
540
|
setupFiles: [
|
|
488
541
|
"miaoda-game-devkit/react/vitest-setup",
|
|
489
542
|
...options.additionalSetupFiles ?? []
|
|
@@ -491,8 +544,8 @@ function defineReactGameVitestConfig(options) {
|
|
|
491
544
|
sequence: {
|
|
492
545
|
setupFiles: "list"
|
|
493
546
|
},
|
|
494
|
-
//
|
|
495
|
-
reporters: [
|
|
547
|
+
// 单一 reporter 输出无 ANSI 的紧凑失败摘要和项目级可玩性门禁。
|
|
548
|
+
reporters: [new ReactPlaythroughReporter(options.projectRoot)],
|
|
496
549
|
restoreMocks: true,
|
|
497
550
|
clearMocks: true,
|
|
498
551
|
testTimeout: options.testTimeout,
|