@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 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
- const project = createTestProject({
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 result = await project.renderComponent('components/counter/index', {
14
- properties: { value: 1 },
15
- slots: { default: '<text>计数器</text>' },
8
+ const project = createTestProject({
9
+ artifact: artifactFromProject(process.cwd()),
16
10
  })
17
11
 
18
- await result.user.tap(result.screen.getByRole('button', { name: '增加' }))
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
- 查询支持 text、role、test id、attribute、`within()` 及 `get/query/find/getAll/findAll` 变体,并保留 `$` / `$$` 选择器逃生口。`tap/input/change/blur/trigger` 会等待同步更新和 microtask 稳定后刷新逻辑树。
30
+ `renderPage('/pages/index/index')` 挂载页面。组件通过内存宿主页挂载,属性更新复用同一组件实例并保留内部状态;`setData()` 更新组件自身数据。`unmount()` 触发组件卸载而保留测试会话,`close()` 释放整个渲染会话,`project.close()` 关闭项目内全部渲染。
22
31
 
23
- 未匹配的网络 mock 会抛错;未捕获运行时异常和 `console.error` 默认令测试失败。可通过 `diagnostics()` 断言 warning,负向 console 测试可显式设置 `failOnConsoleError: false`。
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
- `@mpcore/test` 为真实微信小程序编译产物提供页面和组件单测环境。它模拟微信宿主与逻辑 WXML 树,不提供浏览器 `document` 或 CSS layout。
34
+ 事件需要显式订阅:`events: ['change']` 从挂载阶段开始收集,`on: { change(detail, event) {} }` 同时收集并回调。挂载后可调用 `result.on('change', listener)`,返回取消订阅函数;省略 listener 时只收集。`off()` 取消对应订阅,已收集的 `emitted()` 历史保留。动态订阅复用组件实例。
26
35
 
27
- ```ts
28
- import { artifactFromProject, createTestProject } from '@mpcore/test'
36
+ 子组件可按标签名递归替换:
29
37
 
30
- const project = createTestProject({
31
- artifact: artifactFromProject(process.cwd()),
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
- const { screen, user } = await project.renderPage('/pages/index/index')
35
- await user.tap(screen.getByRole('button', { name: '增加' }))
36
- expect(screen.getByText('2')).toBeDefined()
50
+ 替换应用于当前渲染中的整个组件依赖树,不修改磁盘编译产物,也不影响同项目的其他渲染。字符串指向替代组件;声明式替身支持 `template`、属性默认值 `properties` 和初始 `data`,默认模板为 `<slot />`。
37
51
 
38
- await project.close()
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 currentSnapshot;
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
- on?: Record<string, (detail: unknown, event: Record<string, any>) => void>;
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<MiniProgramRenderResult>;
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
- //#endregion
159
- export { type RoleQueryOptions, type TextMatcher, wechat };
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
- //#region src/host.ts
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
- //#endregion
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
- //#endregion
64
- //#region src/screen/helpers.ts
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
- //#endregion
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
- currentSnapshot;
382
+ snapshotStore;
135
383
  rootNodeId;
136
384
  constructor(page, snapshot, rootNodeId) {
137
385
  this.page = page;
138
- this.currentSnapshot = snapshot;
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.currentSnapshot = await this.page.snapshot();
441
+ this.snapshotStore.current = await this.page.snapshot();
189
442
  }
190
443
  within(node) {
191
- return new MiniProgramScreen(this.page, this.currentSnapshot, node.nodeId);
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
- //#endregion
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
- //#endregion
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, on = {}) {
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
- const entries = eventLog.get(eventName) ?? [];
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
- session.close();
442
- this.renders.delete(result);
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: (eventName) => [...eventLog.get(eventName) ?? []],
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 result;
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 project = createComponentHostProject(this.baseProject, componentPath, options);
460
- return await this.render(project, `/${COMPONENT_HOST_ROUTE}`, options.on);
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
- //#endregion
483
- export { MiniProgramNode, MiniProgramScreen, MiniProgramTestProject, MiniProgramUser, applyWxMock, artifactFromProject, createTestProject, createWxMock, mpcoreMatchers, toBeInTheMiniProgram, toHaveAttribute, toHaveDataset, toHaveEmitted, toHaveTextContent, wechat };
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.0.0",
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.18.0 || ^24.11.0 || >=26.0.0"
42
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
43
43
  },
44
44
  "dependencies": {
45
- "@mpcore/simulator": "1.0.0"
45
+ "@mpcore/simulator": "1.2.0"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public",