@mpcore/test 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 ice breaker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # @mpcore/test
2
+
3
+ 面向微信小程序编译产物的 runner 无关测试 API。它模拟微信宿主和逻辑 WXML 树,不创建浏览器 DOM。
4
+
5
+ ```ts
6
+ const project = createTestProject({
7
+ artifact: { projectPath: '/path/to/project' },
8
+ host: createWxMock({
9
+ request: { url: '/api/count', response: { data: { count: 1 } } },
10
+ }),
11
+ })
12
+
13
+ const result = await project.renderComponent('components/counter/index', {
14
+ properties: { value: 1 },
15
+ slots: { default: '<text>计数器</text>' },
16
+ })
17
+
18
+ await result.user.tap(result.screen.getByRole('button', { name: '增加' }))
19
+ ```
20
+
21
+ 查询支持 text、role、test id、attribute、`within()` 及 `get/query/find/getAll/findAll` 变体,并保留 `$` / `$$` 选择器逃生口。`tap/input/change/blur/trigger` 会等待同步更新和 microtask 稳定后刷新逻辑树。
22
+
23
+ 未匹配的网络 mock 会抛错;未捕获运行时异常和 `console.error` 默认令测试失败。可通过 `diagnostics()` 断言 warning,负向 console 测试可显式设置 `failOnConsoleError: false`。
24
+
25
+ `@mpcore/test` 为真实微信小程序编译产物提供页面和组件单测环境。它模拟微信宿主与逻辑 WXML 树,不提供浏览器 `document` 或 CSS layout。
26
+
27
+ ```ts
28
+ import { artifactFromProject, createTestProject } from '@mpcore/test'
29
+
30
+ const project = createTestProject({
31
+ artifact: artifactFromProject(process.cwd()),
32
+ })
33
+
34
+ const { screen, user } = await project.renderPage('/pages/index/index')
35
+ await user.tap(screen.getByRole('button', { name: '增加' }))
36
+ expect(screen.getByText('2')).toBeDefined()
37
+
38
+ await project.close()
39
+ ```
40
+
41
+ 组件测试通过内存宿主页挂载构建后的组件,不会修改小程序产物。
@@ -0,0 +1,159 @@
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
+ type TextMatcher = string | RegExp;
4
+ interface RoleQueryOptions {
5
+ name?: TextMatcher;
6
+ }
7
+ //#endregion
8
+ //#region src/screen/index.d.ts
9
+ declare class MiniProgramNode {
10
+ private readonly owner;
11
+ readonly nodeId: string;
12
+ constructor(owner: MiniProgramScreen, nodeId: string);
13
+ private get current();
14
+ get attributes(): {
15
+ [x: string]: string;
16
+ };
17
+ get dataset(): {
18
+ [k: string]: string;
19
+ };
20
+ get isConnected(): boolean;
21
+ get role(): string | undefined;
22
+ get tagName(): string | undefined;
23
+ get textContent(): string;
24
+ getAttribute(name: string): string;
25
+ }
26
+ declare class MiniProgramScreen {
27
+ private readonly page;
28
+ private currentSnapshot;
29
+ private readonly rootNodeId?;
30
+ constructor(page: HeadlessTestingPageHandle, snapshot: HeadlessTestingPageSnapshot, rootNodeId?: string);
31
+ private get root();
32
+ private allElements;
33
+ private wrap;
34
+ private requireOne;
35
+ private findOne;
36
+ private findAll;
37
+ hasNode(nodeId: string): boolean;
38
+ resolveNode(nodeId: string): HeadlessTestingLogicalNode;
39
+ refresh(): Promise<void>;
40
+ within(node: MiniProgramNode): MiniProgramScreen;
41
+ queryAllByText(matcher: TextMatcher): MiniProgramNode[];
42
+ queryByText(matcher: TextMatcher): MiniProgramNode;
43
+ getAllByText(matcher: TextMatcher): MiniProgramNode[];
44
+ getByText(matcher: TextMatcher): MiniProgramNode;
45
+ findByText(matcher: TextMatcher, timeout?: number): Promise<MiniProgramNode>;
46
+ findAllByText(matcher: TextMatcher, timeout?: number): Promise<MiniProgramNode[]>;
47
+ queryAllByRole(role: string, options?: RoleQueryOptions): MiniProgramNode[];
48
+ queryByRole(role: string, options?: RoleQueryOptions): MiniProgramNode;
49
+ getAllByRole(role: string, options?: RoleQueryOptions): MiniProgramNode[];
50
+ getByRole(role: string, options?: RoleQueryOptions): MiniProgramNode;
51
+ findByRole(role: string, options?: RoleQueryOptions, timeout?: number): Promise<MiniProgramNode>;
52
+ findAllByRole(role: string, options?: RoleQueryOptions, timeout?: number): Promise<MiniProgramNode[]>;
53
+ queryAllByTestId(testId: string): MiniProgramNode[];
54
+ queryByTestId(testId: string): MiniProgramNode;
55
+ getAllByTestId(testId: string): MiniProgramNode[];
56
+ getByTestId(testId: string): MiniProgramNode;
57
+ findByTestId(testId: string, timeout?: number): Promise<MiniProgramNode>;
58
+ findAllByTestId(testId: string, timeout?: number): Promise<MiniProgramNode[]>;
59
+ queryAllByAttribute(name: string, value?: TextMatcher): MiniProgramNode[];
60
+ queryByAttribute(name: string, value?: TextMatcher): MiniProgramNode;
61
+ getAllByAttribute(name: string, value?: TextMatcher): MiniProgramNode[];
62
+ getByAttribute(name: string, value?: TextMatcher): MiniProgramNode;
63
+ findByAttribute(name: string, value?: TextMatcher, timeout?: number): Promise<MiniProgramNode>;
64
+ findAllByAttribute(name: string, value?: TextMatcher, timeout?: number): Promise<MiniProgramNode[]>;
65
+ $(selector: string): Promise<import("@mpcore/simulator").HeadlessTestingNodeHandle | null>;
66
+ $$(selector: string): Promise<import("@mpcore/simulator").HeadlessTestingNodeHandle[]>;
67
+ }
68
+ //#endregion
69
+ //#region src/user.d.ts
70
+ declare class MiniProgramUser {
71
+ private readonly page;
72
+ private readonly screen;
73
+ private readonly settle;
74
+ constructor(page: HeadlessTestingPageHandle, screen: MiniProgramScreen, settle: () => Promise<void>);
75
+ private nodeHandle;
76
+ private finish;
77
+ tap(node: MiniProgramNode, event?: Record<string, any>): Promise<void>;
78
+ input(node: MiniProgramNode, value: string, event?: Record<string, any>): Promise<void>;
79
+ change(node: MiniProgramNode, value: string, event?: Record<string, any>): Promise<void>;
80
+ blur(node: MiniProgramNode, value?: string, event?: Record<string, any>): Promise<void>;
81
+ trigger(node: MiniProgramNode, eventName: string, event?: Record<string, any>): Promise<void>;
82
+ }
83
+ //#endregion
84
+ //#region src/types.d.ts
85
+ interface MiniProgramArtifact {
86
+ appConfigPath?: string;
87
+ miniprogramRootPath?: string;
88
+ projectPath: string;
89
+ }
90
+ interface WxHostMock {
91
+ actionSheet?: HeadlessWxActionSheetMockDefinition | HeadlessWxActionSheetMockDefinition[];
92
+ downloadFile?: HeadlessWxDownloadFileMockDefinition | HeadlessWxDownloadFileMockDefinition[];
93
+ modal?: HeadlessWxModalMockDefinition | HeadlessWxModalMockDefinition[];
94
+ request?: HeadlessWxRequestMockDefinition | HeadlessWxRequestMockDefinition[];
95
+ uploadFile?: HeadlessWxUploadFileMockDefinition | HeadlessWxUploadFileMockDefinition[];
96
+ }
97
+ interface CreateTestProjectOptions {
98
+ artifact: MiniProgramArtifact;
99
+ failOnConsoleError?: boolean;
100
+ host?: WxHostMock;
101
+ platform?: PlatformAdapter;
102
+ }
103
+ interface RenderComponentOptions {
104
+ on?: Record<string, (detail: unknown, event: Record<string, any>) => void>;
105
+ properties?: Record<string, unknown>;
106
+ slots?: Record<string, string>;
107
+ }
108
+ interface MiniProgramRenderResult {
109
+ close: () => Promise<void>;
110
+ diagnostics: () => RuntimeDiagnosticEntry[];
111
+ emitted: (eventName: string) => unknown[];
112
+ page: HeadlessTestingPageHandle;
113
+ screen: MiniProgramScreen;
114
+ user: MiniProgramUser;
115
+ }
116
+ interface MiniProgramEmissionSource {
117
+ emitted: (eventName: string) => unknown[];
118
+ }
119
+ interface MiniProgramMatcherResult {
120
+ message: () => string;
121
+ pass: boolean;
122
+ }
123
+ type MiniProgramNodeLike = MiniProgramNode;
124
+ //#endregion
125
+ //#region src/host.d.ts
126
+ declare function createWxMock(definition?: WxHostMock): WxHostMock;
127
+ declare function applyWxMock(session: HeadlessSession, definition?: WxHostMock): void;
128
+ //#endregion
129
+ //#region src/matchers.d.ts
130
+ declare function toBeInTheMiniProgram(node: MiniProgramNode): MiniProgramMatcherResult;
131
+ declare function toHaveTextContent(node: MiniProgramNode, expected: string | RegExp): MiniProgramMatcherResult;
132
+ declare function toHaveEmitted(source: MiniProgramEmissionSource, eventName: string, expected?: unknown): MiniProgramMatcherResult;
133
+ declare function toHaveAttribute(node: MiniProgramNode, name: string, expected?: string): MiniProgramMatcherResult;
134
+ declare function toHaveDataset(node: MiniProgramNode, expected: Record<string, unknown>): MiniProgramMatcherResult;
135
+ declare const mpcoreMatchers: {
136
+ toBeInTheMiniProgram: typeof toBeInTheMiniProgram;
137
+ toHaveAttribute: typeof toHaveAttribute;
138
+ toHaveDataset: typeof toHaveDataset;
139
+ toHaveEmitted: typeof toHaveEmitted;
140
+ toHaveTextContent: typeof toHaveTextContent;
141
+ };
142
+ //#endregion
143
+ //#region src/project.d.ts
144
+ declare class MiniProgramTestProject {
145
+ private readonly options;
146
+ private readonly baseProject;
147
+ private readonly renders;
148
+ constructor(options: CreateTestProjectOptions);
149
+ private render;
150
+ renderPage(route: string): Promise<MiniProgramRenderResult>;
151
+ renderComponent(componentPath: string, options?: RenderComponentOptions): Promise<MiniProgramRenderResult>;
152
+ close(): Promise<void>;
153
+ }
154
+ declare function artifactFromProject(projectPath: string): {
155
+ projectPath: string;
156
+ };
157
+ declare function createTestProject(options: CreateTestProjectOptions): MiniProgramTestProject;
158
+ //#endregion
159
+ export { CreateTestProjectOptions, MiniProgramArtifact, MiniProgramEmissionSource, MiniProgramMatcherResult, MiniProgramNode, MiniProgramNodeLike, MiniProgramRenderResult, MiniProgramScreen, MiniProgramTestProject, MiniProgramUser, RenderComponentOptions, type RoleQueryOptions, type TextMatcher, WxHostMock, applyWxMock, artifactFromProject, createTestProject, createWxMock, mpcoreMatchers, toBeInTheMiniProgram, toHaveAttribute, toHaveDataset, toHaveEmitted, toHaveTextContent, wechat };
package/dist/index.mjs ADDED
@@ -0,0 +1,478 @@
1
+ import path from "node:path";
2
+ import { HeadlessTestingPageHandle, createArtifactProject, createHeadlessSession, createMemoryArtifactSource, createOverlayArtifactSource, createProjectDescriptor, loadProject, wechat, wechat as wechat$1 } from "@mpcore/simulator";
3
+ //#region src/host.ts
4
+ function asList(value) {
5
+ if (!value) return [];
6
+ return Array.isArray(value) ? value : [value];
7
+ }
8
+ function createWxMock(definition = {}) {
9
+ return definition;
10
+ }
11
+ function applyWxMock(session, definition = {}) {
12
+ asList(definition.actionSheet).forEach((item) => session.mockActionSheet(item));
13
+ asList(definition.downloadFile).forEach((item) => session.mockDownloadFile(item));
14
+ asList(definition.modal).forEach((item) => session.mockModal(item));
15
+ asList(definition.request).forEach((item) => session.mockRequest(item));
16
+ asList(definition.uploadFile).forEach((item) => session.mockUploadFile(item));
17
+ }
18
+ //#endregion
19
+ //#region src/matchers.ts
20
+ function toBeInTheMiniProgram(node) {
21
+ const pass = node?.isConnected === true;
22
+ return {
23
+ message: () => pass ? "Expected node not to be present in the mini-program tree." : "Expected node to be present in the mini-program tree.",
24
+ pass
25
+ };
26
+ }
27
+ function toHaveTextContent(node, expected) {
28
+ const actual = node.textContent;
29
+ if (expected instanceof RegExp) expected.lastIndex = 0;
30
+ return {
31
+ message: () => `Expected mini-program node text ${JSON.stringify(actual)} to match ${String(expected)}.`,
32
+ pass: typeof expected === "string" ? actual.includes(expected) : expected.test(actual)
33
+ };
34
+ }
35
+ function toHaveEmitted(source, eventName, expected) {
36
+ const emissions = source.emitted(eventName);
37
+ return {
38
+ message: () => `Expected mini-program event ${eventName} to be emitted${expected === void 0 ? "" : ` with ${JSON.stringify(expected)}`}, received ${JSON.stringify(emissions)}.`,
39
+ pass: expected === void 0 ? emissions.length > 0 : emissions.some((value) => JSON.stringify(value) === JSON.stringify(expected))
40
+ };
41
+ }
42
+ function toHaveAttribute(node, name, expected) {
43
+ const actual = node.getAttribute(name);
44
+ return {
45
+ message: () => `Expected mini-program node attribute ${name} to be ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}.`,
46
+ pass: expected === void 0 ? actual !== void 0 : actual === expected
47
+ };
48
+ }
49
+ function toHaveDataset(node, expected) {
50
+ const actual = node.dataset;
51
+ return {
52
+ message: () => `Expected mini-program node dataset ${JSON.stringify(actual)} to equal ${JSON.stringify(expected)}.`,
53
+ pass: JSON.stringify(actual) === JSON.stringify(expected)
54
+ };
55
+ }
56
+ const mpcoreMatchers = {
57
+ toBeInTheMiniProgram,
58
+ toHaveAttribute,
59
+ toHaveDataset,
60
+ toHaveEmitted,
61
+ toHaveTextContent
62
+ };
63
+ //#endregion
64
+ //#region src/screen/helpers.ts
65
+ function matchesText(value, matcher) {
66
+ if (typeof matcher === "string") return value === matcher;
67
+ matcher.lastIndex = 0;
68
+ return matcher.test(value);
69
+ }
70
+ function implicitRole(node) {
71
+ const explicit = node.attrs.role ?? node.attrs["aria-role"];
72
+ if (explicit) return explicit;
73
+ if (node.tag === "button") return "button";
74
+ if (node.tag === "textarea") return "textbox";
75
+ if (node.tag === "input") {
76
+ const type = node.attrs.type ?? "text";
77
+ if (type === "checkbox") return "checkbox";
78
+ if (type === "radio") return "radio";
79
+ return "textbox";
80
+ }
81
+ if (node.tag === "image") return "img";
82
+ }
83
+ function accessibleName(node) {
84
+ return node.attrs["aria-label"] ?? node.attrs.placeholder ?? node.attrs.alt ?? node.text.trim();
85
+ }
86
+ function collectElements(root, result = []) {
87
+ if (root.type === "element") result.push(root);
88
+ root.children.forEach((child) => collectElements(child, result));
89
+ return result;
90
+ }
91
+ function findNodeById(root, nodeId) {
92
+ if (root.nodeId === nodeId) return root;
93
+ for (const child of root.children) {
94
+ const match = findNodeById(child, nodeId);
95
+ if (match) return match;
96
+ }
97
+ }
98
+ //#endregion
99
+ //#region src/screen/index.ts
100
+ var MiniProgramNode = class {
101
+ owner;
102
+ nodeId;
103
+ constructor(owner, nodeId) {
104
+ this.owner = owner;
105
+ this.nodeId = nodeId;
106
+ }
107
+ get current() {
108
+ return this.owner.resolveNode(this.nodeId);
109
+ }
110
+ get attributes() {
111
+ return { ...this.current.attrs };
112
+ }
113
+ get dataset() {
114
+ return Object.fromEntries(Object.entries(this.current.attrs).filter(([key]) => key.startsWith("data-")).map(([key, value]) => [key.slice(5).replace(/-([a-z])/g, (_match, char) => char.toUpperCase()), value]));
115
+ }
116
+ get isConnected() {
117
+ return this.owner.hasNode(this.nodeId);
118
+ }
119
+ get role() {
120
+ return implicitRole(this.current);
121
+ }
122
+ get tagName() {
123
+ return this.current.tag;
124
+ }
125
+ get textContent() {
126
+ return this.current.text;
127
+ }
128
+ getAttribute(name) {
129
+ return this.current.attrs[name];
130
+ }
131
+ };
132
+ var MiniProgramScreen = class MiniProgramScreen {
133
+ page;
134
+ currentSnapshot;
135
+ rootNodeId;
136
+ constructor(page, snapshot, rootNodeId) {
137
+ this.page = page;
138
+ this.currentSnapshot = snapshot;
139
+ this.rootNodeId = rootNodeId;
140
+ }
141
+ get root() {
142
+ this.page.assertActive();
143
+ if (!this.rootNodeId) return this.currentSnapshot.root;
144
+ const root = findNodeById(this.currentSnapshot.root, this.rootNodeId);
145
+ if (!root) throw new Error("The within() root is no longer present in the mini-program tree.");
146
+ return root;
147
+ }
148
+ allElements() {
149
+ return collectElements(this.root).filter((node) => Boolean(node.nodeId));
150
+ }
151
+ wrap(nodes) {
152
+ return nodes.map((node) => new MiniProgramNode(this, node.nodeId));
153
+ }
154
+ requireOne(nodes, label) {
155
+ if (nodes.length === 0) throw new Error(`Unable to find ${label} in the mini-program tree.\n\n${this.currentSnapshot.wxml}`);
156
+ if (nodes.length > 1) throw new Error(`Found multiple ${label} nodes in the mini-program tree.`);
157
+ return nodes[0];
158
+ }
159
+ async findOne(query, label, timeout = 1e3) {
160
+ const deadline = Date.now() + timeout;
161
+ while (true) {
162
+ const node = query();
163
+ if (node) return node;
164
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label} in the mini-program tree.`);
165
+ await new Promise((resolve) => setTimeout(resolve, 10));
166
+ await this.refresh();
167
+ }
168
+ }
169
+ async findAll(query, label, timeout = 1e3) {
170
+ const deadline = Date.now() + timeout;
171
+ while (true) {
172
+ const nodes = query();
173
+ if (nodes.length > 0) return nodes;
174
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label} in the mini-program tree.`);
175
+ await new Promise((resolve) => setTimeout(resolve, 10));
176
+ await this.refresh();
177
+ }
178
+ }
179
+ hasNode(nodeId) {
180
+ return Boolean(findNodeById(this.currentSnapshot.root, nodeId));
181
+ }
182
+ resolveNode(nodeId) {
183
+ const node = findNodeById(this.currentSnapshot.root, nodeId);
184
+ if (!node) throw new Error("The mini-program node is no longer present in the rendered tree.");
185
+ return node;
186
+ }
187
+ async refresh() {
188
+ this.currentSnapshot = await this.page.snapshot();
189
+ }
190
+ within(node) {
191
+ return new MiniProgramScreen(this.page, this.currentSnapshot, node.nodeId);
192
+ }
193
+ queryAllByText(matcher) {
194
+ const matches = this.allElements().filter((node) => {
195
+ if (!matchesText(node.text.trim(), matcher)) return false;
196
+ return !node.children.some((child) => child.type === "element" && matchesText(child.text.trim(), matcher));
197
+ });
198
+ return this.wrap(matches);
199
+ }
200
+ queryByText(matcher) {
201
+ const matches = this.queryAllByText(matcher);
202
+ if (matches.length > 1) throw new Error(`Found multiple text ${String(matcher)} nodes in the mini-program tree.`);
203
+ return matches[0] ?? null;
204
+ }
205
+ getAllByText(matcher) {
206
+ const matches = this.queryAllByText(matcher);
207
+ if (matches.length === 0) throw new Error(`Unable to find text ${String(matcher)} in the mini-program tree.`);
208
+ return matches;
209
+ }
210
+ getByText(matcher) {
211
+ return this.requireOne(this.queryAllByText(matcher), `text ${String(matcher)}`);
212
+ }
213
+ findByText(matcher, timeout) {
214
+ return this.findOne(() => this.queryByText(matcher), `text ${String(matcher)}`, timeout);
215
+ }
216
+ findAllByText(matcher, timeout) {
217
+ return this.findAll(() => this.queryAllByText(matcher), `text ${String(matcher)}`, timeout);
218
+ }
219
+ queryAllByRole(role, options = {}) {
220
+ return this.wrap(this.allElements().filter((node) => {
221
+ if (implicitRole(node) !== role) return false;
222
+ return options.name == null || matchesText(accessibleName(node), options.name);
223
+ }));
224
+ }
225
+ queryByRole(role, options = {}) {
226
+ const matches = this.queryAllByRole(role, options);
227
+ if (matches.length > 1) throw new Error(`Found multiple role ${role} nodes in the mini-program tree.`);
228
+ return matches[0] ?? null;
229
+ }
230
+ getAllByRole(role, options = {}) {
231
+ const matches = this.queryAllByRole(role, options);
232
+ if (matches.length === 0) throw new Error(`Unable to find role ${role} in the mini-program tree.`);
233
+ return matches;
234
+ }
235
+ getByRole(role, options = {}) {
236
+ return this.requireOne(this.queryAllByRole(role, options), `role ${role}`);
237
+ }
238
+ findByRole(role, options = {}, timeout) {
239
+ return this.findOne(() => this.queryByRole(role, options), `role ${role}`, timeout);
240
+ }
241
+ findAllByRole(role, options = {}, timeout) {
242
+ return this.findAll(() => this.queryAllByRole(role, options), `role ${role}`, timeout);
243
+ }
244
+ queryAllByTestId(testId) {
245
+ return this.queryAllByAttribute("data-testid", testId);
246
+ }
247
+ queryByTestId(testId) {
248
+ return this.queryByAttribute("data-testid", testId);
249
+ }
250
+ getAllByTestId(testId) {
251
+ return this.getAllByAttribute("data-testid", testId);
252
+ }
253
+ getByTestId(testId) {
254
+ return this.getByAttribute("data-testid", testId);
255
+ }
256
+ findByTestId(testId, timeout) {
257
+ return this.findOne(() => this.queryByTestId(testId), `test id ${testId}`, timeout);
258
+ }
259
+ findAllByTestId(testId, timeout) {
260
+ return this.findAll(() => this.queryAllByTestId(testId), `test id ${testId}`, timeout);
261
+ }
262
+ queryAllByAttribute(name, value) {
263
+ return this.wrap(this.allElements().filter((node) => {
264
+ const attribute = node.attrs[name];
265
+ return attribute != null && (value == null || matchesText(attribute, value));
266
+ }));
267
+ }
268
+ queryByAttribute(name, value) {
269
+ const matches = this.queryAllByAttribute(name, value);
270
+ if (matches.length > 1) throw new Error(`Found multiple attribute ${name} nodes in the mini-program tree.`);
271
+ return matches[0] ?? null;
272
+ }
273
+ getAllByAttribute(name, value) {
274
+ const matches = this.queryAllByAttribute(name, value);
275
+ if (matches.length === 0) throw new Error(`Unable to find attribute ${name} in the mini-program tree.`);
276
+ return matches;
277
+ }
278
+ getByAttribute(name, value) {
279
+ return this.requireOne(this.queryAllByAttribute(name, value), `attribute ${name}`);
280
+ }
281
+ findByAttribute(name, value, timeout) {
282
+ return this.findOne(() => this.queryByAttribute(name, value), `attribute ${name}`, timeout);
283
+ }
284
+ findAllByAttribute(name, value, timeout) {
285
+ return this.findAll(() => this.queryAllByAttribute(name, value), `attribute ${name}`, timeout);
286
+ }
287
+ async $(selector) {
288
+ return await this.page.$(selector);
289
+ }
290
+ async $$(selector) {
291
+ return await this.page.$$(selector);
292
+ }
293
+ };
294
+ //#endregion
295
+ //#region src/user.ts
296
+ var MiniProgramUser = class {
297
+ page;
298
+ screen;
299
+ settle;
300
+ constructor(page, screen, settle) {
301
+ this.page = page;
302
+ this.screen = screen;
303
+ this.settle = settle;
304
+ }
305
+ async nodeHandle(node) {
306
+ const handle = await this.page.$(`[data-sim-node="${node.nodeId}"]`);
307
+ if (!handle) throw new Error("The mini-program node is no longer available for interaction.");
308
+ return handle;
309
+ }
310
+ async finish() {
311
+ await this.settle();
312
+ await this.screen.refresh();
313
+ }
314
+ async tap(node, event = {}) {
315
+ await (await this.nodeHandle(node)).tap(event);
316
+ await this.finish();
317
+ }
318
+ async input(node, value, event = {}) {
319
+ await (await this.nodeHandle(node)).input(value, event);
320
+ await this.finish();
321
+ }
322
+ async change(node, value, event = {}) {
323
+ await (await this.nodeHandle(node)).change(value, event);
324
+ await this.finish();
325
+ }
326
+ async blur(node, value, event = {}) {
327
+ await (await this.nodeHandle(node)).blur(value, event);
328
+ await this.finish();
329
+ }
330
+ async trigger(node, eventName, event = {}) {
331
+ await (await this.nodeHandle(node)).trigger(eventName, event);
332
+ await this.finish();
333
+ }
334
+ };
335
+ //#endregion
336
+ //#region src/project.ts
337
+ const COMPONENT_HOST_ROUTE = "__mpcore__/component-host/index";
338
+ function normalizeComponentPath(componentPath) {
339
+ return componentPath.trim().replace(/^\/+/, "").replace(/\.(?:js|json|wxml)$/, "");
340
+ }
341
+ function serializeProperties(properties) {
342
+ try {
343
+ return JSON.stringify(properties).replaceAll("<", "\\u003c");
344
+ } catch (error) {
345
+ throw new TypeError("Component properties must be serializable.", { cause: error });
346
+ }
347
+ }
348
+ function createSlotMarkup(slots) {
349
+ return Object.entries(slots).map(([name, content]) => {
350
+ return name === "default" ? content : `<block slot="${name}">${content}</block>`;
351
+ }).join("");
352
+ }
353
+ function createComponentHostProject(project, componentPath, options) {
354
+ const root = project.miniprogramRootPath;
355
+ const eventEntries = Object.keys(options.on ?? {}).map((eventName, index) => ({
356
+ eventName,
357
+ methodName: `__mpcoreEvent${index}`
358
+ }));
359
+ const eventMethods = eventEntries.map(({ eventName, methodName }) => {
360
+ return `${methodName}(event) { globalThis.__mpcoreRecordEvent(${JSON.stringify(eventName)}, event?.detail, event) }`;
361
+ }).join(",\n");
362
+ const propertyBindings = Object.keys(options.properties ?? {}).map((key) => `${key}="{{__mpcoreProperties.${key}}}"`).join(" ");
363
+ const eventBindings = eventEntries.map(({ eventName, methodName }) => `bind:${eventName}="${methodName}"`).join(" ");
364
+ const hostRoot = path.resolve(root, COMPONENT_HOST_ROUTE);
365
+ const overlay = createMemoryArtifactSource([
366
+ [path.resolve(root, "app.json"), JSON.stringify({ pages: [COMPONENT_HOST_ROUTE] })],
367
+ [path.resolve(root, "app.js"), "App({})\n"],
368
+ [`${hostRoot}.json`, JSON.stringify({ usingComponents: { "mpcore-subject": `/${normalizeComponentPath(componentPath)}` } })],
369
+ [`${hostRoot}.js`, `Page({
370
+ data: { __mpcoreProperties: ${serializeProperties(options.properties ?? {})} },
371
+ ${eventMethods}
372
+ })\n`],
373
+ [`${hostRoot}.wxml`, `<mpcore-subject id="mpcore-subject" ${propertyBindings} ${eventBindings}>${createSlotMarkup(options.slots ?? {})}</mpcore-subject>`]
374
+ ]);
375
+ return createProjectDescriptor({
376
+ appConfig: { pages: [COMPONENT_HOST_ROUTE] },
377
+ appConfigPath: path.resolve(root, "app.json"),
378
+ artifactSource: createOverlayArtifactSource(overlay, project.artifactSource),
379
+ miniprogramRoot: project.miniprogramRoot,
380
+ miniprogramRootPath: root,
381
+ projectConfigFiles: project.projectConfigFiles,
382
+ projectPath: project.projectPath
383
+ });
384
+ }
385
+ function formatDiagnostic(entry) {
386
+ return entry.args.map((arg) => {
387
+ if (arg && typeof arg === "object" && "message" in arg && typeof arg.message === "string") return "stack" in arg && typeof arg.stack === "string" ? arg.stack : arg.message;
388
+ return typeof arg === "string" ? arg : JSON.stringify(arg);
389
+ }).join(" ");
390
+ }
391
+ var MiniProgramTestProject = class {
392
+ options;
393
+ baseProject;
394
+ renders = /* @__PURE__ */ new Set();
395
+ constructor(options) {
396
+ this.options = options;
397
+ if (options.platform && options.platform.name !== "wechat") throw new Error(`Unsupported mini-program test platform: ${options.platform.name}`);
398
+ this.baseProject = options.artifact.miniprogramRootPath ? createArtifactProject({
399
+ appConfigPath: options.artifact.appConfigPath,
400
+ miniprogramRootPath: options.artifact.miniprogramRootPath,
401
+ projectPath: options.artifact.projectPath
402
+ }) : loadProject(options.artifact.projectPath);
403
+ }
404
+ async render(project, route, on = {}) {
405
+ const eventLog = /* @__PURE__ */ new Map();
406
+ const session = createHeadlessSession({
407
+ globals: { __mpcoreRecordEvent(eventName, detail, event) {
408
+ const entries = eventLog.get(eventName) ?? [];
409
+ entries.push(detail);
410
+ eventLog.set(eventName, entries);
411
+ on?.[eventName]?.(detail, event);
412
+ } },
413
+ project,
414
+ strictHostMocks: true
415
+ });
416
+ applyWxMock(session, this.options.host);
417
+ const page = new HeadlessTestingPageHandle(project, session.reLaunch(route), session);
418
+ const screen = new MiniProgramScreen(page, await page.snapshot());
419
+ const assertDiagnostics = () => {
420
+ const errors = session.getDiagnostics().filter((entry) => {
421
+ return entry.level === "exception" || entry.level === "error" && this.options.failOnConsoleError !== false;
422
+ });
423
+ if (errors.length > 0) throw new Error(`Mini-program runtime reported errors:\n${errors.map(formatDiagnostic).join("\n")}`);
424
+ };
425
+ const settle = async () => {
426
+ await Promise.resolve();
427
+ await Promise.resolve();
428
+ assertDiagnostics();
429
+ };
430
+ const user = new MiniProgramUser(page, screen, settle);
431
+ let closed = false;
432
+ const result = {
433
+ close: async () => {
434
+ if (closed) return;
435
+ closed = true;
436
+ session.close();
437
+ this.renders.delete(result);
438
+ assertDiagnostics();
439
+ },
440
+ diagnostics: () => session.getDiagnostics(),
441
+ emitted: (eventName) => [...eventLog.get(eventName) ?? []],
442
+ page,
443
+ screen,
444
+ user
445
+ };
446
+ this.renders.add(result);
447
+ await settle();
448
+ return result;
449
+ }
450
+ async renderPage(route) {
451
+ return await this.render(this.baseProject, route);
452
+ }
453
+ async renderComponent(componentPath, options = {}) {
454
+ const project = createComponentHostProject(this.baseProject, componentPath, options);
455
+ return await this.render(project, `/${COMPONENT_HOST_ROUTE}`, options.on);
456
+ }
457
+ async close() {
458
+ let firstError;
459
+ for (const render of this.renders) try {
460
+ await render.close();
461
+ } catch (error) {
462
+ firstError ??= error;
463
+ }
464
+ this.renders.clear();
465
+ if (firstError) throw firstError;
466
+ }
467
+ };
468
+ function artifactFromProject(projectPath) {
469
+ return { projectPath };
470
+ }
471
+ function createTestProject(options) {
472
+ return new MiniProgramTestProject({
473
+ ...options,
474
+ platform: options.platform ?? wechat$1()
475
+ });
476
+ }
477
+ //#endregion
478
+ export { MiniProgramNode, MiniProgramScreen, MiniProgramTestProject, MiniProgramUser, applyWxMock, artifactFromProject, createTestProject, createWxMock, mpcoreMatchers, toBeInTheMiniProgram, toHaveAttribute, toHaveDataset, toHaveEmitted, toHaveTextContent, wechat };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@mpcore/test",
3
+ "type": "module",
4
+ "version": "0.0.0",
5
+ "description": "Testing Library-style utilities for compiled WeChat mini-program artifacts",
6
+ "author": "ice breaker <1324318532@qq.com>",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/weapp-vite/weapp-vite.git",
11
+ "directory": "mpcore/packages/test"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/weapp-vite/weapp-vite/issues"
15
+ },
16
+ "keywords": [
17
+ "mpcore",
18
+ "weapp",
19
+ "mini-program",
20
+ "testing",
21
+ "vitest"
22
+ ],
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.mts",
27
+ "import": "./dist/index.mjs"
28
+ }
29
+ },
30
+ "main": "./dist/index.mjs",
31
+ "module": "./dist/index.mjs",
32
+ "types": "./dist/index.d.mts",
33
+ "files": [
34
+ "README.md",
35
+ "dist"
36
+ ],
37
+ "engines": {
38
+ "node": "^20.19.0 || >=22.12.0"
39
+ },
40
+ "dependencies": {
41
+ "@mpcore/simulator": "0.2.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org"
46
+ },
47
+ "scripts": {
48
+ "dev": "tsdown -w",
49
+ "build": "tsdown",
50
+ "test": "vitest run",
51
+ "test:dev": "vitest",
52
+ "test:types": "pnpm build && tsd",
53
+ "typecheck": "tsc --noEmit"
54
+ }
55
+ }