@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 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
@@ -5,7 +5,7 @@ interface RoleQueryOptions {
5
5
  name?: TextMatcher;
6
6
  }
7
7
  //#endregion
8
- //#region src/screen/index.d.ts
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 currentSnapshot;
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
- on?: Record<string, (detail: unknown, event: Record<string, any>) => void>;
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<MiniProgramRenderResult>;
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
- export { type RoleQueryOptions, type TextMatcher, wechat };
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/index.ts
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
- currentSnapshot;
392
+ snapshotStore;
135
393
  rootNodeId;
136
394
  constructor(page, snapshot, rootNodeId) {
137
395
  this.page = page;
138
- this.currentSnapshot = snapshot;
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.currentSnapshot = await this.page.snapshot();
451
+ this.snapshotStore.current = await this.page.snapshot();
189
452
  }
190
453
  within(node) {
191
- return new MiniProgramScreen(this.page, this.currentSnapshot, node.nodeId);
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, on = {}) {
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
- const entries = eventLog.get(eventName) ?? [];
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
- session.close();
442
- this.renders.delete(result);
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: (eventName) => [...eventLog.get(eventName) ?? []],
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 result;
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 project = createComponentHostProject(this.baseProject, componentPath, options);
460
- return await this.render(project, `/${COMPONENT_HOST_ROUTE}`, options.on);
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
- export { MiniProgramNode, MiniProgramScreen, MiniProgramTestProject, MiniProgramUser, applyWxMock, artifactFromProject, createTestProject, createWxMock, mpcoreMatchers, toBeInTheMiniProgram, toHaveAttribute, toHaveDataset, toHaveEmitted, toHaveTextContent, wechat };
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.0.0",
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.0.0"
45
+ "@mpcore/simulator": "1.1.0"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public",