@mpcore/test 1.0.0 → 1.2.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 +37 -24
- package/dist/index.d.mts +49 -18
- package/dist/index.mjs +308 -79
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,41 +1,54 @@
|
|
|
1
1
|
# @mpcore/test
|
|
2
2
|
|
|
3
|
-
面向微信小程序编译产物的 runner 无关测试 API。它模拟微信宿主和逻辑 WXML 树,不创建浏览器 DOM。
|
|
3
|
+
面向微信小程序编译产物的 runner 无关测试 API。它模拟微信宿主和逻辑 WXML 树,不创建浏览器 DOM,也不计算 CSS layout。
|
|
4
4
|
|
|
5
5
|
```ts
|
|
6
|
-
|
|
7
|
-
artifact: { projectPath: '/path/to/project' },
|
|
8
|
-
host: createWxMock({
|
|
9
|
-
request: { url: '/api/count', response: { data: { count: 1 } } },
|
|
10
|
-
}),
|
|
11
|
-
})
|
|
6
|
+
import { artifactFromProject, createTestProject } from '@mpcore/test'
|
|
12
7
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
slots: { default: '<text>计数器</text>' },
|
|
8
|
+
const project = createTestProject({
|
|
9
|
+
artifact: artifactFromProject(process.cwd()),
|
|
16
10
|
})
|
|
17
11
|
|
|
18
|
-
|
|
12
|
+
try {
|
|
13
|
+
const result = await project.renderComponent('components/counter/index', {
|
|
14
|
+
properties: { value: 1 },
|
|
15
|
+
slots: { default: '<text>计数器</text>' },
|
|
16
|
+
events: ['change'],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
await result.user.tap(result.screen.getByRole('button', { name: '增加' }))
|
|
20
|
+
expect(result.emitted('change')).toEqual([{ value: 2 }])
|
|
21
|
+
await result.setProperties({ value: 3 })
|
|
22
|
+
await result.setData({ expanded: true })
|
|
23
|
+
await result.unmount()
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await project.close()
|
|
27
|
+
}
|
|
19
28
|
```
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
`renderPage('/pages/index/index')` 挂载页面。组件通过内存宿主页挂载,属性更新复用同一组件实例并保留内部状态;`setData()` 更新组件自身数据。`unmount()` 触发组件卸载而保留测试会话,`close()` 释放整个渲染会话,`project.close()` 关闭项目内全部渲染。
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
查询支持 text、role、test id、attribute、`within()` 及 `get/query/find/getAll/findAll` 变体,并保留 `$` / `$$` 选择器逃生口。`tap/input/change/blur/trigger` 会等待同步更新和 microtask 稳定后刷新逻辑树。保留的 `within()` 查询共享最新快照,并按原 runtime `nodeId` 确定范围;该 ID 缺失时查询报错,不会按相同 test id 重定位。runtime 可能在条件节点恢复等情况下复用同一路径的 `nodeId`,此时保留的节点和作用域会解析到当前快照中该 ID 对应的节点。
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
事件需要显式订阅:`events: ['change']` 从挂载阶段开始收集,`on: { change(detail, event) {} }` 同时收集并回调。挂载后可调用 `result.on('change', listener)`,返回取消订阅函数;省略 listener 时只收集。`off()` 取消对应订阅,已收集的 `emitted()` 历史保留。动态订阅复用组件实例。
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
import { artifactFromProject, createTestProject } from '@mpcore/test'
|
|
36
|
+
子组件可按标签名递归替换:
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
```ts
|
|
39
|
+
const result = await project.renderComponent('components/profile/index', {
|
|
40
|
+
usingComponents: {
|
|
41
|
+
'remote-avatar': {
|
|
42
|
+
properties: { label: '' },
|
|
43
|
+
template: '<text>{{label}}</text><slot />',
|
|
44
|
+
},
|
|
45
|
+
'remote-panel': '/components/local-panel/index',
|
|
46
|
+
},
|
|
32
47
|
})
|
|
48
|
+
```
|
|
33
49
|
|
|
34
|
-
|
|
35
|
-
await user.tap(screen.getByRole('button', { name: '增加' }))
|
|
36
|
-
expect(screen.getByText('2')).toBeDefined()
|
|
50
|
+
替换应用于当前渲染中的整个组件依赖树,不修改磁盘编译产物,也不影响同项目的其他渲染。字符串指向替代组件;声明式替身支持 `template`、属性默认值 `properties` 和初始 `data`,默认模板为 `<slot />`。
|
|
37
51
|
|
|
38
|
-
|
|
39
|
-
```
|
|
52
|
+
`toMiniProgramSnapshot(result.screen)` 将 screen、节点或 `page.snapshot()` 转为稳定语义树,保留标签、公开属性、文本、事件类型和子节点,过滤 runtime ID 与作用域标记,事件 handler 名统一显示为 `[handler]`。`mpcoreSnapshotSerializer` 可注册到支持 Jest serializer 协议的 runner;`@mpcore/vitest` 会自动注册。完整诊断快照仍可通过 `page.snapshot()` 获取。
|
|
40
53
|
|
|
41
|
-
|
|
54
|
+
未匹配的网络 mock 会抛错;未捕获运行时异常和 `console.error` 默认令测试失败。可通过 `diagnostics()` 断言 warning,负向 console 测试可显式设置 `failOnConsoleError: false`。宿主请求可通过 `createWxMock()` 显式配置。
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { HeadlessSession, HeadlessTestingLogicalNode, HeadlessTestingPageHandle, HeadlessTestingPageSnapshot, HeadlessWxActionSheetMockDefinition, HeadlessWxDownloadFileMockDefinition, HeadlessWxModalMockDefinition, HeadlessWxRequestMockDefinition, HeadlessWxUploadFileMockDefinition, PlatformAdapter, RuntimeDiagnosticEntry, wechat } from "@mpcore/simulator";
|
|
2
|
-
//#region src/screen/helpers.d.ts
|
|
3
2
|
type TextMatcher = string | RegExp;
|
|
4
3
|
interface RoleQueryOptions {
|
|
5
4
|
name?: TextMatcher;
|
|
6
5
|
}
|
|
7
|
-
//#endregion
|
|
8
|
-
//#region src/screen/index.d.ts
|
|
9
6
|
export declare class MiniProgramNode {
|
|
10
7
|
private readonly owner;
|
|
11
8
|
readonly nodeId: string;
|
|
@@ -22,12 +19,15 @@ export declare class MiniProgramNode {
|
|
|
22
19
|
get tagName(): string | undefined;
|
|
23
20
|
get textContent(): string;
|
|
24
21
|
getAttribute(name: string): string;
|
|
22
|
+
/** 返回当前节点的稳定语义树,卸载后的节点不能生成快照。 */
|
|
23
|
+
snapshot(): MiniProgramTreeSnapshot;
|
|
25
24
|
}
|
|
26
25
|
export declare class MiniProgramScreen {
|
|
27
26
|
private readonly page;
|
|
28
|
-
private
|
|
27
|
+
private snapshotStore;
|
|
29
28
|
private readonly rootNodeId?;
|
|
30
29
|
constructor(page: HeadlessTestingPageHandle, snapshot: HeadlessTestingPageSnapshot, rootNodeId?: string);
|
|
30
|
+
private get currentSnapshot();
|
|
31
31
|
private get root();
|
|
32
32
|
private allElements;
|
|
33
33
|
private wrap;
|
|
@@ -38,6 +38,8 @@ export declare class MiniProgramScreen {
|
|
|
38
38
|
resolveNode(nodeId: string): HeadlessTestingLogicalNode;
|
|
39
39
|
refresh(): Promise<void>;
|
|
40
40
|
within(node: MiniProgramNode): MiniProgramScreen;
|
|
41
|
+
/** 返回用于断言的稳定语义树,不包含页面数据与内部节点身份。 */
|
|
42
|
+
snapshot(): MiniProgramTreeSnapshot;
|
|
41
43
|
queryAllByText(matcher: TextMatcher): MiniProgramNode[];
|
|
42
44
|
queryByText(matcher: TextMatcher): MiniProgramNode;
|
|
43
45
|
getAllByText(matcher: TextMatcher): MiniProgramNode[];
|
|
@@ -65,8 +67,6 @@ export declare class MiniProgramScreen {
|
|
|
65
67
|
$(selector: string): Promise<import("@mpcore/simulator").HeadlessTestingNodeHandle | null>;
|
|
66
68
|
$$(selector: string): Promise<import("@mpcore/simulator").HeadlessTestingNodeHandle[]>;
|
|
67
69
|
}
|
|
68
|
-
//#endregion
|
|
69
|
-
//#region src/user.d.ts
|
|
70
70
|
export declare class MiniProgramUser {
|
|
71
71
|
private readonly page;
|
|
72
72
|
private readonly screen;
|
|
@@ -80,8 +80,6 @@ export declare class MiniProgramUser {
|
|
|
80
80
|
blur(node: MiniProgramNode, value?: string, event?: Record<string, any>): Promise<void>;
|
|
81
81
|
trigger(node: MiniProgramNode, eventName: string, event?: Record<string, any>): Promise<void>;
|
|
82
82
|
}
|
|
83
|
-
//#endregion
|
|
84
|
-
//#region src/types.d.ts
|
|
85
83
|
export interface MiniProgramArtifact {
|
|
86
84
|
appConfigPath?: string;
|
|
87
85
|
miniprogramRootPath?: string;
|
|
@@ -101,9 +99,20 @@ export interface CreateTestProjectOptions {
|
|
|
101
99
|
platform?: PlatformAdapter;
|
|
102
100
|
}
|
|
103
101
|
export interface RenderComponentOptions {
|
|
104
|
-
|
|
102
|
+
/** 从首次挂载开始收集指定事件,无需提供回调。 */
|
|
103
|
+
events?: string[];
|
|
104
|
+
on?: Record<string, MiniProgramEventListener>;
|
|
105
105
|
properties?: Record<string, unknown>;
|
|
106
106
|
slots?: Record<string, string>;
|
|
107
|
+
/** 递归替换本次渲染的组件别名;字符串路径相对于小程序产物根目录。 */
|
|
108
|
+
usingComponents?: Record<string, string | InlineComponentStub>;
|
|
109
|
+
}
|
|
110
|
+
export type MiniProgramEventListener = (detail: unknown, event: Record<string, any>) => void;
|
|
111
|
+
/** 声明式子组件替身;properties 的值为默认属性值。 */
|
|
112
|
+
export interface InlineComponentStub {
|
|
113
|
+
data?: Record<string, unknown>;
|
|
114
|
+
properties?: Record<string, unknown>;
|
|
115
|
+
template?: string;
|
|
107
116
|
}
|
|
108
117
|
export interface MiniProgramRenderResult {
|
|
109
118
|
close: () => Promise<void>;
|
|
@@ -116,17 +125,25 @@ export interface MiniProgramRenderResult {
|
|
|
116
125
|
export interface MiniProgramEmissionSource {
|
|
117
126
|
emitted: (eventName: string) => unknown[];
|
|
118
127
|
}
|
|
128
|
+
export interface MiniProgramComponentRenderResult extends MiniProgramRenderResult {
|
|
129
|
+
/** 移除当前事件的全部订阅,或移除指定回调的全部订阅。 */
|
|
130
|
+
off: (eventName: string, listener?: MiniProgramEventListener) => void;
|
|
131
|
+
/** 每次调用建立独立订阅;返回值仅取消本次订阅,省略回调时只收集事件。 */
|
|
132
|
+
on: (eventName: string, listener?: MiniProgramEventListener) => () => void;
|
|
133
|
+
/** 更新当前组件实例的数据,并等待渲染完成。 */
|
|
134
|
+
setData: (data: Record<string, unknown>) => Promise<void>;
|
|
135
|
+
/** 通过宿主绑定更新当前组件的属性,并等待渲染完成。 */
|
|
136
|
+
setProperties: (properties: Record<string, unknown>) => Promise<void>;
|
|
137
|
+
/** 移除组件主体并触发真实卸载流程,保留可独立关闭的测试会话。 */
|
|
138
|
+
unmount: () => Promise<void>;
|
|
139
|
+
}
|
|
119
140
|
export interface MiniProgramMatcherResult {
|
|
120
141
|
message: () => string;
|
|
121
142
|
pass: boolean;
|
|
122
143
|
}
|
|
123
144
|
export type MiniProgramNodeLike = MiniProgramNode;
|
|
124
|
-
//#endregion
|
|
125
|
-
//#region src/host.d.ts
|
|
126
145
|
export declare function createWxMock(definition?: WxHostMock): WxHostMock;
|
|
127
146
|
export declare function applyWxMock(session: HeadlessSession, definition?: WxHostMock): void;
|
|
128
|
-
//#endregion
|
|
129
|
-
//#region src/matchers.d.ts
|
|
130
147
|
export declare function toBeInTheMiniProgram(node: MiniProgramNode): MiniProgramMatcherResult;
|
|
131
148
|
export declare function toHaveTextContent(node: MiniProgramNode, expected: string | RegExp): MiniProgramMatcherResult;
|
|
132
149
|
export declare function toHaveEmitted(source: MiniProgramEmissionSource, eventName: string, expected?: unknown): MiniProgramMatcherResult;
|
|
@@ -139,8 +156,6 @@ export declare const mpcoreMatchers: {
|
|
|
139
156
|
toHaveEmitted: typeof toHaveEmitted;
|
|
140
157
|
toHaveTextContent: typeof toHaveTextContent;
|
|
141
158
|
};
|
|
142
|
-
//#endregion
|
|
143
|
-
//#region src/project.d.ts
|
|
144
159
|
export declare class MiniProgramTestProject {
|
|
145
160
|
private readonly options;
|
|
146
161
|
private readonly baseProject;
|
|
@@ -148,12 +163,28 @@ export declare class MiniProgramTestProject {
|
|
|
148
163
|
constructor(options: CreateTestProjectOptions);
|
|
149
164
|
private render;
|
|
150
165
|
renderPage(route: string): Promise<MiniProgramRenderResult>;
|
|
151
|
-
renderComponent(componentPath: string, options?: RenderComponentOptions): Promise<
|
|
166
|
+
renderComponent(componentPath: string, options?: RenderComponentOptions): Promise<MiniProgramComponentRenderResult>;
|
|
152
167
|
close(): Promise<void>;
|
|
153
168
|
}
|
|
154
169
|
export declare function artifactFromProject(projectPath: string): {
|
|
155
170
|
projectPath: string;
|
|
156
171
|
};
|
|
157
172
|
export declare function createTestProject(options: CreateTestProjectOptions): MiniProgramTestProject;
|
|
158
|
-
|
|
159
|
-
|
|
173
|
+
interface MiniProgramElementSnapshot {
|
|
174
|
+
attributes: Record<string, string>;
|
|
175
|
+
children: MiniProgramTreeSnapshot[];
|
|
176
|
+
tag: string;
|
|
177
|
+
}
|
|
178
|
+
interface MiniProgramTextSnapshot {
|
|
179
|
+
text: string;
|
|
180
|
+
}
|
|
181
|
+
type MiniProgramTreeSnapshot = MiniProgramElementSnapshot | MiniProgramTextSnapshot;
|
|
182
|
+
export type MiniProgramSnapshotSource = MiniProgramScreen | MiniProgramNode | HeadlessTestingPageSnapshot;
|
|
183
|
+
/** 将查询对象或完整诊断快照显式转换为适合稳定断言的语义树。 */
|
|
184
|
+
export declare function toMiniProgramSnapshot(source: MiniProgramSnapshotSource): MiniProgramTreeSnapshot;
|
|
185
|
+
/** 通过 Vitest/Jest 的公开 serializer 接口,仅格式化本包的查询对象。 */
|
|
186
|
+
export declare const mpcoreSnapshotSerializer: {
|
|
187
|
+
test(value: unknown): value is MiniProgramScreen | MiniProgramNode;
|
|
188
|
+
serialize(value: MiniProgramScreen | MiniProgramNode): string;
|
|
189
|
+
};
|
|
190
|
+
export { type MiniProgramElementSnapshot, type MiniProgramTextSnapshot, type MiniProgramTreeSnapshot, type RoleQueryOptions, type TextMatcher, wechat };
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { HeadlessTestingPageHandle, createArtifactProject, createHeadlessSession, createMemoryArtifactSource, createOverlayArtifactSource, createProjectDescriptor, loadProject, wechat, wechat as wechat$1 } from "@mpcore/simulator";
|
|
3
|
-
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// src/host.ts
|
|
4
4
|
function asList(value) {
|
|
5
5
|
if (!value) return [];
|
|
6
6
|
return Array.isArray(value) ? value : [value];
|
|
@@ -15,8 +15,7 @@ function applyWxMock(session, definition = {}) {
|
|
|
15
15
|
asList(definition.request).forEach((item) => session.mockRequest(item));
|
|
16
16
|
asList(definition.uploadFile).forEach((item) => session.mockUploadFile(item));
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
//#region src/matchers.ts
|
|
18
|
+
// src/matchers.ts
|
|
20
19
|
function toBeInTheMiniProgram(node) {
|
|
21
20
|
const pass = node?.isConnected === true;
|
|
22
21
|
return {
|
|
@@ -60,8 +59,253 @@ const mpcoreMatchers = {
|
|
|
60
59
|
toHaveEmitted,
|
|
61
60
|
toHaveTextContent
|
|
62
61
|
};
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// src/componentHarness/data.ts
|
|
63
|
+
/** 在测试 API 边界复制 JSON 数据,避免调用方引用进入 runtime。 */
|
|
64
|
+
function serializeComponentData(value, label) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.stringify(value, (_key, item) => {
|
|
67
|
+
if (typeof item === "function" || typeof item === "symbol" || typeof item === "bigint") throw new TypeError("Unsupported non-serializable value.");
|
|
68
|
+
return item;
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw new TypeError(`${label} must contain serializable data.`, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function cloneComponentData(value, label) {
|
|
75
|
+
return JSON.parse(serializeComponentData(value, label));
|
|
76
|
+
}
|
|
77
|
+
// src/overrides/index.ts
|
|
78
|
+
function normalizeComponentPath(componentPath) {
|
|
79
|
+
const relativePath = componentPath.trim().replaceAll("\\", "/").replace(/^\/+/, "");
|
|
80
|
+
const normalized = path.posix.normalize(relativePath.replace(/\.(?:js|json|wxml)$/, ""));
|
|
81
|
+
if (/^[a-z]:/i.test(relativePath) || /^[a-z]:/i.test(normalized) || !normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) throw new TypeError("Component paths must point inside the mini-program artifact root.");
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
/** 在本次渲染的 artifact 读取边界递归覆盖组件依赖。 */
|
|
85
|
+
function createComponentOverrides(project, overrides = {}) {
|
|
86
|
+
const root = project.miniprogramRootPath;
|
|
87
|
+
const replacements = {};
|
|
88
|
+
const entries = [];
|
|
89
|
+
for (const [index, [alias, replacement]] of Object.entries(overrides).entries()) {
|
|
90
|
+
if (typeof replacement === "string") {
|
|
91
|
+
replacements[alias] = `/${normalizeComponentPath(replacement)}`;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const relativePath = `mpcore-test/stubs/stub-${index}/index`;
|
|
95
|
+
const basePath = path.resolve(root, relativePath);
|
|
96
|
+
replacements[alias] = `/${relativePath}`;
|
|
97
|
+
entries.push([`${basePath}.json`, "{\"component\":true}"], [`${basePath}.wxml`, replacement.template ?? "<slot />"], [`${basePath}.js`, `Component(${serializeComponentData({
|
|
98
|
+
data: replacement.data ?? {},
|
|
99
|
+
properties: Object.fromEntries(Object.entries(replacement.properties ?? {}).map(([key, value]) => [key, {
|
|
100
|
+
type: null,
|
|
101
|
+
value
|
|
102
|
+
}]))
|
|
103
|
+
}, "Inline component stubs")})`]);
|
|
104
|
+
}
|
|
105
|
+
const source = createOverlayArtifactSource(createMemoryArtifactSource(entries), project.artifactSource);
|
|
106
|
+
if (Object.keys(replacements).length === 0) return source;
|
|
107
|
+
return {
|
|
108
|
+
has: (filePath) => source.has(filePath),
|
|
109
|
+
readText(filePath) {
|
|
110
|
+
const original = source.readText(filePath);
|
|
111
|
+
const basePath = filePath.replace(/\.json$/, "");
|
|
112
|
+
if (!filePath.endsWith(".json") || !source.has(`${basePath}.js`) || !source.has(`${basePath}.wxml`)) return original;
|
|
113
|
+
const config = JSON.parse(original ?? "{}");
|
|
114
|
+
return JSON.stringify({
|
|
115
|
+
...config,
|
|
116
|
+
usingComponents: {
|
|
117
|
+
...config.usingComponents,
|
|
118
|
+
...replacements
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// src/componentHarness/host.ts
|
|
125
|
+
const COMPONENT_HOST_ROUTE = "mpcore-test/component-host/index";
|
|
126
|
+
const COMPONENT_SUBJECT_SELECTOR = "#mpcore-subject";
|
|
127
|
+
function createSlotMarkup(slots) {
|
|
128
|
+
return Object.entries(slots).map(([name, content]) => {
|
|
129
|
+
if (!/^[a-z_]\w*(?:-\w+)*$/i.test(name)) throw new TypeError("Component slot names must be valid WXML attribute names.");
|
|
130
|
+
return name === "default" ? content : `<block slot="${name}">${content}</block>`;
|
|
131
|
+
}).join("");
|
|
132
|
+
}
|
|
133
|
+
/** 宿主模板读取每轮订阅和属性名称,主体节点位置保持不变。 */
|
|
134
|
+
function createComponentHostProject(project, componentPath, options, events) {
|
|
135
|
+
const root = project.miniprogramRootPath;
|
|
136
|
+
const hostRoot = path.resolve(root, COMPONENT_HOST_ROUTE);
|
|
137
|
+
const templatePath = `${hostRoot}.wxml`;
|
|
138
|
+
const propertyNames = /* @__PURE__ */ new Set();
|
|
139
|
+
const addPropertyNames = (names) => {
|
|
140
|
+
for (const name of names) if (!/^[a-z_]\w*(?:-\w+)*$/i.test(name)) throw new TypeError("Component property names must be valid WXML attribute names.");
|
|
141
|
+
names.forEach((name) => propertyNames.add(name));
|
|
142
|
+
};
|
|
143
|
+
addPropertyNames(Object.keys(options.properties ?? {}));
|
|
144
|
+
const slots = createSlotMarkup(options.slots ?? {});
|
|
145
|
+
const fixed = createMemoryArtifactSource([
|
|
146
|
+
[path.resolve(root, "app.json"), JSON.stringify({ pages: [COMPONENT_HOST_ROUTE] })],
|
|
147
|
+
[path.resolve(root, "app.js"), "App({})\n"],
|
|
148
|
+
[`${hostRoot}.json`, JSON.stringify({ usingComponents: { "mpcore-subject": `/${normalizeComponentPath(componentPath)}` } })],
|
|
149
|
+
[`${hostRoot}.js`, `Page({
|
|
150
|
+
data: { __mpcoreMounted: true, __mpcoreProperties: ${serializeComponentData(options.properties ?? {}, "Component properties").replaceAll("<", "\\u003c")} },
|
|
151
|
+
__mpcoreRecordEvent(event) { globalThis.__mpcoreRecordEvent(event.type, event.detail, event) }
|
|
152
|
+
})\n`]
|
|
153
|
+
]);
|
|
154
|
+
return {
|
|
155
|
+
addPropertyNames,
|
|
156
|
+
project: createProjectDescriptor({
|
|
157
|
+
appConfig: { pages: [COMPONENT_HOST_ROUTE] },
|
|
158
|
+
appConfigPath: path.resolve(root, "app.json"),
|
|
159
|
+
artifactSource: createOverlayArtifactSource({
|
|
160
|
+
has: (filePath) => filePath === templatePath || fixed.has(filePath),
|
|
161
|
+
readText(filePath) {
|
|
162
|
+
if (filePath !== templatePath) return fixed.readText(filePath);
|
|
163
|
+
return `<mpcore-subject wx:if="{{__mpcoreMounted}}" id="mpcore-subject" ${[...propertyNames].map((key) => `${key}='{{__mpcoreProperties[${JSON.stringify(key)}]}}'`).join(" ")} ${events.names().map((name) => `bind:${name}="__mpcoreRecordEvent"`).join(" ")}>${slots}</mpcore-subject>`;
|
|
164
|
+
}
|
|
165
|
+
}, createComponentOverrides(project, options.usingComponents)),
|
|
166
|
+
miniprogramRoot: project.miniprogramRoot,
|
|
167
|
+
miniprogramRootPath: root,
|
|
168
|
+
plugins: project.plugins,
|
|
169
|
+
projectConfigFiles: project.projectConfigFiles,
|
|
170
|
+
projectPath: project.projectPath
|
|
171
|
+
})
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// src/componentHarness/index.ts
|
|
175
|
+
/** 复用宿主会话更新与移除真实组件,不重新构造主体实例。 */
|
|
176
|
+
function createComponentRenderResult(base, session, settle, host, events) {
|
|
177
|
+
let mounted = true;
|
|
178
|
+
let unmounting;
|
|
179
|
+
const assertMounted = () => {
|
|
180
|
+
session.assertActive();
|
|
181
|
+
if (!mounted) throw new Error("The test component has been unmounted.");
|
|
182
|
+
};
|
|
183
|
+
events.setChangeHandler(() => {
|
|
184
|
+
session.renderCurrentPage();
|
|
185
|
+
});
|
|
186
|
+
const refresh = async () => {
|
|
187
|
+
await settle();
|
|
188
|
+
await base.screen.refresh();
|
|
189
|
+
};
|
|
190
|
+
return Object.assign(base, {
|
|
191
|
+
off: events.off,
|
|
192
|
+
on(eventName, listener) {
|
|
193
|
+
assertMounted();
|
|
194
|
+
return events.on(eventName, listener);
|
|
195
|
+
},
|
|
196
|
+
async setData(data) {
|
|
197
|
+
assertMounted();
|
|
198
|
+
const payload = cloneComponentData(data, "Component data");
|
|
199
|
+
const subject = session.selectComponent(COMPONENT_SUBJECT_SELECTOR);
|
|
200
|
+
if (!subject) throw new Error("The test component is no longer mounted.");
|
|
201
|
+
await new Promise((resolve) => {
|
|
202
|
+
subject.setData(payload, resolve);
|
|
203
|
+
});
|
|
204
|
+
await refresh();
|
|
205
|
+
},
|
|
206
|
+
async setProperties(properties) {
|
|
207
|
+
assertMounted();
|
|
208
|
+
const payload = cloneComponentData(properties, "Component properties");
|
|
209
|
+
host.addPropertyNames(Object.keys(payload));
|
|
210
|
+
const previous = await base.page.data("__mpcoreProperties");
|
|
211
|
+
await base.page.setData({ __mpcoreProperties: {
|
|
212
|
+
...previous,
|
|
213
|
+
...payload
|
|
214
|
+
} });
|
|
215
|
+
await refresh();
|
|
216
|
+
},
|
|
217
|
+
async unmount() {
|
|
218
|
+
if (unmounting) return await unmounting;
|
|
219
|
+
session.assertActive();
|
|
220
|
+
mounted = false;
|
|
221
|
+
unmounting = (async () => {
|
|
222
|
+
try {
|
|
223
|
+
await base.page.setData({ __mpcoreMounted: false });
|
|
224
|
+
} finally {
|
|
225
|
+
events.clear();
|
|
226
|
+
}
|
|
227
|
+
await refresh();
|
|
228
|
+
})();
|
|
229
|
+
return await unmounting;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// src/componentHarness/events.ts
|
|
234
|
+
/** 将事件订阅映射到宿主声明绑定,不拦截组件 triggerEvent。 */
|
|
235
|
+
function createComponentEvents(options = {}) {
|
|
236
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
237
|
+
const log = /* @__PURE__ */ new Map();
|
|
238
|
+
let changed = () => {};
|
|
239
|
+
function normalizeEventName(eventName) {
|
|
240
|
+
if (!/^[\w:.-]+$/.test(eventName)) throw new TypeError("Component event names must contain only letters, numbers, underscores, colons, dots or hyphens.");
|
|
241
|
+
return eventName;
|
|
242
|
+
}
|
|
243
|
+
function on(eventName, listener) {
|
|
244
|
+
const name = normalizeEventName(eventName);
|
|
245
|
+
const listeners = subscriptions.get(name) ?? /* @__PURE__ */ new Map();
|
|
246
|
+
const token = Symbol(name);
|
|
247
|
+
subscriptions.set(name, listeners);
|
|
248
|
+
listeners.set(token, listener);
|
|
249
|
+
changed();
|
|
250
|
+
let active = true;
|
|
251
|
+
return () => {
|
|
252
|
+
if (active) {
|
|
253
|
+
active = false;
|
|
254
|
+
listeners.delete(token);
|
|
255
|
+
if (listeners.size === 0 && subscriptions.get(name) === listeners) subscriptions.delete(name);
|
|
256
|
+
changed();
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function off(eventName, listener) {
|
|
261
|
+
if (listener === void 0) subscriptions.delete(eventName);
|
|
262
|
+
else {
|
|
263
|
+
const listeners = subscriptions.get(eventName);
|
|
264
|
+
for (const [token, registered] of listeners ?? []) if (registered === listener) listeners?.delete(token);
|
|
265
|
+
if (listeners?.size === 0) subscriptions.delete(eventName);
|
|
266
|
+
}
|
|
267
|
+
changed();
|
|
268
|
+
}
|
|
269
|
+
for (const name of options.events ?? []) on(name);
|
|
270
|
+
for (const [name, listener] of Object.entries(options.on ?? {})) on(name, listener);
|
|
271
|
+
return {
|
|
272
|
+
clear() {
|
|
273
|
+
subscriptions.clear();
|
|
274
|
+
changed = () => {};
|
|
275
|
+
},
|
|
276
|
+
emitted: (name) => [...log.get(name) ?? []],
|
|
277
|
+
names: () => [...subscriptions.keys()],
|
|
278
|
+
off,
|
|
279
|
+
on,
|
|
280
|
+
receive(name, detail, event) {
|
|
281
|
+
const listeners = subscriptions.get(name);
|
|
282
|
+
if (!listeners) return;
|
|
283
|
+
const entries = log.get(name) ?? [];
|
|
284
|
+
entries.push(detail);
|
|
285
|
+
log.set(name, entries);
|
|
286
|
+
for (const listener of [...listeners.values()]) listener?.(detail, event);
|
|
287
|
+
},
|
|
288
|
+
setChangeHandler(handler) {
|
|
289
|
+
changed = handler;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// src/snapshot/tree.ts
|
|
294
|
+
const EVENT_ATTRIBUTE = /^(?:(?:capture-)?(?:bind|catch)|mut-bind)(?::[\w:.-]+|\w+)$/;
|
|
295
|
+
const SCOPED_STYLE_ATTRIBUTE = /^data-v-[a-f\d]+$/i;
|
|
296
|
+
function snapshotAttributes(attributes) {
|
|
297
|
+
return Object.fromEntries(Object.entries(attributes).filter(([name]) => !name.startsWith("data-sim-") && !SCOPED_STYLE_ATTRIBUTE.test(name)).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([name, value]) => [name, EVENT_ATTRIBUTE.test(name) && value ? "[handler]" : value]));
|
|
298
|
+
}
|
|
299
|
+
/** 保留逻辑结构、文本和公开属性,仅移除宿主内部标识并隐藏事件方法名。 */
|
|
300
|
+
function createMiniProgramTreeSnapshot(node) {
|
|
301
|
+
if (node.type === "text") return { text: node.text };
|
|
302
|
+
return {
|
|
303
|
+
attributes: snapshotAttributes(node.attrs),
|
|
304
|
+
children: node.children.map(createMiniProgramTreeSnapshot),
|
|
305
|
+
tag: node.tag ?? "root"
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
// src/screen/helpers.ts
|
|
65
309
|
function matchesText(value, matcher) {
|
|
66
310
|
if (typeof matcher === "string") return value === matcher;
|
|
67
311
|
matcher.lastIndex = 0;
|
|
@@ -95,8 +339,7 @@ function findNodeById(root, nodeId) {
|
|
|
95
339
|
if (match) return match;
|
|
96
340
|
}
|
|
97
341
|
}
|
|
98
|
-
|
|
99
|
-
//#region src/screen/index.ts
|
|
342
|
+
// src/screen/node.ts
|
|
100
343
|
var MiniProgramNode = class {
|
|
101
344
|
owner;
|
|
102
345
|
nodeId;
|
|
@@ -128,16 +371,24 @@ var MiniProgramNode = class {
|
|
|
128
371
|
getAttribute(name) {
|
|
129
372
|
return this.current.attrs[name];
|
|
130
373
|
}
|
|
374
|
+
/** 返回当前节点的稳定语义树,卸载后的节点不能生成快照。 */
|
|
375
|
+
snapshot() {
|
|
376
|
+
return createMiniProgramTreeSnapshot(this.current);
|
|
377
|
+
}
|
|
131
378
|
};
|
|
379
|
+
// src/screen/index.ts
|
|
132
380
|
var MiniProgramScreen = class MiniProgramScreen {
|
|
133
381
|
page;
|
|
134
|
-
|
|
382
|
+
snapshotStore;
|
|
135
383
|
rootNodeId;
|
|
136
384
|
constructor(page, snapshot, rootNodeId) {
|
|
137
385
|
this.page = page;
|
|
138
|
-
this.
|
|
386
|
+
this.snapshotStore = { current: snapshot };
|
|
139
387
|
this.rootNodeId = rootNodeId;
|
|
140
388
|
}
|
|
389
|
+
get currentSnapshot() {
|
|
390
|
+
return this.snapshotStore.current;
|
|
391
|
+
}
|
|
141
392
|
get root() {
|
|
142
393
|
this.page.assertActive();
|
|
143
394
|
if (!this.rootNodeId) return this.currentSnapshot.root;
|
|
@@ -177,18 +428,27 @@ var MiniProgramScreen = class MiniProgramScreen {
|
|
|
177
428
|
}
|
|
178
429
|
}
|
|
179
430
|
hasNode(nodeId) {
|
|
431
|
+
this.page.assertActive();
|
|
180
432
|
return Boolean(findNodeById(this.currentSnapshot.root, nodeId));
|
|
181
433
|
}
|
|
182
434
|
resolveNode(nodeId) {
|
|
435
|
+
this.page.assertActive();
|
|
183
436
|
const node = findNodeById(this.currentSnapshot.root, nodeId);
|
|
184
437
|
if (!node) throw new Error("The mini-program node is no longer present in the rendered tree.");
|
|
185
438
|
return node;
|
|
186
439
|
}
|
|
187
440
|
async refresh() {
|
|
188
|
-
this.
|
|
441
|
+
this.snapshotStore.current = await this.page.snapshot();
|
|
189
442
|
}
|
|
190
443
|
within(node) {
|
|
191
|
-
|
|
444
|
+
this.resolveNode(node.nodeId);
|
|
445
|
+
const screen = new MiniProgramScreen(this.page, this.currentSnapshot, node.nodeId);
|
|
446
|
+
screen.snapshotStore = this.snapshotStore;
|
|
447
|
+
return screen;
|
|
448
|
+
}
|
|
449
|
+
/** 返回用于断言的稳定语义树,不包含页面数据与内部节点身份。 */
|
|
450
|
+
snapshot() {
|
|
451
|
+
return createMiniProgramTreeSnapshot(this.root);
|
|
192
452
|
}
|
|
193
453
|
queryAllByText(matcher) {
|
|
194
454
|
const matches = this.allElements().filter((node) => {
|
|
@@ -291,8 +551,7 @@ var MiniProgramScreen = class MiniProgramScreen {
|
|
|
291
551
|
return await this.page.$$(selector);
|
|
292
552
|
}
|
|
293
553
|
};
|
|
294
|
-
|
|
295
|
-
//#region src/user.ts
|
|
554
|
+
// src/user.ts
|
|
296
555
|
var MiniProgramUser = class {
|
|
297
556
|
page;
|
|
298
557
|
screen;
|
|
@@ -336,56 +595,7 @@ var MiniProgramUser = class {
|
|
|
336
595
|
await this.finish();
|
|
337
596
|
}
|
|
338
597
|
};
|
|
339
|
-
|
|
340
|
-
//#region src/project.ts
|
|
341
|
-
const COMPONENT_HOST_ROUTE = "__mpcore__/component-host/index";
|
|
342
|
-
function normalizeComponentPath(componentPath) {
|
|
343
|
-
return componentPath.trim().replace(/^\/+/, "").replace(/\.(?:js|json|wxml)$/, "");
|
|
344
|
-
}
|
|
345
|
-
function serializeProperties(properties) {
|
|
346
|
-
try {
|
|
347
|
-
return JSON.stringify(properties).replaceAll("<", "\\u003c");
|
|
348
|
-
} catch (error) {
|
|
349
|
-
throw new TypeError("Component properties must be serializable.", { cause: error });
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
function createSlotMarkup(slots) {
|
|
353
|
-
return Object.entries(slots).map(([name, content]) => {
|
|
354
|
-
return name === "default" ? content : `<block slot="${name}">${content}</block>`;
|
|
355
|
-
}).join("");
|
|
356
|
-
}
|
|
357
|
-
function createComponentHostProject(project, componentPath, options) {
|
|
358
|
-
const root = project.miniprogramRootPath;
|
|
359
|
-
const eventEntries = Object.keys(options.on ?? {}).map((eventName, index) => ({
|
|
360
|
-
eventName,
|
|
361
|
-
methodName: `__mpcoreEvent${index}`
|
|
362
|
-
}));
|
|
363
|
-
const eventMethods = eventEntries.map(({ eventName, methodName }) => {
|
|
364
|
-
return `${methodName}(event) { globalThis.__mpcoreRecordEvent(${JSON.stringify(eventName)}, event?.detail, event) }`;
|
|
365
|
-
}).join(",\n");
|
|
366
|
-
const propertyBindings = Object.keys(options.properties ?? {}).map((key) => `${key}="{{__mpcoreProperties.${key}}}"`).join(" ");
|
|
367
|
-
const eventBindings = eventEntries.map(({ eventName, methodName }) => `bind:${eventName}="${methodName}"`).join(" ");
|
|
368
|
-
const hostRoot = path.resolve(root, COMPONENT_HOST_ROUTE);
|
|
369
|
-
const overlay = createMemoryArtifactSource([
|
|
370
|
-
[path.resolve(root, "app.json"), JSON.stringify({ pages: [COMPONENT_HOST_ROUTE] })],
|
|
371
|
-
[path.resolve(root, "app.js"), "App({})\n"],
|
|
372
|
-
[`${hostRoot}.json`, JSON.stringify({ usingComponents: { "mpcore-subject": `/${normalizeComponentPath(componentPath)}` } })],
|
|
373
|
-
[`${hostRoot}.js`, `Page({
|
|
374
|
-
data: { __mpcoreProperties: ${serializeProperties(options.properties ?? {})} },
|
|
375
|
-
${eventMethods}
|
|
376
|
-
})\n`],
|
|
377
|
-
[`${hostRoot}.wxml`, `<mpcore-subject id="mpcore-subject" ${propertyBindings} ${eventBindings}>${createSlotMarkup(options.slots ?? {})}</mpcore-subject>`]
|
|
378
|
-
]);
|
|
379
|
-
return createProjectDescriptor({
|
|
380
|
-
appConfig: { pages: [COMPONENT_HOST_ROUTE] },
|
|
381
|
-
appConfigPath: path.resolve(root, "app.json"),
|
|
382
|
-
artifactSource: createOverlayArtifactSource(overlay, project.artifactSource),
|
|
383
|
-
miniprogramRoot: project.miniprogramRoot,
|
|
384
|
-
miniprogramRootPath: root,
|
|
385
|
-
projectConfigFiles: project.projectConfigFiles,
|
|
386
|
-
projectPath: project.projectPath
|
|
387
|
-
});
|
|
388
|
-
}
|
|
598
|
+
// src/project.ts
|
|
389
599
|
function formatDiagnostic(entry) {
|
|
390
600
|
return entry.args.map((arg) => {
|
|
391
601
|
if (arg && typeof arg === "object" && "message" in arg && typeof arg.message === "string") return "stack" in arg && typeof arg.stack === "string" ? arg.stack : arg.message;
|
|
@@ -405,14 +615,10 @@ var MiniProgramTestProject = class {
|
|
|
405
615
|
projectPath: options.artifact.projectPath
|
|
406
616
|
}) : loadProject(options.artifact.projectPath);
|
|
407
617
|
}
|
|
408
|
-
async render(project, route,
|
|
409
|
-
const eventLog = /* @__PURE__ */ new Map();
|
|
618
|
+
async render(project, route, events = createComponentEvents()) {
|
|
410
619
|
const session = createHeadlessSession({
|
|
411
620
|
globals: { __mpcoreRecordEvent(eventName, detail, event) {
|
|
412
|
-
|
|
413
|
-
entries.push(detail);
|
|
414
|
-
eventLog.set(eventName, entries);
|
|
415
|
-
on?.[eventName]?.(detail, event);
|
|
621
|
+
events.receive(eventName, detail, event);
|
|
416
622
|
} },
|
|
417
623
|
project,
|
|
418
624
|
strictHostMocks: true
|
|
@@ -438,26 +644,36 @@ var MiniProgramTestProject = class {
|
|
|
438
644
|
close: async () => {
|
|
439
645
|
if (closed) return;
|
|
440
646
|
closed = true;
|
|
441
|
-
|
|
442
|
-
|
|
647
|
+
try {
|
|
648
|
+
session.close();
|
|
649
|
+
} finally {
|
|
650
|
+
events.clear();
|
|
651
|
+
this.renders.delete(result);
|
|
652
|
+
}
|
|
443
653
|
assertDiagnostics();
|
|
444
654
|
},
|
|
445
655
|
diagnostics: () => session.getDiagnostics(),
|
|
446
|
-
emitted:
|
|
656
|
+
emitted: events.emitted,
|
|
447
657
|
page,
|
|
448
658
|
screen,
|
|
449
659
|
user
|
|
450
660
|
};
|
|
451
661
|
this.renders.add(result);
|
|
452
662
|
await settle();
|
|
453
|
-
return
|
|
663
|
+
return {
|
|
664
|
+
result,
|
|
665
|
+
session,
|
|
666
|
+
settle
|
|
667
|
+
};
|
|
454
668
|
}
|
|
455
669
|
async renderPage(route) {
|
|
456
|
-
return await this.render(this.baseProject, route);
|
|
670
|
+
return (await this.render(this.baseProject, route)).result;
|
|
457
671
|
}
|
|
458
672
|
async renderComponent(componentPath, options = {}) {
|
|
459
|
-
const
|
|
460
|
-
|
|
673
|
+
const events = createComponentEvents(options);
|
|
674
|
+
const host = createComponentHostProject(this.baseProject, componentPath, options, events);
|
|
675
|
+
const { result, session, settle } = await this.render(host.project, `/${COMPONENT_HOST_ROUTE}`, events);
|
|
676
|
+
return createComponentRenderResult(result, session, settle, host, events);
|
|
461
677
|
}
|
|
462
678
|
async close() {
|
|
463
679
|
let firstError;
|
|
@@ -479,5 +695,18 @@ function createTestProject(options) {
|
|
|
479
695
|
platform: options.platform ?? wechat$1()
|
|
480
696
|
});
|
|
481
697
|
}
|
|
482
|
-
|
|
483
|
-
|
|
698
|
+
// src/snapshot/index.ts
|
|
699
|
+
/** 将查询对象或完整诊断快照显式转换为适合稳定断言的语义树。 */
|
|
700
|
+
function toMiniProgramSnapshot(source) {
|
|
701
|
+
return source instanceof MiniProgramScreen || source instanceof MiniProgramNode ? source.snapshot() : createMiniProgramTreeSnapshot(source.root);
|
|
702
|
+
}
|
|
703
|
+
/** 通过 Vitest/Jest 的公开 serializer 接口,仅格式化本包的查询对象。 */
|
|
704
|
+
const mpcoreSnapshotSerializer = {
|
|
705
|
+
test(value) {
|
|
706
|
+
return value instanceof MiniProgramScreen || value instanceof MiniProgramNode;
|
|
707
|
+
},
|
|
708
|
+
serialize(value) {
|
|
709
|
+
return JSON.stringify(toMiniProgramSnapshot(value), null, 2);
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
export { MiniProgramNode, MiniProgramScreen, MiniProgramTestProject, MiniProgramUser, applyWxMock, artifactFromProject, createTestProject, createWxMock, mpcoreMatchers, mpcoreSnapshotSerializer, toBeInTheMiniProgram, toHaveAttribute, toHaveDataset, toHaveEmitted, toHaveTextContent, toMiniProgramSnapshot, wechat };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpcore/test",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"description": "Testing Library-style utilities for compiled WeChat mini-program artifacts",
|
|
6
6
|
"author": "ice breaker <1324318532@qq.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"dist"
|
|
40
40
|
],
|
|
41
41
|
"engines": {
|
|
42
|
-
"node": "^22.
|
|
42
|
+
"node": "^22.23.4 || ^24.22.1 || >=26.12.1"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@mpcore/simulator": "1.
|
|
45
|
+
"@mpcore/simulator": "1.2.0"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public",
|