@mpcore/test 1.0.0 → 1.1.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 +56 -5
- package/dist/index.mjs +311 -68
- package/package.json +2 -2
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
|
@@ -5,7 +5,7 @@ interface RoleQueryOptions {
|
|
|
5
5
|
name?: TextMatcher;
|
|
6
6
|
}
|
|
7
7
|
//#endregion
|
|
8
|
-
//#region src/screen/
|
|
8
|
+
//#region src/screen/node.d.ts
|
|
9
9
|
export declare class MiniProgramNode {
|
|
10
10
|
private readonly owner;
|
|
11
11
|
readonly nodeId: string;
|
|
@@ -22,12 +22,17 @@ export declare class MiniProgramNode {
|
|
|
22
22
|
get tagName(): string | undefined;
|
|
23
23
|
get textContent(): string;
|
|
24
24
|
getAttribute(name: string): string;
|
|
25
|
+
/** 返回当前节点的稳定语义树,卸载后的节点不能生成快照。 */
|
|
26
|
+
snapshot(): MiniProgramTreeSnapshot;
|
|
25
27
|
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/screen/index.d.ts
|
|
26
30
|
export declare class MiniProgramScreen {
|
|
27
31
|
private readonly page;
|
|
28
|
-
private
|
|
32
|
+
private snapshotStore;
|
|
29
33
|
private readonly rootNodeId?;
|
|
30
34
|
constructor(page: HeadlessTestingPageHandle, snapshot: HeadlessTestingPageSnapshot, rootNodeId?: string);
|
|
35
|
+
private get currentSnapshot();
|
|
31
36
|
private get root();
|
|
32
37
|
private allElements;
|
|
33
38
|
private wrap;
|
|
@@ -38,6 +43,8 @@ export declare class MiniProgramScreen {
|
|
|
38
43
|
resolveNode(nodeId: string): HeadlessTestingLogicalNode;
|
|
39
44
|
refresh(): Promise<void>;
|
|
40
45
|
within(node: MiniProgramNode): MiniProgramScreen;
|
|
46
|
+
/** 返回用于断言的稳定语义树,不包含页面数据与内部节点身份。 */
|
|
47
|
+
snapshot(): MiniProgramTreeSnapshot;
|
|
41
48
|
queryAllByText(matcher: TextMatcher): MiniProgramNode[];
|
|
42
49
|
queryByText(matcher: TextMatcher): MiniProgramNode;
|
|
43
50
|
getAllByText(matcher: TextMatcher): MiniProgramNode[];
|
|
@@ -101,9 +108,20 @@ export interface CreateTestProjectOptions {
|
|
|
101
108
|
platform?: PlatformAdapter;
|
|
102
109
|
}
|
|
103
110
|
export interface RenderComponentOptions {
|
|
104
|
-
|
|
111
|
+
/** 从首次挂载开始收集指定事件,无需提供回调。 */
|
|
112
|
+
events?: string[];
|
|
113
|
+
on?: Record<string, MiniProgramEventListener>;
|
|
105
114
|
properties?: Record<string, unknown>;
|
|
106
115
|
slots?: Record<string, string>;
|
|
116
|
+
/** 递归替换本次渲染的组件别名;字符串路径相对于小程序产物根目录。 */
|
|
117
|
+
usingComponents?: Record<string, string | InlineComponentStub>;
|
|
118
|
+
}
|
|
119
|
+
export type MiniProgramEventListener = (detail: unknown, event: Record<string, any>) => void;
|
|
120
|
+
/** 声明式子组件替身;properties 的值为默认属性值。 */
|
|
121
|
+
export interface InlineComponentStub {
|
|
122
|
+
data?: Record<string, unknown>;
|
|
123
|
+
properties?: Record<string, unknown>;
|
|
124
|
+
template?: string;
|
|
107
125
|
}
|
|
108
126
|
export interface MiniProgramRenderResult {
|
|
109
127
|
close: () => Promise<void>;
|
|
@@ -116,6 +134,18 @@ export interface MiniProgramRenderResult {
|
|
|
116
134
|
export interface MiniProgramEmissionSource {
|
|
117
135
|
emitted: (eventName: string) => unknown[];
|
|
118
136
|
}
|
|
137
|
+
export interface MiniProgramComponentRenderResult extends MiniProgramRenderResult {
|
|
138
|
+
/** 移除当前事件的全部订阅,或移除指定回调的全部订阅。 */
|
|
139
|
+
off: (eventName: string, listener?: MiniProgramEventListener) => void;
|
|
140
|
+
/** 每次调用建立独立订阅;返回值仅取消本次订阅,省略回调时只收集事件。 */
|
|
141
|
+
on: (eventName: string, listener?: MiniProgramEventListener) => () => void;
|
|
142
|
+
/** 更新当前组件实例的数据,并等待渲染完成。 */
|
|
143
|
+
setData: (data: Record<string, unknown>) => Promise<void>;
|
|
144
|
+
/** 通过宿主绑定更新当前组件的属性,并等待渲染完成。 */
|
|
145
|
+
setProperties: (properties: Record<string, unknown>) => Promise<void>;
|
|
146
|
+
/** 移除组件主体并触发真实卸载流程,保留可独立关闭的测试会话。 */
|
|
147
|
+
unmount: () => Promise<void>;
|
|
148
|
+
}
|
|
119
149
|
export interface MiniProgramMatcherResult {
|
|
120
150
|
message: () => string;
|
|
121
151
|
pass: boolean;
|
|
@@ -148,7 +178,7 @@ export declare class MiniProgramTestProject {
|
|
|
148
178
|
constructor(options: CreateTestProjectOptions);
|
|
149
179
|
private render;
|
|
150
180
|
renderPage(route: string): Promise<MiniProgramRenderResult>;
|
|
151
|
-
renderComponent(componentPath: string, options?: RenderComponentOptions): Promise<
|
|
181
|
+
renderComponent(componentPath: string, options?: RenderComponentOptions): Promise<MiniProgramComponentRenderResult>;
|
|
152
182
|
close(): Promise<void>;
|
|
153
183
|
}
|
|
154
184
|
export declare function artifactFromProject(projectPath: string): {
|
|
@@ -156,4 +186,25 @@ export declare function artifactFromProject(projectPath: string): {
|
|
|
156
186
|
};
|
|
157
187
|
export declare function createTestProject(options: CreateTestProjectOptions): MiniProgramTestProject;
|
|
158
188
|
//#endregion
|
|
159
|
-
|
|
189
|
+
//#region src/snapshot/tree.d.ts
|
|
190
|
+
interface MiniProgramElementSnapshot {
|
|
191
|
+
attributes: Record<string, string>;
|
|
192
|
+
children: MiniProgramTreeSnapshot[];
|
|
193
|
+
tag: string;
|
|
194
|
+
}
|
|
195
|
+
interface MiniProgramTextSnapshot {
|
|
196
|
+
text: string;
|
|
197
|
+
}
|
|
198
|
+
type MiniProgramTreeSnapshot = MiniProgramElementSnapshot | MiniProgramTextSnapshot;
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/snapshot/index.d.ts
|
|
201
|
+
export type MiniProgramSnapshotSource = MiniProgramScreen | MiniProgramNode | HeadlessTestingPageSnapshot;
|
|
202
|
+
/** 将查询对象或完整诊断快照显式转换为适合稳定断言的语义树。 */
|
|
203
|
+
export declare function toMiniProgramSnapshot(source: MiniProgramSnapshotSource): MiniProgramTreeSnapshot;
|
|
204
|
+
/** 通过 Vitest/Jest 的公开 serializer 接口,仅格式化本包的查询对象。 */
|
|
205
|
+
export declare const mpcoreSnapshotSerializer: {
|
|
206
|
+
test(value: unknown): value is MiniProgramScreen | MiniProgramNode;
|
|
207
|
+
serialize(value: MiniProgramScreen | MiniProgramNode): string;
|
|
208
|
+
};
|
|
209
|
+
//#endregion
|
|
210
|
+
export { type MiniProgramElementSnapshot, type MiniProgramTextSnapshot, type MiniProgramTreeSnapshot, type RoleQueryOptions, type TextMatcher, wechat };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { HeadlessTestingPageHandle, createArtifactProject, createHeadlessSession, createMemoryArtifactSource, createOverlayArtifactSource, createProjectDescriptor, loadProject, wechat, wechat as wechat$1 } from "@mpcore/simulator";
|
|
2
|
+
import path from "node:path";
|
|
3
3
|
//#region src/host.ts
|
|
4
4
|
function asList(value) {
|
|
5
5
|
if (!value) return [];
|
|
@@ -61,6 +61,258 @@ const mpcoreMatchers = {
|
|
|
61
61
|
toHaveTextContent
|
|
62
62
|
};
|
|
63
63
|
//#endregion
|
|
64
|
+
//#region src/componentHarness/data.ts
|
|
65
|
+
/** 在测试 API 边界复制 JSON 数据,避免调用方引用进入 runtime。 */
|
|
66
|
+
function serializeComponentData(value, label) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.stringify(value, (_key, item) => {
|
|
69
|
+
if (typeof item === "function" || typeof item === "symbol" || typeof item === "bigint") throw new TypeError("Unsupported non-serializable value.");
|
|
70
|
+
return item;
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new TypeError(`${label} must contain serializable data.`, { cause: error });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function cloneComponentData(value, label) {
|
|
77
|
+
return JSON.parse(serializeComponentData(value, label));
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/overrides/index.ts
|
|
81
|
+
function normalizeComponentPath(componentPath) {
|
|
82
|
+
const relativePath = componentPath.trim().replaceAll("\\", "/").replace(/^\/+/, "");
|
|
83
|
+
const normalized = path.posix.normalize(relativePath.replace(/\.(?:js|json|wxml)$/, ""));
|
|
84
|
+
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.");
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
/** 在本次渲染的 artifact 读取边界递归覆盖组件依赖。 */
|
|
88
|
+
function createComponentOverrides(project, overrides = {}) {
|
|
89
|
+
const root = project.miniprogramRootPath;
|
|
90
|
+
const replacements = {};
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (const [index, [alias, replacement]] of Object.entries(overrides).entries()) {
|
|
93
|
+
if (typeof replacement === "string") {
|
|
94
|
+
replacements[alias] = `/${normalizeComponentPath(replacement)}`;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const relativePath = `mpcore-test/stubs/stub-${index}/index`;
|
|
98
|
+
const basePath = path.resolve(root, relativePath);
|
|
99
|
+
replacements[alias] = `/${relativePath}`;
|
|
100
|
+
entries.push([`${basePath}.json`, "{\"component\":true}"], [`${basePath}.wxml`, replacement.template ?? "<slot />"], [`${basePath}.js`, `Component(${serializeComponentData({
|
|
101
|
+
data: replacement.data ?? {},
|
|
102
|
+
properties: Object.fromEntries(Object.entries(replacement.properties ?? {}).map(([key, value]) => [key, {
|
|
103
|
+
type: null,
|
|
104
|
+
value
|
|
105
|
+
}]))
|
|
106
|
+
}, "Inline component stubs")})`]);
|
|
107
|
+
}
|
|
108
|
+
const source = createOverlayArtifactSource(createMemoryArtifactSource(entries), project.artifactSource);
|
|
109
|
+
if (Object.keys(replacements).length === 0) return source;
|
|
110
|
+
return {
|
|
111
|
+
has: (filePath) => source.has(filePath),
|
|
112
|
+
readText(filePath) {
|
|
113
|
+
const original = source.readText(filePath);
|
|
114
|
+
const basePath = filePath.replace(/\.json$/, "");
|
|
115
|
+
if (!filePath.endsWith(".json") || !source.has(`${basePath}.js`) || !source.has(`${basePath}.wxml`)) return original;
|
|
116
|
+
const config = JSON.parse(original ?? "{}");
|
|
117
|
+
return JSON.stringify({
|
|
118
|
+
...config,
|
|
119
|
+
usingComponents: {
|
|
120
|
+
...config.usingComponents,
|
|
121
|
+
...replacements
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/componentHarness/host.ts
|
|
129
|
+
const COMPONENT_HOST_ROUTE = "mpcore-test/component-host/index";
|
|
130
|
+
const COMPONENT_SUBJECT_SELECTOR = "#mpcore-subject";
|
|
131
|
+
function createSlotMarkup(slots) {
|
|
132
|
+
return Object.entries(slots).map(([name, content]) => {
|
|
133
|
+
if (!/^[a-z_]\w*(?:-\w+)*$/i.test(name)) throw new TypeError("Component slot names must be valid WXML attribute names.");
|
|
134
|
+
return name === "default" ? content : `<block slot="${name}">${content}</block>`;
|
|
135
|
+
}).join("");
|
|
136
|
+
}
|
|
137
|
+
/** 宿主模板读取每轮订阅和属性名称,主体节点位置保持不变。 */
|
|
138
|
+
function createComponentHostProject(project, componentPath, options, events) {
|
|
139
|
+
const root = project.miniprogramRootPath;
|
|
140
|
+
const hostRoot = path.resolve(root, COMPONENT_HOST_ROUTE);
|
|
141
|
+
const templatePath = `${hostRoot}.wxml`;
|
|
142
|
+
const propertyNames = /* @__PURE__ */ new Set();
|
|
143
|
+
const addPropertyNames = (names) => {
|
|
144
|
+
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.");
|
|
145
|
+
names.forEach((name) => propertyNames.add(name));
|
|
146
|
+
};
|
|
147
|
+
addPropertyNames(Object.keys(options.properties ?? {}));
|
|
148
|
+
const slots = createSlotMarkup(options.slots ?? {});
|
|
149
|
+
const fixed = createMemoryArtifactSource([
|
|
150
|
+
[path.resolve(root, "app.json"), JSON.stringify({ pages: [COMPONENT_HOST_ROUTE] })],
|
|
151
|
+
[path.resolve(root, "app.js"), "App({})\n"],
|
|
152
|
+
[`${hostRoot}.json`, JSON.stringify({ usingComponents: { "mpcore-subject": `/${normalizeComponentPath(componentPath)}` } })],
|
|
153
|
+
[`${hostRoot}.js`, `Page({
|
|
154
|
+
data: { __mpcoreMounted: true, __mpcoreProperties: ${serializeComponentData(options.properties ?? {}, "Component properties").replaceAll("<", "\\u003c")} },
|
|
155
|
+
__mpcoreRecordEvent(event) { globalThis.__mpcoreRecordEvent(event.type, event.detail, event) }
|
|
156
|
+
})\n`]
|
|
157
|
+
]);
|
|
158
|
+
return {
|
|
159
|
+
addPropertyNames,
|
|
160
|
+
project: createProjectDescriptor({
|
|
161
|
+
appConfig: { pages: [COMPONENT_HOST_ROUTE] },
|
|
162
|
+
appConfigPath: path.resolve(root, "app.json"),
|
|
163
|
+
artifactSource: createOverlayArtifactSource({
|
|
164
|
+
has: (filePath) => filePath === templatePath || fixed.has(filePath),
|
|
165
|
+
readText(filePath) {
|
|
166
|
+
if (filePath !== templatePath) return fixed.readText(filePath);
|
|
167
|
+
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>`;
|
|
168
|
+
}
|
|
169
|
+
}, createComponentOverrides(project, options.usingComponents)),
|
|
170
|
+
miniprogramRoot: project.miniprogramRoot,
|
|
171
|
+
miniprogramRootPath: root,
|
|
172
|
+
plugins: project.plugins,
|
|
173
|
+
projectConfigFiles: project.projectConfigFiles,
|
|
174
|
+
projectPath: project.projectPath
|
|
175
|
+
})
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/componentHarness/index.ts
|
|
180
|
+
/** 复用宿主会话更新与移除真实组件,不重新构造主体实例。 */
|
|
181
|
+
function createComponentRenderResult(base, session, settle, host, events) {
|
|
182
|
+
let mounted = true;
|
|
183
|
+
let unmounting;
|
|
184
|
+
const assertMounted = () => {
|
|
185
|
+
session.assertActive();
|
|
186
|
+
if (!mounted) throw new Error("The test component has been unmounted.");
|
|
187
|
+
};
|
|
188
|
+
events.setChangeHandler(() => {
|
|
189
|
+
session.renderCurrentPage();
|
|
190
|
+
});
|
|
191
|
+
const refresh = async () => {
|
|
192
|
+
await settle();
|
|
193
|
+
await base.screen.refresh();
|
|
194
|
+
};
|
|
195
|
+
return Object.assign(base, {
|
|
196
|
+
off: events.off,
|
|
197
|
+
on(eventName, listener) {
|
|
198
|
+
assertMounted();
|
|
199
|
+
return events.on(eventName, listener);
|
|
200
|
+
},
|
|
201
|
+
async setData(data) {
|
|
202
|
+
assertMounted();
|
|
203
|
+
const payload = cloneComponentData(data, "Component data");
|
|
204
|
+
const subject = session.selectComponent(COMPONENT_SUBJECT_SELECTOR);
|
|
205
|
+
if (!subject) throw new Error("The test component is no longer mounted.");
|
|
206
|
+
await new Promise((resolve) => {
|
|
207
|
+
subject.setData(payload, resolve);
|
|
208
|
+
});
|
|
209
|
+
await refresh();
|
|
210
|
+
},
|
|
211
|
+
async setProperties(properties) {
|
|
212
|
+
assertMounted();
|
|
213
|
+
const payload = cloneComponentData(properties, "Component properties");
|
|
214
|
+
host.addPropertyNames(Object.keys(payload));
|
|
215
|
+
const previous = await base.page.data("__mpcoreProperties");
|
|
216
|
+
await base.page.setData({ __mpcoreProperties: {
|
|
217
|
+
...previous,
|
|
218
|
+
...payload
|
|
219
|
+
} });
|
|
220
|
+
await refresh();
|
|
221
|
+
},
|
|
222
|
+
async unmount() {
|
|
223
|
+
if (unmounting) return await unmounting;
|
|
224
|
+
session.assertActive();
|
|
225
|
+
mounted = false;
|
|
226
|
+
unmounting = (async () => {
|
|
227
|
+
try {
|
|
228
|
+
await base.page.setData({ __mpcoreMounted: false });
|
|
229
|
+
} finally {
|
|
230
|
+
events.clear();
|
|
231
|
+
}
|
|
232
|
+
await refresh();
|
|
233
|
+
})();
|
|
234
|
+
return await unmounting;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
//#endregion
|
|
239
|
+
//#region src/componentHarness/events.ts
|
|
240
|
+
/** 将事件订阅映射到宿主声明绑定,不拦截组件 triggerEvent。 */
|
|
241
|
+
function createComponentEvents(options = {}) {
|
|
242
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
243
|
+
const log = /* @__PURE__ */ new Map();
|
|
244
|
+
let changed = () => {};
|
|
245
|
+
function normalizeEventName(eventName) {
|
|
246
|
+
if (!/^[\w:.-]+$/.test(eventName)) throw new TypeError("Component event names must contain only letters, numbers, underscores, colons, dots or hyphens.");
|
|
247
|
+
return eventName;
|
|
248
|
+
}
|
|
249
|
+
function on(eventName, listener) {
|
|
250
|
+
const name = normalizeEventName(eventName);
|
|
251
|
+
const listeners = subscriptions.get(name) ?? /* @__PURE__ */ new Map();
|
|
252
|
+
const token = Symbol(name);
|
|
253
|
+
subscriptions.set(name, listeners);
|
|
254
|
+
listeners.set(token, listener);
|
|
255
|
+
changed();
|
|
256
|
+
let active = true;
|
|
257
|
+
return () => {
|
|
258
|
+
if (active) {
|
|
259
|
+
active = false;
|
|
260
|
+
listeners.delete(token);
|
|
261
|
+
if (listeners.size === 0 && subscriptions.get(name) === listeners) subscriptions.delete(name);
|
|
262
|
+
changed();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function off(eventName, listener) {
|
|
267
|
+
if (listener === void 0) subscriptions.delete(eventName);
|
|
268
|
+
else {
|
|
269
|
+
const listeners = subscriptions.get(eventName);
|
|
270
|
+
for (const [token, registered] of listeners ?? []) if (registered === listener) listeners?.delete(token);
|
|
271
|
+
if (listeners?.size === 0) subscriptions.delete(eventName);
|
|
272
|
+
}
|
|
273
|
+
changed();
|
|
274
|
+
}
|
|
275
|
+
for (const name of options.events ?? []) on(name);
|
|
276
|
+
for (const [name, listener] of Object.entries(options.on ?? {})) on(name, listener);
|
|
277
|
+
return {
|
|
278
|
+
clear() {
|
|
279
|
+
subscriptions.clear();
|
|
280
|
+
changed = () => {};
|
|
281
|
+
},
|
|
282
|
+
emitted: (name) => [...log.get(name) ?? []],
|
|
283
|
+
names: () => [...subscriptions.keys()],
|
|
284
|
+
off,
|
|
285
|
+
on,
|
|
286
|
+
receive(name, detail, event) {
|
|
287
|
+
const listeners = subscriptions.get(name);
|
|
288
|
+
if (!listeners) return;
|
|
289
|
+
const entries = log.get(name) ?? [];
|
|
290
|
+
entries.push(detail);
|
|
291
|
+
log.set(name, entries);
|
|
292
|
+
for (const listener of [...listeners.values()]) listener?.(detail, event);
|
|
293
|
+
},
|
|
294
|
+
setChangeHandler(handler) {
|
|
295
|
+
changed = handler;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
//#endregion
|
|
300
|
+
//#region src/snapshot/tree.ts
|
|
301
|
+
const EVENT_ATTRIBUTE = /^(?:(?:capture-)?(?:bind|catch)|mut-bind)(?::[\w:.-]+|\w+)$/;
|
|
302
|
+
const SCOPED_STYLE_ATTRIBUTE = /^data-v-[a-f\d]+$/i;
|
|
303
|
+
function snapshotAttributes(attributes) {
|
|
304
|
+
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]));
|
|
305
|
+
}
|
|
306
|
+
/** 保留逻辑结构、文本和公开属性,仅移除宿主内部标识并隐藏事件方法名。 */
|
|
307
|
+
function createMiniProgramTreeSnapshot(node) {
|
|
308
|
+
if (node.type === "text") return { text: node.text };
|
|
309
|
+
return {
|
|
310
|
+
attributes: snapshotAttributes(node.attrs),
|
|
311
|
+
children: node.children.map(createMiniProgramTreeSnapshot),
|
|
312
|
+
tag: node.tag ?? "root"
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
64
316
|
//#region src/screen/helpers.ts
|
|
65
317
|
function matchesText(value, matcher) {
|
|
66
318
|
if (typeof matcher === "string") return value === matcher;
|
|
@@ -96,7 +348,7 @@ function findNodeById(root, nodeId) {
|
|
|
96
348
|
}
|
|
97
349
|
}
|
|
98
350
|
//#endregion
|
|
99
|
-
//#region src/screen/
|
|
351
|
+
//#region src/screen/node.ts
|
|
100
352
|
var MiniProgramNode = class {
|
|
101
353
|
owner;
|
|
102
354
|
nodeId;
|
|
@@ -128,16 +380,25 @@ var MiniProgramNode = class {
|
|
|
128
380
|
getAttribute(name) {
|
|
129
381
|
return this.current.attrs[name];
|
|
130
382
|
}
|
|
383
|
+
/** 返回当前节点的稳定语义树,卸载后的节点不能生成快照。 */
|
|
384
|
+
snapshot() {
|
|
385
|
+
return createMiniProgramTreeSnapshot(this.current);
|
|
386
|
+
}
|
|
131
387
|
};
|
|
388
|
+
//#endregion
|
|
389
|
+
//#region src/screen/index.ts
|
|
132
390
|
var MiniProgramScreen = class MiniProgramScreen {
|
|
133
391
|
page;
|
|
134
|
-
|
|
392
|
+
snapshotStore;
|
|
135
393
|
rootNodeId;
|
|
136
394
|
constructor(page, snapshot, rootNodeId) {
|
|
137
395
|
this.page = page;
|
|
138
|
-
this.
|
|
396
|
+
this.snapshotStore = { current: snapshot };
|
|
139
397
|
this.rootNodeId = rootNodeId;
|
|
140
398
|
}
|
|
399
|
+
get currentSnapshot() {
|
|
400
|
+
return this.snapshotStore.current;
|
|
401
|
+
}
|
|
141
402
|
get root() {
|
|
142
403
|
this.page.assertActive();
|
|
143
404
|
if (!this.rootNodeId) return this.currentSnapshot.root;
|
|
@@ -177,18 +438,27 @@ var MiniProgramScreen = class MiniProgramScreen {
|
|
|
177
438
|
}
|
|
178
439
|
}
|
|
179
440
|
hasNode(nodeId) {
|
|
441
|
+
this.page.assertActive();
|
|
180
442
|
return Boolean(findNodeById(this.currentSnapshot.root, nodeId));
|
|
181
443
|
}
|
|
182
444
|
resolveNode(nodeId) {
|
|
445
|
+
this.page.assertActive();
|
|
183
446
|
const node = findNodeById(this.currentSnapshot.root, nodeId);
|
|
184
447
|
if (!node) throw new Error("The mini-program node is no longer present in the rendered tree.");
|
|
185
448
|
return node;
|
|
186
449
|
}
|
|
187
450
|
async refresh() {
|
|
188
|
-
this.
|
|
451
|
+
this.snapshotStore.current = await this.page.snapshot();
|
|
189
452
|
}
|
|
190
453
|
within(node) {
|
|
191
|
-
|
|
454
|
+
this.resolveNode(node.nodeId);
|
|
455
|
+
const screen = new MiniProgramScreen(this.page, this.currentSnapshot, node.nodeId);
|
|
456
|
+
screen.snapshotStore = this.snapshotStore;
|
|
457
|
+
return screen;
|
|
458
|
+
}
|
|
459
|
+
/** 返回用于断言的稳定语义树,不包含页面数据与内部节点身份。 */
|
|
460
|
+
snapshot() {
|
|
461
|
+
return createMiniProgramTreeSnapshot(this.root);
|
|
192
462
|
}
|
|
193
463
|
queryAllByText(matcher) {
|
|
194
464
|
const matches = this.allElements().filter((node) => {
|
|
@@ -338,54 +608,6 @@ var MiniProgramUser = class {
|
|
|
338
608
|
};
|
|
339
609
|
//#endregion
|
|
340
610
|
//#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
|
-
}
|
|
389
611
|
function formatDiagnostic(entry) {
|
|
390
612
|
return entry.args.map((arg) => {
|
|
391
613
|
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 +627,10 @@ var MiniProgramTestProject = class {
|
|
|
405
627
|
projectPath: options.artifact.projectPath
|
|
406
628
|
}) : loadProject(options.artifact.projectPath);
|
|
407
629
|
}
|
|
408
|
-
async render(project, route,
|
|
409
|
-
const eventLog = /* @__PURE__ */ new Map();
|
|
630
|
+
async render(project, route, events = createComponentEvents()) {
|
|
410
631
|
const session = createHeadlessSession({
|
|
411
632
|
globals: { __mpcoreRecordEvent(eventName, detail, event) {
|
|
412
|
-
|
|
413
|
-
entries.push(detail);
|
|
414
|
-
eventLog.set(eventName, entries);
|
|
415
|
-
on?.[eventName]?.(detail, event);
|
|
633
|
+
events.receive(eventName, detail, event);
|
|
416
634
|
} },
|
|
417
635
|
project,
|
|
418
636
|
strictHostMocks: true
|
|
@@ -438,26 +656,36 @@ var MiniProgramTestProject = class {
|
|
|
438
656
|
close: async () => {
|
|
439
657
|
if (closed) return;
|
|
440
658
|
closed = true;
|
|
441
|
-
|
|
442
|
-
|
|
659
|
+
try {
|
|
660
|
+
session.close();
|
|
661
|
+
} finally {
|
|
662
|
+
events.clear();
|
|
663
|
+
this.renders.delete(result);
|
|
664
|
+
}
|
|
443
665
|
assertDiagnostics();
|
|
444
666
|
},
|
|
445
667
|
diagnostics: () => session.getDiagnostics(),
|
|
446
|
-
emitted:
|
|
668
|
+
emitted: events.emitted,
|
|
447
669
|
page,
|
|
448
670
|
screen,
|
|
449
671
|
user
|
|
450
672
|
};
|
|
451
673
|
this.renders.add(result);
|
|
452
674
|
await settle();
|
|
453
|
-
return
|
|
675
|
+
return {
|
|
676
|
+
result,
|
|
677
|
+
session,
|
|
678
|
+
settle
|
|
679
|
+
};
|
|
454
680
|
}
|
|
455
681
|
async renderPage(route) {
|
|
456
|
-
return await this.render(this.baseProject, route);
|
|
682
|
+
return (await this.render(this.baseProject, route)).result;
|
|
457
683
|
}
|
|
458
684
|
async renderComponent(componentPath, options = {}) {
|
|
459
|
-
const
|
|
460
|
-
|
|
685
|
+
const events = createComponentEvents(options);
|
|
686
|
+
const host = createComponentHostProject(this.baseProject, componentPath, options, events);
|
|
687
|
+
const { result, session, settle } = await this.render(host.project, `/${COMPONENT_HOST_ROUTE}`, events);
|
|
688
|
+
return createComponentRenderResult(result, session, settle, host, events);
|
|
461
689
|
}
|
|
462
690
|
async close() {
|
|
463
691
|
let firstError;
|
|
@@ -480,4 +708,19 @@ function createTestProject(options) {
|
|
|
480
708
|
});
|
|
481
709
|
}
|
|
482
710
|
//#endregion
|
|
483
|
-
|
|
711
|
+
//#region src/snapshot/index.ts
|
|
712
|
+
/** 将查询对象或完整诊断快照显式转换为适合稳定断言的语义树。 */
|
|
713
|
+
function toMiniProgramSnapshot(source) {
|
|
714
|
+
return source instanceof MiniProgramScreen || source instanceof MiniProgramNode ? source.snapshot() : createMiniProgramTreeSnapshot(source.root);
|
|
715
|
+
}
|
|
716
|
+
/** 通过 Vitest/Jest 的公开 serializer 接口,仅格式化本包的查询对象。 */
|
|
717
|
+
const mpcoreSnapshotSerializer = {
|
|
718
|
+
test(value) {
|
|
719
|
+
return value instanceof MiniProgramScreen || value instanceof MiniProgramNode;
|
|
720
|
+
},
|
|
721
|
+
serialize(value) {
|
|
722
|
+
return JSON.stringify(toMiniProgramSnapshot(value), null, 2);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
//#endregion
|
|
726
|
+
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.1.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",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"node": "^22.18.0 || ^24.11.0 || >=26.0.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@mpcore/simulator": "1.
|
|
45
|
+
"@mpcore/simulator": "1.1.0"
|
|
46
46
|
},
|
|
47
47
|
"publishConfig": {
|
|
48
48
|
"access": "public",
|