@vobs/test-utils 0.3.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/LICENSE +1 -1
- package/README.md +55 -0
- package/package.json +13 -38
- package/src/error-boundary.test.ts +91 -0
- package/src/fragment.test.ts +41 -0
- package/src/index.test.ts +26 -0
- package/src/index.ts +212 -0
- package/dist/index.d.ts +0 -57
- package/dist/index.js +0 -146
package/LICENSE
CHANGED
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.3.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.3.0",
|
|
22
|
-
"@vobs/reactivity": "0.3.0",
|
|
23
|
-
"@vobs/runtime-core": "0.3.0",
|
|
24
|
-
"@vobs/runtime-dom": "0.3.0",
|
|
25
|
-
"@vobs/i18n": "0.3.0",
|
|
26
|
-
"@vobs/server-renderer": "0.3.0",
|
|
27
|
-
"@vobs/ui": "0.3.0"
|
|
28
|
-
},
|
|
29
3
|
"files": [
|
|
30
|
-
"
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
31
7
|
],
|
|
8
|
+
"name": "@vobs/test-utils",
|
|
9
|
+
"version": "1.1.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
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"engines": {
|
|
44
|
-
"node": ">=22.12.0"
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@vobs/dom": "1.1.0",
|
|
18
|
+
"@vobs/vobs": "1.1.0",
|
|
19
|
+
"@vobs/runtime": "1.1.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<InitialState = unknown> extends Partial<PageEnvironment<InitialState>> {
|
|
13
|
-
readonly baseUrl?: string;
|
|
14
|
-
readonly errors?: unknown[];
|
|
15
|
-
}
|
|
16
|
-
export interface TestContainer extends Element {
|
|
17
|
-
readonly items: Element[];
|
|
18
|
-
}
|
|
19
|
-
export interface TestPageHarness<InitialState = unknown> {
|
|
20
|
-
readonly container: TestContainer;
|
|
21
|
-
readonly environment: PageEnvironment<InitialState>;
|
|
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<InitialState = unknown>(options?: TestEnvironmentOptions<InitialState>): PageEnvironment<InitialState>;
|
|
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
|
-
}
|