@vobs/test-utils 0.1.0 → 1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 vobsjs
3
+ Copyright (c) 2026 vobs contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @vobs/test-utils
2
+
3
+ In-memory test renderer and assertion helpers for mounting Vobs components without a DOM.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @vobs/test-utils
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { expect, it } from 'vitest'
15
+ import { addEventListener, bindText, createElement, createText, insertBefore, state } from '@vobs/vobs'
16
+ import { flushPromises, mount } from '@vobs/test-utils'
17
+
18
+ it('increments on click', async () => {
19
+ const view = mount(() => {
20
+ const count = state(0)
21
+ const button = createElement('button')
22
+ const text = createText('')
23
+ insertBefore(button, text, null)
24
+ bindText(text, () => `count: ${count.value}`)
25
+ addEventListener(button, 'click', () => { count.value++ })
26
+ return button
27
+ })
28
+
29
+ view.fireEvent('click', { target: 'button' })
30
+ await flushPromises()
31
+
32
+ expect(view.queryByText('count: 1')).toBeTruthy()
33
+ view.destroy()
34
+ })
35
+ ```
36
+
37
+ ## API
38
+
39
+ | Signature | Description |
40
+ | --- | --- |
41
+ | `mount(render, options?)` | Mount a render function into an in-memory renderer; returns a `MountedTestApp`. `options.plugins` accepts `VobsPlugin[]`. |
42
+ | `createTestRenderer()` | Standalone `TestRenderer` exposing `container`, `renderer` and query helpers. |
43
+ | `view.queryByText(text)` | Find a text node with exact content. |
44
+ | `view.queryByTag(tag)` | Find the first element with that tag. |
45
+ | `view.queryByAttr(name, value)` | Find the first element with a matching attribute. |
46
+ | `view.fireEvent(event, { target })` | Invoke the handler registered for `event` on the element with that tag. |
47
+ | `view.update()` / `view.destroy()` | Flush an app update / unmount the app. |
48
+ | `act(fn)` | Run updates as one deterministic reactive transaction (`batch`). |
49
+ | `flushEffects()` | Flush queued effects without relying on implementation-specific timers. |
50
+ | `flushPromises()` | Resolve pending promise continuations, then flush reactive effects. |
51
+ | `measure(label, fn)` | Measure synchronous execution; returns `{ label, duration, result }`. |
52
+
53
+ ## Types
54
+
55
+ TestNode, TestElement, TestText, TestComment, TestRenderer, MountedTestApp, PerformanceMeasurement
package/package.json CHANGED
@@ -1,46 +1,21 @@
1
1
  {
2
- "name": "@vobs/test-utils",
3
- "version": "0.1.0",
4
- "description": "Mount, hydrate and cleanup test harnesses for vobs applications and components.",
5
- "type": "module",
6
- "publishConfig": {
7
- "access": "public"
8
- },
9
2
  "license": "MIT",
10
- "author": "vobsjs",
11
- "repository": {
12
- "type": "git",
13
- "url": "git+https://github.com/vobsjs/vobs.git",
14
- "directory": "packages/tools/test-utils"
15
- },
16
- "bugs": {
17
- "url": "https://github.com/vobsjs/vobs/issues"
18
- },
19
- "homepage": "https://github.com/vobsjs/vobs#readme",
20
- "dependencies": {
21
- "@vobs/icons": "0.1.0",
22
- "@vobs/i18n": "0.1.0",
23
- "@vobs/reactivity": "0.1.0",
24
- "@vobs/runtime-core": "0.1.0",
25
- "@vobs/runtime-dom": "0.1.0",
26
- "@vobs/server-renderer": "0.1.0",
27
- "@vobs/ui": "0.1.0"
28
- },
29
3
  "files": [
30
- "dist"
4
+ "src",
5
+ "README.md",
6
+ "LICENSE"
31
7
  ],
8
+ "name": "@vobs/test-utils",
9
+ "version": "1.0.0",
10
+ "type": "module",
11
+ "main": "src/index.ts",
12
+ "types": "src/index.ts",
32
13
  "exports": {
33
- ".": {
34
- "types": "./dist/index.d.ts",
35
- "import": "./dist/index.js"
36
- },
37
- "./package.json": "./package.json"
14
+ ".": "./src/index.ts"
38
15
  },
39
- "types": "./dist/index.d.ts",
40
- "module": "./dist/index.js",
41
- "main": "./dist/index.js",
42
- "sideEffects": false,
43
- "engines": {
44
- "node": ">=20.19.0"
16
+ "dependencies": {
17
+ "@vobs/dom": "1.0.0",
18
+ "@vobs/runtime": "1.0.0",
19
+ "@vobs/vobs": "1.0.0"
45
20
  }
46
21
  }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createElement, createText, createVobs, effect, ErrorBoundary, insertErrorBoundary, state } from '@vobs/vobs'
3
+
4
+ describe('insertErrorBoundary', () => {
5
+ it('捕获子渲染分支的错误并可重试', () => {
6
+ const broken = state(true)
7
+ let retry!: () => void
8
+ const app = createVobs({
9
+ render: () => {
10
+ const root = createElement('div')
11
+ insertErrorBoundary(root, null, {
12
+ children: () => {
13
+ if (broken.value) throw new Error('broken')
14
+ return createText('ready')
15
+ },
16
+ fallback: (error, nextRetry) => {
17
+ retry = nextRetry
18
+ return createText(`error: ${error.message}`)
19
+ }
20
+ })
21
+ return root
22
+ }
23
+ })
24
+ const container = document.createElement('div')
25
+ app.mount(container)
26
+ app.update()
27
+ expect(container.textContent).toBe('error: broken')
28
+
29
+ broken.value = false
30
+ retry()
31
+ app.update()
32
+ expect(container.textContent).toBe('ready')
33
+ app.destroy()
34
+ })
35
+
36
+ it('捕获子分支中创建的 effect 的错误', () => {
37
+ const broken = state(false)
38
+ let cleanups = 0
39
+ const app = createVobs({
40
+ render: () => {
41
+ const root = createElement('div')
42
+ insertErrorBoundary(root, null, {
43
+ children: () => {
44
+ const text = createText('ready')
45
+ effect(() => {
46
+ if (broken.value) throw new Error('effect failure')
47
+ return () => { cleanups++ }
48
+ })
49
+ return text
50
+ },
51
+ fallback: error => createText(`error: ${error.message}`)
52
+ })
53
+ return root
54
+ }
55
+ })
56
+ const container = document.createElement('div')
57
+ app.mount(container)
58
+ broken.value = true
59
+ app.update()
60
+ expect(container.textContent).toBe('error: effect failure')
61
+ expect(cleanups).toBe(1)
62
+ app.destroy()
63
+ })
64
+
65
+ it('组件形态复用同一边界协议,并在路由 key 变化时恢复子树', () => {
66
+ const broken = state(true)
67
+ let retry!: () => void
68
+ const app = createVobs({
69
+ render: () => ErrorBoundary({
70
+ children: () => {
71
+ if (broken.value) throw new Error('broken')
72
+ return createText('ready')
73
+ },
74
+ fallback: (error, nextRetry) => {
75
+ retry = nextRetry
76
+ return createText(`error: ${error.message}`)
77
+ }
78
+ })
79
+ })
80
+ const container = document.createElement('div')
81
+ app.mount(container)
82
+ app.update()
83
+ expect(container.textContent).toBe('error: broken')
84
+
85
+ broken.value = false
86
+ retry()
87
+ app.update()
88
+ expect(container.textContent).toBe('ready')
89
+ app.destroy()
90
+ })
91
+ })
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createElement, createFragment, createText, createVobs, insertBefore, insertDynamic, state } from '@vobs/vobs'
3
+
4
+ describe('Fragment', () => {
5
+ it('渲染多个同级节点且不产生元素包装', () => {
6
+ const app = createVobs({
7
+ render: () => createFragment((parent, anchor) => {
8
+ insertBefore(parent, createText('one'), anchor)
9
+ insertBefore(parent, createText('two'), anchor)
10
+ })
11
+ })
12
+ const container = document.createElement('div')
13
+ app.mount(container)
14
+
15
+ expect(container.textContent).toBe('onetwo')
16
+ expect(container.children).toHaveLength(0)
17
+ app.destroy()
18
+ })
19
+
20
+ it('能作为动态分支插入和移除整个节点范围', () => {
21
+ const visible = state(true)
22
+ const app = createVobs({
23
+ render: () => {
24
+ const root = createElement('div')
25
+ insertDynamic(root, null, () => visible.value ? createFragment((parent, anchor) => {
26
+ insertBefore(parent, createText('one'), anchor)
27
+ insertBefore(parent, createText('two'), anchor)
28
+ }) : null)
29
+ return root
30
+ }
31
+ })
32
+ const container = document.createElement('div')
33
+ app.mount(container)
34
+ expect(container.textContent).toBe('onetwo')
35
+
36
+ visible.value = false
37
+ app.update()
38
+ expect(container.textContent).toBe('')
39
+ app.destroy()
40
+ })
41
+ })
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { state } from '@vobs/vobs'
3
+ import { addEventListener, bindText, createElement, createText, insertBefore } from '@vobs/dom'
4
+ import { mount } from './index'
5
+
6
+ describe('test-utils', () => {
7
+ it('在内存渲染器中挂载、交互和更新', () => {
8
+ const count = state(0)
9
+ const app = mount(() => {
10
+ const button = createElement('button')
11
+ const text = createText('')
12
+ insertBefore(button, text, null)
13
+ bindText(text, () => `count: ${count.value}`)
14
+ addEventListener(button, 'click', () => { count.value++ })
15
+ return button
16
+ })
17
+
18
+ expect(app.queryByText('count: 0')).toBeTruthy()
19
+ app.fireEvent('click', { target: 'button' })
20
+ app.update()
21
+ expect(app.queryByText('count: 1')).toBeTruthy()
22
+
23
+ app.destroy()
24
+ expect(app.container.children).toHaveLength(0)
25
+ })
26
+ })
package/src/index.ts ADDED
@@ -0,0 +1,212 @@
1
+ import {
2
+ createVobs,
3
+ type VobsApp,
4
+ type VobsConfig,
5
+ type VobsPlugin
6
+ } from '@vobs/vobs'
7
+ import { batch, scheduler } from '@vobs/reactivity'
8
+ import type { VobsRenderer } from '@vobs/runtime'
9
+
10
+ export type TestNode = TestElement | TestText | TestComment
11
+
12
+ export interface TestElement {
13
+ type: 'element'
14
+ tag: string
15
+ attrs: Record<string, string>
16
+ props: Record<string, unknown>
17
+ events: Record<string, EventListener>
18
+ children: TestNode[]
19
+ parent: TestElement | null
20
+ }
21
+
22
+ export interface TestText {
23
+ type: 'text'
24
+ content: string
25
+ parent: TestElement | null
26
+ }
27
+
28
+ export interface TestComment {
29
+ type: 'comment'
30
+ content: string
31
+ parent: TestElement | null
32
+ }
33
+
34
+ export interface TestRenderer {
35
+ readonly container: TestElement
36
+ readonly renderer: VobsRenderer<TestNode, TestText, TestElement, TestComment>
37
+ queryByText(text: string): TestNode | null
38
+ queryByTag(tag: string): TestElement | null
39
+ queryByAttr(name: string, value: string): TestElement | null
40
+ }
41
+
42
+ export interface MountedTestApp {
43
+ readonly app: VobsApp<TestNode>
44
+ readonly container: TestElement
45
+ update(): void
46
+ destroy(): void
47
+ queryByText(text: string): TestNode | null
48
+ queryByTag(tag: string): TestElement | null
49
+ queryByAttr(name: string, value: string): TestElement | null
50
+ fireEvent(event: string, options: { target: string }): void
51
+ }
52
+
53
+ /** Execute test updates as one deterministic reactive transaction. */
54
+ export function act<T>(fn: () => T): T {
55
+ return batch(fn)
56
+ }
57
+
58
+ /** Flush queued effects without relying on implementation-specific timers. */
59
+ export function flushEffects(): void {
60
+ scheduler.flush()
61
+ }
62
+
63
+ /** Resolve pending Promise continuations and then flush reactive effects. */
64
+ export async function flushPromises(): Promise<void> {
65
+ await Promise.resolve()
66
+ scheduler.flush()
67
+ }
68
+
69
+ export interface PerformanceMeasurement<T = unknown> {
70
+ readonly label: string
71
+ readonly duration: number
72
+ readonly result: T
73
+ }
74
+
75
+ /** Small deterministic measurement helper for component/runtime baselines. */
76
+ export function measure<T>(label: string, fn: () => T): PerformanceMeasurement<T> {
77
+ const started = typeof performance !== 'undefined' ? performance.now() : Date.now()
78
+ const result = fn()
79
+ const ended = typeof performance !== 'undefined' ? performance.now() : Date.now()
80
+ return { label, duration: Math.max(0, ended - started), result }
81
+ }
82
+
83
+ export function createTestRenderer(): TestRenderer {
84
+ const container = createElementNode('root')
85
+ const renderer: VobsRenderer<TestNode, TestText, TestElement, TestComment> = {
86
+ createText(content: string): TestText {
87
+ return { type: 'text', content, parent: null }
88
+ },
89
+
90
+ createElement(tag: string): TestElement {
91
+ return createElementNode(tag)
92
+ },
93
+
94
+ createComment(content: string): TestComment {
95
+ return { type: 'comment', content, parent: null }
96
+ },
97
+
98
+ insertBefore(parent: TestElement, child: TestNode, anchor: TestNode | null): void {
99
+ if (child.parent) removeChildNode(child.parent, child)
100
+ const index = anchor ? parent.children.indexOf(anchor) : -1
101
+ if (index >= 0) parent.children.splice(index, 0, child)
102
+ else parent.children.push(child)
103
+ child.parent = parent
104
+ },
105
+
106
+ removeChild(parent: TestElement, child: TestNode): void {
107
+ removeChildNode(parent, child)
108
+ },
109
+
110
+ setTextContent(node: TestText, content: string): void {
111
+ node.content = content
112
+ },
113
+
114
+ setProperty(node: TestElement, key: string, value: unknown): void {
115
+ node.props[key] = value
116
+ },
117
+
118
+ setAttribute(node: TestElement, key: string, value: string): void {
119
+ node.attrs[key] = value
120
+ },
121
+
122
+ addEventListener(node: TestElement, event: string, handler: EventListener): void {
123
+ node.events[event] = handler
124
+ },
125
+
126
+ removeEventListener(node: TestElement, event: string, handler: EventListener): void {
127
+ if (node.events[event] === handler) delete node.events[event]
128
+ },
129
+
130
+ nextSibling(node: TestNode): TestNode | null {
131
+ const parent = node.parent
132
+ if (!parent) return null
133
+ const index = parent.children.indexOf(node)
134
+ return index >= 0 ? parent.children[index + 1] ?? null : null
135
+ },
136
+
137
+ clear(node: TestElement): void {
138
+ for (const child of node.children) child.parent = null
139
+ node.children.length = 0
140
+ }
141
+ }
142
+
143
+ return {
144
+ container,
145
+ renderer,
146
+ queryByText: text => findNode(container, node => node.type === 'text' && node.content === text),
147
+ queryByTag: tag => findNode(container, node => node.type === 'element' && node.tag === tag) as TestElement | null,
148
+ queryByAttr: (name, value) => findNode(
149
+ container,
150
+ node => node.type === 'element' && node.attrs[name] === value
151
+ ) as TestElement | null
152
+ }
153
+ }
154
+
155
+ export function mount(
156
+ render: VobsConfig['render'],
157
+ options: { plugins?: VobsPlugin[] } = {}
158
+ ): MountedTestApp {
159
+ const testRenderer = createTestRenderer()
160
+ const app = createVobs({
161
+ render,
162
+ renderer: testRenderer.renderer,
163
+ plugins: options.plugins
164
+ })
165
+ app.mount(testRenderer.container)
166
+
167
+ return {
168
+ app,
169
+ container: testRenderer.container,
170
+ update: () => app.update(),
171
+ destroy: () => app.destroy(),
172
+ queryByText: testRenderer.queryByText,
173
+ queryByTag: testRenderer.queryByTag,
174
+ queryByAttr: testRenderer.queryByAttr,
175
+ fireEvent(event, { target }): void {
176
+ const node = testRenderer.queryByTag(target)
177
+ const handler = node?.events[event]
178
+ if (!handler) throw new Error(`Vobs test-utils: 未找到 ${target} 的 ${event} 事件处理器`)
179
+ handler.call(node, { type: event, target: node } as unknown as Event)
180
+ }
181
+ }
182
+ }
183
+
184
+ function createElementNode(tag: string): TestElement {
185
+ return {
186
+ type: 'element',
187
+ tag,
188
+ attrs: {},
189
+ props: {},
190
+ events: {},
191
+ children: [],
192
+ parent: null
193
+ }
194
+ }
195
+
196
+ function removeChildNode(parent: TestElement, child: TestNode): void {
197
+ const index = parent.children.indexOf(child)
198
+ if (index < 0) throw new Error('Vobs test-utils: 节点不属于指定父节点')
199
+ parent.children.splice(index, 1)
200
+ child.parent = null
201
+ }
202
+
203
+ function findNode(root: TestElement, predicate: (node: TestNode) => boolean): TestNode | null {
204
+ for (const child of root.children) {
205
+ if (predicate(child)) return child
206
+ if (child.type === 'element') {
207
+ const result = findNode(child, predicate)
208
+ if (result) return result
209
+ }
210
+ }
211
+ return null
212
+ }
package/dist/index.d.ts DELETED
@@ -1,57 +0,0 @@
1
- /** @license MIT
2
- * Copyright (c) 2026 vobsjs
3
- * @vobs/test-utils
4
- */
5
- import { type I18nInstance, type I18nOptions } from '@vobs/i18n';
6
- import { type IconDefinition, type IconRegistry } from '@vobs/icons';
7
- import type { PageEnvironment, PageInstance, TemplateEnvironment } from '@vobs/runtime-core';
8
- import { type PageDefinition } from '@vobs/runtime-dom';
9
- import { type RenderPageOptions, type RenderPageResult } from '@vobs/server-renderer';
10
- import { type ThemeController, type ThemeControllerOptions, type ThemeTarget } from '@vobs/ui';
11
- import { type UiEnvironment } from '@vobs/ui/icons';
12
- export interface TestEnvironmentOptions extends Partial<PageEnvironment> {
13
- readonly baseUrl?: string;
14
- readonly errors?: unknown[];
15
- }
16
- export interface TestContainer extends Element {
17
- readonly items: Element[];
18
- }
19
- export interface TestPageHarness {
20
- readonly container: TestContainer;
21
- readonly environment: PageEnvironment;
22
- readonly instance: PageInstance;
23
- cleanup(): void;
24
- }
25
- export interface UiTestContextOptions {
26
- readonly i18n?: I18nOptions;
27
- readonly icons?: Readonly<Record<string, IconDefinition>>;
28
- readonly theme?: Omit<ThemeControllerOptions, 'target'>;
29
- }
30
- export interface UiTestContext {
31
- readonly i18n: I18nInstance;
32
- readonly icons: IconRegistry;
33
- readonly ui: UiEnvironment;
34
- readonly themeTarget: TestThemeTarget;
35
- readonly theme: ThemeController;
36
- cleanup(): void;
37
- }
38
- export interface TestThemeTarget extends ThemeTarget {
39
- readonly attributes: Readonly<Record<string, string>>;
40
- readonly variables: ReadonlyMap<string, string>;
41
- }
42
- export declare function createTestEnvironment(options?: TestEnvironmentOptions): PageEnvironment;
43
- export declare function createTestContainer(initialRoot?: Element): TestContainer;
44
- export declare function mountTestPage(page: PageDefinition, options?: {
45
- readonly container?: TestContainer;
46
- readonly environment?: TestEnvironmentOptions;
47
- }): Promise<TestPageHarness>;
48
- export declare function hydrateTestPage(page: PageDefinition, options: {
49
- readonly container: TestContainer;
50
- readonly environment?: TestEnvironmentOptions;
51
- }): Promise<TestPageHarness>;
52
- export declare function renderTestPage<Scope extends object, InitialState = unknown>(options: Omit<RenderPageOptions<Scope, InitialState>, 'environment'> & {
53
- readonly environment?: TemplateEnvironment;
54
- }): RenderPageResult;
55
- export declare function createUiTestContext(options?: UiTestContextOptions): UiTestContext;
56
- export declare function createThemeTestTarget(): TestThemeTarget;
57
- export declare function cleanup(): void;
package/dist/index.js DELETED
@@ -1,146 +0,0 @@
1
- /** @license MIT
2
- * Copyright (c) 2026 vobsjs
3
- * @vobs/test-utils
4
- */
5
- import { createI18n } from '@vobs/i18n';
6
- import { createIconRegistry } from '@vobs/icons';
7
- import { Scheduler } from '@vobs/reactivity';
8
- import { createDefaultUrlPolicy } from '@vobs/runtime-dom';
9
- import { renderPage } from '@vobs/server-renderer';
10
- import { createThemeController, } from '@vobs/ui';
11
- import { createUiEnvironment } from '@vobs/ui/icons';
12
- const cleanups = new Set();
13
- export function createTestEnvironment(options = {}) {
14
- const errors = options.errors;
15
- const environment = {
16
- scheduler: options.scheduler ?? new Scheduler(),
17
- security: options.security ?? {
18
- urlPolicy: createDefaultUrlPolicy(options.baseUrl ?? 'https://example.test/'),
19
- },
20
- onError: options.onError ??
21
- ((error) => {
22
- errors?.push(error);
23
- if (errors === undefined)
24
- throw error;
25
- }),
26
- ...(options.components === undefined ? {} : { components: options.components }),
27
- ...(options.slots === undefined ? {} : { slots: options.slots }),
28
- ...(options.route === undefined ? {} : { route: options.route }),
29
- ...(options.initialState === undefined ? {} : { initialState: options.initialState }),
30
- };
31
- return environment;
32
- }
33
- export function createTestContainer(initialRoot) {
34
- const items = initialRoot === undefined ? [] : [initialRoot];
35
- const appendNodes = (nodes) => {
36
- for (const node of nodes) {
37
- if (typeof node !== 'string')
38
- items.push(node);
39
- }
40
- };
41
- return {
42
- items,
43
- get firstElementChild() {
44
- return items[0] ?? null;
45
- },
46
- append(...nodes) {
47
- appendNodes(nodes);
48
- },
49
- replaceChildren(...nodes) {
50
- items.length = 0;
51
- appendNodes(nodes);
52
- },
53
- querySelectorAll() {
54
- return [];
55
- },
56
- };
57
- }
58
- export async function mountTestPage(page, options = {}) {
59
- const container = options.container ?? createTestContainer();
60
- const environment = createTestEnvironment(options.environment);
61
- const instance = await page.mount(environment);
62
- container.append(instance.root);
63
- return createPageHarness(container, environment, instance);
64
- }
65
- export async function hydrateTestPage(page, options) {
66
- const environment = createTestEnvironment(options.environment);
67
- const instance = await page.hydrate(options.container, environment);
68
- return createPageHarness(options.container, environment, instance);
69
- }
70
- export function renderTestPage(options) {
71
- return renderPage({
72
- ...options,
73
- environment: options.environment ?? createTestEnvironment(),
74
- });
75
- }
76
- export function createUiTestContext(options = {}) {
77
- const i18n = createI18n(options.i18n ?? { locale: 'en-US', messages: {} });
78
- const icons = createIconRegistry(options.icons ?? {});
79
- const ui = createUiEnvironment({ icons });
80
- const themeTarget = createThemeTestTarget();
81
- const theme = createThemeController({
82
- target: themeTarget,
83
- ...(options.theme ?? {}),
84
- });
85
- const cleanup = trackCleanup(() => {
86
- theme.destroy();
87
- });
88
- return {
89
- i18n,
90
- icons,
91
- ui,
92
- themeTarget,
93
- theme,
94
- cleanup,
95
- };
96
- }
97
- export function createThemeTestTarget() {
98
- const attributes = {};
99
- const variables = new Map();
100
- return {
101
- attributes,
102
- variables,
103
- setAttribute(name, value) {
104
- attributes[name] = value;
105
- },
106
- style: {
107
- setProperty(name, value) {
108
- variables.set(name, value);
109
- },
110
- removeProperty(name) {
111
- const current = variables.get(name) ?? '';
112
- variables.delete(name);
113
- return current;
114
- },
115
- },
116
- };
117
- }
118
- export function cleanup() {
119
- const pending = Array.from(cleanups).reverse();
120
- cleanups.clear();
121
- for (const dispose of pending)
122
- dispose();
123
- }
124
- function createPageHarness(container, environment, instance) {
125
- const cleanupHarness = trackCleanup(() => {
126
- instance.destroy();
127
- });
128
- return {
129
- container,
130
- environment,
131
- instance,
132
- cleanup: cleanupHarness,
133
- };
134
- }
135
- function trackCleanup(dispose) {
136
- let active = true;
137
- const cleanupOnce = () => {
138
- if (!active)
139
- return;
140
- active = false;
141
- cleanups.delete(cleanupOnce);
142
- dispose();
143
- };
144
- cleanups.add(cleanupOnce);
145
- return cleanupOnce;
146
- }