@vobs/devtools 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 +21 -0
- package/README.md +50 -0
- package/package.json +22 -0
- package/src/index.test.ts +696 -0
- package/src/index.ts +2189 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createOwner,
|
|
4
|
+
effect,
|
|
5
|
+
memo,
|
|
6
|
+
setSignalDebugName,
|
|
7
|
+
state,
|
|
8
|
+
type Signal
|
|
9
|
+
} from '@vobs/reactivity'
|
|
10
|
+
import { createHTTPClient } from '@vobs/http'
|
|
11
|
+
import { createMemoryHistory, createRouter } from '@vobs/router'
|
|
12
|
+
import { hydrate } from '@vobs/ssr'
|
|
13
|
+
import { addEventListener, bindText, createComponent, createElement, createText, createVobs, ErrorBoundary, type VobsContext } from '@vobs/vobs'
|
|
14
|
+
import { connectDevTools, createDevTools, devtoolsPlugin, getDevTools, type DevToolsTarget } from './index'
|
|
15
|
+
|
|
16
|
+
let active: ReturnType<typeof createDevTools> | undefined
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
active?.dispose()
|
|
20
|
+
active = undefined
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('@vobs/devtools', () => {
|
|
24
|
+
it('收集 Signal、Memo、Effect 依赖图并追踪更新', async () => {
|
|
25
|
+
active = createDevTools({ expose: false })
|
|
26
|
+
const owner = createOwner()
|
|
27
|
+
owner.onError(() => undefined)
|
|
28
|
+
let count!: Signal<number>
|
|
29
|
+
|
|
30
|
+
owner.run(() => {
|
|
31
|
+
count = state(0)
|
|
32
|
+
setSignalDebugName(count, 'count')
|
|
33
|
+
const doubled = memo(() => count.value * 2)
|
|
34
|
+
effect(() => { void doubled.value })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const countInfo = active.getSignals().find(signal => signal.name === 'count')!
|
|
38
|
+
const memoInfo = active.getSignals().find(signal => signal.id !== countInfo.id)!
|
|
39
|
+
expect(active.getDependencies(countInfo.id)).toEqual([{
|
|
40
|
+
from: countInfo.id,
|
|
41
|
+
to: expect.stringMatching(/^signal-/),
|
|
42
|
+
type: 'state-to-memo'
|
|
43
|
+
}])
|
|
44
|
+
expect(active.getDependencies(memoInfo.id)[0]?.type).toBe('memo-to-effect')
|
|
45
|
+
|
|
46
|
+
const traces: unknown[] = []
|
|
47
|
+
active.onUpdate(trace => traces.push(trace))
|
|
48
|
+
count.value = 1
|
|
49
|
+
await Promise.resolve()
|
|
50
|
+
|
|
51
|
+
expect(active.getSignal(countInfo.id)?.value).toBe(1)
|
|
52
|
+
expect(active.getEffects()[0]?.executionCount).toBe(2)
|
|
53
|
+
expect(active.getEffects()[0]?.name).toContain('App effect')
|
|
54
|
+
expect(active.getUpdates()).toHaveLength(1)
|
|
55
|
+
expect(active.getUpdates()[0]?.signalId).toBe(countInfo.id)
|
|
56
|
+
expect(active.getUpdates()[0]?.effects).toHaveLength(1)
|
|
57
|
+
expect(traces).toHaveLength(1)
|
|
58
|
+
expect(active.getDependents(active.getEffects()[0]!.id)).toHaveLength(1)
|
|
59
|
+
expect(active.getLifecycleEvents()).toEqual(expect.arrayContaining([
|
|
60
|
+
expect.objectContaining({ type: 'signal-changed', targetId: countInfo.id }),
|
|
61
|
+
expect.objectContaining({ type: 'effect-run', status: 'success' })
|
|
62
|
+
]))
|
|
63
|
+
|
|
64
|
+
owner.dispose()
|
|
65
|
+
expect(active.getSignals()).toHaveLength(0)
|
|
66
|
+
expect(active.getEffects()).toHaveLength(0)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('建立组件 Owner 树,并在销毁后移除节点', () => {
|
|
70
|
+
active = createDevTools({ expose: false })
|
|
71
|
+
|
|
72
|
+
function Counter(): ReturnType<typeof createText> {
|
|
73
|
+
return createText('counter')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const app = createVobs({ render: () => createComponent(Counter, {}) })
|
|
77
|
+
app.mount(document.createElement('div'))
|
|
78
|
+
|
|
79
|
+
expect(active.getComponentTree()).toEqual([
|
|
80
|
+
expect.objectContaining({ name: 'App', children: [
|
|
81
|
+
expect.objectContaining({ name: 'Counter', mounted: true })
|
|
82
|
+
] })
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
app.destroy()
|
|
86
|
+
expect(active.getComponentTree()).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('为组件提供关联的 Signal、Effect、Update 和 DOM 摘要', async () => {
|
|
90
|
+
active = createDevTools({ expose: false })
|
|
91
|
+
let count!: Signal<number>
|
|
92
|
+
const app = createVobs({
|
|
93
|
+
render: () => createComponent(function Counter() {
|
|
94
|
+
count = state(0, 'counter.value')
|
|
95
|
+
const text = createText('')
|
|
96
|
+
bindText(text, count)
|
|
97
|
+
return text
|
|
98
|
+
}, {})
|
|
99
|
+
})
|
|
100
|
+
app.mount(document.createElement('div'))
|
|
101
|
+
count.value = 1
|
|
102
|
+
await Promise.resolve()
|
|
103
|
+
|
|
104
|
+
const component = active.getComponentTree()[0]?.children[0]
|
|
105
|
+
expect(component).toMatchObject({
|
|
106
|
+
name: 'Counter',
|
|
107
|
+
signals: [expect.stringMatching(/^signal-/)],
|
|
108
|
+
effects: [expect.stringMatching(/^effect-/)],
|
|
109
|
+
recentUpdates: [expect.stringMatching(/^update-/)],
|
|
110
|
+
domUpdates: 1
|
|
111
|
+
})
|
|
112
|
+
expect(active.getComponent(component!.id)).toEqual(component)
|
|
113
|
+
app.destroy()
|
|
114
|
+
expect(active.getComponent(component!.id)).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('可暴露到指定宿主对象,并在 dispose 时恢复原值', () => {
|
|
118
|
+
const previous = { marker: true }
|
|
119
|
+
const target: DevToolsTarget = { __VOBS_DEVTOOLS__: previous as never }
|
|
120
|
+
active = createDevTools({ target })
|
|
121
|
+
|
|
122
|
+
expect(target.__VOBS_DEVTOOLS__).toBe(active)
|
|
123
|
+
active.dispose()
|
|
124
|
+
expect(target.__VOBS_DEVTOOLS__).toBe(previous)
|
|
125
|
+
active = undefined
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('通过 postMessage 为浏览器面板提供查询和更新事件', () => {
|
|
129
|
+
active = createDevTools({ expose: false })
|
|
130
|
+
const messages: unknown[] = []
|
|
131
|
+
const listeners = new Set<(event: { data?: unknown }) => void>()
|
|
132
|
+
const target = {
|
|
133
|
+
addEventListener(_type: string, listener: (event: { data?: unknown }) => void) { listeners.add(listener) },
|
|
134
|
+
removeEventListener(_type: string, listener: (event: { data?: unknown }) => void) { listeners.delete(listener) },
|
|
135
|
+
postMessage(message: unknown, _targetOrigin: string) { messages.push(message) }
|
|
136
|
+
}
|
|
137
|
+
const disconnect = connectDevTools({ api: active, target })
|
|
138
|
+
for (const listener of listeners) listener({
|
|
139
|
+
data: { source: 'vobs-devtools', type: 'request', id: '1', method: 'getSignals' }
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
expect(messages[0]).toMatchObject({ source: 'vobs-devtools', type: 'response', id: '1', ok: true })
|
|
143
|
+
const count = state(0)
|
|
144
|
+
count.value = 1
|
|
145
|
+
expect(messages.some(message => (message as { type?: string }).type === 'event')).toBe(true)
|
|
146
|
+
expect(messages.some(message => (message as { event?: string }).event === 'signal-update')).toBe(true)
|
|
147
|
+
disconnect()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('保留一次更新的旧值、新值、传播链和 Effect 最近结果', async () => {
|
|
151
|
+
active = createDevTools({ expose: false })
|
|
152
|
+
const owner = createOwner()
|
|
153
|
+
let count!: Signal<number>
|
|
154
|
+
|
|
155
|
+
owner.run(() => {
|
|
156
|
+
count = state(0, 'count')
|
|
157
|
+
effect(() => { void count.value })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
count.value = 1
|
|
161
|
+
await Promise.resolve()
|
|
162
|
+
|
|
163
|
+
const update = active.getUpdates()[0]!
|
|
164
|
+
expect(update.previousValue).toBe(0)
|
|
165
|
+
expect(update.nextValue).toBe(1)
|
|
166
|
+
expect(update.status).toBe('completed')
|
|
167
|
+
expect(update.affectedSignals).toContain(update.signalId)
|
|
168
|
+
expect(update.affectedEffects).toHaveLength(1)
|
|
169
|
+
expect(update.effects[0]).toMatchObject({ status: 'success', domUpdates: 0 })
|
|
170
|
+
expect(active.getEffects()[0]).toMatchObject({
|
|
171
|
+
status: 'success',
|
|
172
|
+
lastRunStatus: 'success',
|
|
173
|
+
lastUpdateId: update.id,
|
|
174
|
+
lastDomUpdates: 0
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('隔离 Effect 异常并保留错误结果', async () => {
|
|
179
|
+
active = createDevTools({ expose: false })
|
|
180
|
+
const owner = createOwner()
|
|
181
|
+
owner.onError(() => undefined)
|
|
182
|
+
let count!: Signal<number>
|
|
183
|
+
owner.run(() => {
|
|
184
|
+
count = state(0, 'count')
|
|
185
|
+
effect(() => {
|
|
186
|
+
if (count.value > 0) throw new Error('effect failed')
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
count.value = 1
|
|
191
|
+
await Promise.resolve()
|
|
192
|
+
|
|
193
|
+
expect(active.getUpdates()[0]).toMatchObject({ status: 'error', error: { message: 'effect failed', phase: 'effect' } })
|
|
194
|
+
expect(active.getEffects()[0]).toMatchObject({ status: 'error', lastRunStatus: 'error', lastError: { message: 'effect failed' } })
|
|
195
|
+
expect(active.getErrors()).toMatchObject([{ phase: 'effect', message: 'effect failed', count: 1 }])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('采集 ErrorBoundary 错误并保留 fallback 恢复状态', () => {
|
|
199
|
+
active = createDevTools({ expose: false })
|
|
200
|
+
const app = createVobs({
|
|
201
|
+
render: () => ErrorBoundary({
|
|
202
|
+
children: () => { throw new Error('boundary failed') },
|
|
203
|
+
fallback: error => createText(`error: ${error.message}`)
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
const container = document.createElement('div')
|
|
207
|
+
app.mount(container)
|
|
208
|
+
app.update()
|
|
209
|
+
|
|
210
|
+
expect(container.textContent).toBe('error: boundary failed')
|
|
211
|
+
expect(active.getErrors()).toMatchObject([{
|
|
212
|
+
phases: expect.arrayContaining(['boundary', 'effect']),
|
|
213
|
+
message: 'boundary failed',
|
|
214
|
+
component: 'App',
|
|
215
|
+
handled: true,
|
|
216
|
+
recovery: 'fallback'
|
|
217
|
+
}])
|
|
218
|
+
app.destroy()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('错误详情保留组件源码位置,并记录被边界处理的事件错误', () => {
|
|
222
|
+
active = createDevTools({ expose: false })
|
|
223
|
+
let button!: HTMLButtonElement
|
|
224
|
+
const app = createVobs({
|
|
225
|
+
render: () => ErrorBoundary({
|
|
226
|
+
children: () => createComponent(() => {
|
|
227
|
+
button = createElement('button') as HTMLButtonElement
|
|
228
|
+
addEventListener(button, 'click', () => { throw new Error('event failed') })
|
|
229
|
+
return button
|
|
230
|
+
}, {}, { file: 'src/EventPage.tsx', line: 18, column: 5 }),
|
|
231
|
+
fallback: error => createText(`error: ${error.message}`)
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
const container = document.createElement('div')
|
|
235
|
+
app.mount(container)
|
|
236
|
+
|
|
237
|
+
button.dispatchEvent(new Event('click'))
|
|
238
|
+
app.update()
|
|
239
|
+
|
|
240
|
+
expect(container.textContent).toBe('error: event failed')
|
|
241
|
+
expect(active.getErrors()).toMatchObject([{
|
|
242
|
+
phases: expect.arrayContaining(['event', 'boundary']),
|
|
243
|
+
source: 'src/EventPage.tsx:18:5',
|
|
244
|
+
component: 'anonymous',
|
|
245
|
+
handled: true,
|
|
246
|
+
recovery: 'fallback'
|
|
247
|
+
}])
|
|
248
|
+
app.destroy()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('为框架核心错误生成稳定代码和修复提示', () => {
|
|
252
|
+
active = createDevTools({ expose: false })
|
|
253
|
+
active.reportError('application', new Error('Vobs: 响应式更新超过 100 轮,可能存在循环依赖'))
|
|
254
|
+
|
|
255
|
+
expect(active.getErrors()[0]).toMatchObject({
|
|
256
|
+
origin: 'framework',
|
|
257
|
+
code: 'VOBS_REACTIVITY_LOOP',
|
|
258
|
+
hint: expect.stringContaining('持续写入')
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('读取框架错误的标准 code 属性并标记使用错误', () => {
|
|
263
|
+
active = createDevTools({ expose: false })
|
|
264
|
+
const failure = Object.assign(new Error('Vobs Widgets: 配置无效'), { code: 'INVALID_WIDGET_OPTIONS' })
|
|
265
|
+
active.reportError('application', failure)
|
|
266
|
+
|
|
267
|
+
expect(active.getErrors()[0]).toMatchObject({
|
|
268
|
+
code: 'INVALID_WIDGET_OPTIONS',
|
|
269
|
+
origin: 'usage'
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('通过 devtools 插件接收应用级错误', () => {
|
|
274
|
+
let report!: (error: unknown) => void
|
|
275
|
+
const context = {
|
|
276
|
+
onError(handler: (error: unknown) => void) {
|
|
277
|
+
report = handler
|
|
278
|
+
return () => undefined
|
|
279
|
+
}
|
|
280
|
+
} as unknown as VobsContext
|
|
281
|
+
const cleanup = devtoolsPlugin({ expose: false }).install?.(context)
|
|
282
|
+
const failure = new Error('application failed')
|
|
283
|
+
report(failure)
|
|
284
|
+
|
|
285
|
+
expect(getDevTools()?.getErrors()).toMatchObject([{ phase: 'application', message: 'application failed', origin: 'application' }])
|
|
286
|
+
cleanup?.()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('显式关闭插件时不创建采集器', () => {
|
|
290
|
+
const context = {
|
|
291
|
+
onError() { return () => undefined }
|
|
292
|
+
} as unknown as VobsContext
|
|
293
|
+
const cleanup = devtoolsPlugin({ enabled: false, expose: false }).install?.(context)
|
|
294
|
+
expect(getDevTools()).toBeNull()
|
|
295
|
+
cleanup?.()
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('采集 @vobs/http 请求并默认脱敏凭据', async () => {
|
|
299
|
+
active = createDevTools({ expose: false })
|
|
300
|
+
const client = createHTTPClient({
|
|
301
|
+
headers: { Authorization: 'Bearer secret', 'X-Trace': 'visible' },
|
|
302
|
+
adapter: async () => new Response(JSON.stringify({ ok: true }), {
|
|
303
|
+
status: 200,
|
|
304
|
+
headers: { 'content-type': 'application/json' }
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
await client.get('/users')
|
|
309
|
+
|
|
310
|
+
expect(active.getNetworkRequests()).toHaveLength(1)
|
|
311
|
+
expect(active.getNetworkRequests()[0]).toMatchObject({
|
|
312
|
+
method: 'GET',
|
|
313
|
+
status: 'success',
|
|
314
|
+
responseStatus: 200,
|
|
315
|
+
headers: { 'x-trace': 'visible' }
|
|
316
|
+
})
|
|
317
|
+
expect(active.getNetworkRequests()[0]?.headers).not.toHaveProperty('authorization')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('支持暂停采集和清空诊断记录,不改变业务 Signal', async () => {
|
|
321
|
+
active = createDevTools({ expose: false })
|
|
322
|
+
const count = state(0, 'count')
|
|
323
|
+
active.setCollectionPaused(true)
|
|
324
|
+
count.value = 1
|
|
325
|
+
await Promise.resolve()
|
|
326
|
+
expect(count.value).toBe(1)
|
|
327
|
+
expect(active.getUpdates()).toHaveLength(0)
|
|
328
|
+
expect(active.getCollectionState()).toEqual({ paused: true })
|
|
329
|
+
|
|
330
|
+
active.setCollectionPaused(false)
|
|
331
|
+
count.value = 2
|
|
332
|
+
await Promise.resolve()
|
|
333
|
+
expect(active.getUpdates()).toHaveLength(1)
|
|
334
|
+
active.clearUpdates()
|
|
335
|
+
active.clearErrors()
|
|
336
|
+
active.clearNetworkRequests()
|
|
337
|
+
active.clearLifecycleEvents()
|
|
338
|
+
expect(active.getUpdates()).toHaveLength(0)
|
|
339
|
+
expect(active.getLifecycleEvents()).toHaveLength(0)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('在销毁后丢弃已排队的刷新,并拒绝新的订阅', async () => {
|
|
343
|
+
active = createDevTools({ expose: false })
|
|
344
|
+
const count = state(0, 'dispose-check')
|
|
345
|
+
count.value = 1
|
|
346
|
+
const callback = vi.fn()
|
|
347
|
+
active.dispose()
|
|
348
|
+
active.subscribe('update', callback)
|
|
349
|
+
await Promise.resolve()
|
|
350
|
+
expect(callback).not.toHaveBeenCalled()
|
|
351
|
+
expect(getDevTools()).toBeNull()
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('为更新、请求、错误和生命周期记录统一执行保留上限', async () => {
|
|
355
|
+
active = createDevTools({ expose: false, maxUpdates: 2 })
|
|
356
|
+
const count = state(0, 'bounded')
|
|
357
|
+
count.value = 1
|
|
358
|
+
await Promise.resolve()
|
|
359
|
+
count.value = 2
|
|
360
|
+
await Promise.resolve()
|
|
361
|
+
count.value = 3
|
|
362
|
+
await Promise.resolve()
|
|
363
|
+
expect(active.getUpdates()).toHaveLength(2)
|
|
364
|
+
|
|
365
|
+
const client = createHTTPClient({ adapter: async () => new Response('ok', { status: 200 }) })
|
|
366
|
+
await client.get('/one')
|
|
367
|
+
await client.get('/two')
|
|
368
|
+
await client.get('/three')
|
|
369
|
+
expect(active.getNetworkRequests()).toHaveLength(2)
|
|
370
|
+
|
|
371
|
+
active.reportError('application', new Error('one'))
|
|
372
|
+
active.reportError('application', new Error('two'))
|
|
373
|
+
active.reportError('application', new Error('three'))
|
|
374
|
+
expect(active.getErrors()).toHaveLength(2)
|
|
375
|
+
|
|
376
|
+
createOwner()
|
|
377
|
+
createOwner()
|
|
378
|
+
createOwner()
|
|
379
|
+
expect(active.getLifecycleEvents()).toHaveLength(2)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('回收销毁 Owner、Signal、Effect 索引和依赖边', () => {
|
|
383
|
+
active = createDevTools({ expose: false })
|
|
384
|
+
for (let index = 0; index < 20; index++) {
|
|
385
|
+
const owner = createOwner()
|
|
386
|
+
owner.run(() => {
|
|
387
|
+
const source = state(index, `disposed-${index}`)
|
|
388
|
+
const derived = memo(() => source.value + 1)
|
|
389
|
+
effect(() => { void derived.value })
|
|
390
|
+
})
|
|
391
|
+
owner.dispose()
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
expect(active.takeMemorySnapshot()).toMatchObject({
|
|
395
|
+
signalCount: 0,
|
|
396
|
+
effectCount: 0,
|
|
397
|
+
ownerCount: 0,
|
|
398
|
+
dependencyEdgeCount: 0,
|
|
399
|
+
leakedOwners: 0
|
|
400
|
+
})
|
|
401
|
+
expect(active.getComponentTree()).toEqual([])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('保持并发 HTTP 请求的独立关联和完成状态', async () => {
|
|
405
|
+
active = createDevTools({ expose: false })
|
|
406
|
+
let resolveFirst!: (response: Response) => void
|
|
407
|
+
let resolveSecond!: (response: Response) => void
|
|
408
|
+
const client = createHTTPClient({
|
|
409
|
+
adapter: async config => new Promise<Response>(resolve => {
|
|
410
|
+
if (config.url.includes('/first')) resolveFirst = resolve
|
|
411
|
+
else resolveSecond = resolve
|
|
412
|
+
})
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const first = client.get('/first', { debugContext: { route: '/first', navigationId: 11 } })
|
|
416
|
+
const second = client.get('/second', { debugContext: { route: '/second', navigationId: 12 } })
|
|
417
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
418
|
+
expect(active.getNetworkRequests()).toMatchObject([
|
|
419
|
+
{ url: '/first', status: 'loading', route: '/first', navigationId: 11 },
|
|
420
|
+
{ url: '/second', status: 'loading', route: '/second', navigationId: 12 }
|
|
421
|
+
])
|
|
422
|
+
|
|
423
|
+
resolveSecond(new Response('{}', { status: 200 }))
|
|
424
|
+
resolveFirst(new Response('{}', { status: 200 }))
|
|
425
|
+
await Promise.all([first, second])
|
|
426
|
+
expect(active.getNetworkRequests()).toMatchObject([
|
|
427
|
+
{ url: '/first', status: 'success', route: '/first', navigationId: 11 },
|
|
428
|
+
{ url: '/second', status: 'success', route: '/second', navigationId: 12 }
|
|
429
|
+
])
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('记录 HTTP 取消和重试的最终状态与尝试次数', async () => {
|
|
433
|
+
active = createDevTools({ expose: false })
|
|
434
|
+
const controller = new AbortController()
|
|
435
|
+
const client = createHTTPClient({
|
|
436
|
+
adapter: async config => new Promise<Response>((_resolve, reject) => {
|
|
437
|
+
config.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
const cancelled = client.get('/cancelled', { signal: controller.signal, debugContext: { route: '/cancelled' } })
|
|
441
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
442
|
+
controller.abort()
|
|
443
|
+
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
|
444
|
+
expect(active.getNetworkRequests()[0]).toMatchObject({ status: 'cancelled', route: '/cancelled' })
|
|
445
|
+
|
|
446
|
+
let attempts = 0
|
|
447
|
+
const retryClient = createHTTPClient({
|
|
448
|
+
retry: 1,
|
|
449
|
+
adapter: async () => new Response('{}', { status: ++attempts === 1 ? 503 : 200 })
|
|
450
|
+
})
|
|
451
|
+
await retryClient.get('/retry', { debugContext: { route: '/retry' } })
|
|
452
|
+
expect(active.getNetworkRequests()).toContainEqual(expect.objectContaining({
|
|
453
|
+
url: '/retry',
|
|
454
|
+
status: 'success',
|
|
455
|
+
attempt: 1,
|
|
456
|
+
retries: 1,
|
|
457
|
+
route: '/retry'
|
|
458
|
+
}))
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('将 Effect 发起的 HTTP 请求关联到同一次 Update', async () => {
|
|
462
|
+
active = createDevTools({ expose: false })
|
|
463
|
+
const client = createHTTPClient({
|
|
464
|
+
adapter: async () => new Response('{}', { status: 200 })
|
|
465
|
+
})
|
|
466
|
+
const owner = createOwner()
|
|
467
|
+
let count!: Signal<number>
|
|
468
|
+
owner.run(() => {
|
|
469
|
+
count = state(0, 'request-trigger')
|
|
470
|
+
effect(() => {
|
|
471
|
+
if (count.value > 0) void client.get('/effect-request')
|
|
472
|
+
})
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
count.value = 1
|
|
476
|
+
await Promise.resolve()
|
|
477
|
+
await Promise.resolve()
|
|
478
|
+
const update = active.getUpdates()[0]
|
|
479
|
+
const requestId = update?.requestIds?.[0]
|
|
480
|
+
expect(update?.requestIds).toEqual([expect.any(Number)])
|
|
481
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
482
|
+
expect(active.getNetworkRequests()).toContainEqual(expect.objectContaining({
|
|
483
|
+
id: requestId,
|
|
484
|
+
url: '/effect-request',
|
|
485
|
+
status: 'success'
|
|
486
|
+
}))
|
|
487
|
+
owner.dispose()
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('关联慢 Loader 的 loading、完成耗时和内部 HTTP 请求', async () => {
|
|
491
|
+
active = createDevTools({ expose: false })
|
|
492
|
+
const client = createHTTPClient({
|
|
493
|
+
adapter: async () => new Response(JSON.stringify({ ok: true }), {
|
|
494
|
+
status: 200,
|
|
495
|
+
headers: { 'content-type': 'application/json' }
|
|
496
|
+
})
|
|
497
|
+
})
|
|
498
|
+
const router = createRouter({
|
|
499
|
+
history: createMemoryHistory('/'),
|
|
500
|
+
routes: [
|
|
501
|
+
{ path: '/', component: () => createText('home') },
|
|
502
|
+
{
|
|
503
|
+
path: '/slow',
|
|
504
|
+
component: () => createText('slow'),
|
|
505
|
+
loader: async () => {
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
507
|
+
return client.get('/slow-data')
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
]
|
|
511
|
+
})
|
|
512
|
+
const detach = active.attachRouter(router)
|
|
513
|
+
const navigation = router.push('/slow')
|
|
514
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
515
|
+
expect(active.getRouterContext()?.dataRequests).toContainEqual(expect.objectContaining({
|
|
516
|
+
kind: 'loader',
|
|
517
|
+
route: '/slow',
|
|
518
|
+
navigationId: 1,
|
|
519
|
+
status: 'loading'
|
|
520
|
+
}))
|
|
521
|
+
|
|
522
|
+
await navigation
|
|
523
|
+
const loader = active.getRouterContext()?.dataRequests.find(request => request.kind === 'loader' && request.route === '/slow')
|
|
524
|
+
expect(loader).toMatchObject({ status: 'success', navigationId: 1, duration: expect.any(Number) })
|
|
525
|
+
expect(loader?.duration).toBeGreaterThanOrEqual(16)
|
|
526
|
+
expect(active.getNetworkRequests()).toContainEqual(expect.objectContaining({
|
|
527
|
+
url: '/slow-data',
|
|
528
|
+
status: 'success',
|
|
529
|
+
route: '/slow',
|
|
530
|
+
navigationId: 1,
|
|
531
|
+
dataRequestId: loader?.id
|
|
532
|
+
}))
|
|
533
|
+
detach()
|
|
534
|
+
router.destroy()
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('忽略没有实际 DOM 变化的 viewport 尺寸更新', async () => {
|
|
538
|
+
active = createDevTools({ expose: false })
|
|
539
|
+
const width = state(1200, 'layout.viewport.width')
|
|
540
|
+
const height = state(800, 'layout.viewport.height')
|
|
541
|
+
|
|
542
|
+
width.value = 1199
|
|
543
|
+
height.value = 799
|
|
544
|
+
await Promise.resolve()
|
|
545
|
+
|
|
546
|
+
expect(active.getUpdates()).toHaveLength(0)
|
|
547
|
+
expect(active.getPerformanceEntries().filter(entry => entry.kind === 'update')).toHaveLength(0)
|
|
548
|
+
expect(active.getSignals().map(signal => signal.name)).toEqual(expect.arrayContaining([
|
|
549
|
+
'layout.viewport.width',
|
|
550
|
+
'layout.viewport.height'
|
|
551
|
+
]))
|
|
552
|
+
|
|
553
|
+
const container = document.createElement('div')
|
|
554
|
+
const app = createVobs({
|
|
555
|
+
render: () => {
|
|
556
|
+
const text = createText('')
|
|
557
|
+
bindText(text, width)
|
|
558
|
+
return text
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
app.mount(container)
|
|
562
|
+
width.value = 1198
|
|
563
|
+
await Promise.resolve()
|
|
564
|
+
|
|
565
|
+
expect(active.getUpdates()).toHaveLength(1)
|
|
566
|
+
expect(active.getUpdates()[0]?.domUpdates[0]).toMatchObject({ operation: 'text' })
|
|
567
|
+
app.destroy()
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('记录 Runtime DOM 修改并关联到 Update 和 Effect', async () => {
|
|
571
|
+
active = createDevTools({ expose: false })
|
|
572
|
+
let count!: Signal<string>
|
|
573
|
+
const app = createVobs({
|
|
574
|
+
render: () => {
|
|
575
|
+
count = state('before', 'query')
|
|
576
|
+
const text = createText('')
|
|
577
|
+
bindText(text, count)
|
|
578
|
+
return text
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
const container = document.createElement('div')
|
|
582
|
+
app.mount(container)
|
|
583
|
+
|
|
584
|
+
count.value = 'after'
|
|
585
|
+
await Promise.resolve()
|
|
586
|
+
|
|
587
|
+
const update = active.getUpdates()[0]!
|
|
588
|
+
expect(update.domUpdates).toEqual([
|
|
589
|
+
expect.objectContaining({
|
|
590
|
+
operation: 'text',
|
|
591
|
+
previousValue: 'before',
|
|
592
|
+
nextValue: 'after',
|
|
593
|
+
effectId: update.effects[0]?.effectId
|
|
594
|
+
})
|
|
595
|
+
])
|
|
596
|
+
expect(update.effects[0]?.domUpdates).toBe(1)
|
|
597
|
+
app.destroy()
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
it('支持可配置脱敏、性能条目、诊断导出和扩展注册', async () => {
|
|
601
|
+
active = createDevTools({
|
|
602
|
+
expose: false,
|
|
603
|
+
privacy: { redactedHeaders: ['x-private'], redactedFields: ['password'] }
|
|
604
|
+
})
|
|
605
|
+
const inspectorStop = active.registerInspector('resource', {
|
|
606
|
+
inspect: value => ({ value, password: 'should-hide' })
|
|
607
|
+
})
|
|
608
|
+
const timelineStop = active.registerTimeline('upload', {
|
|
609
|
+
label: 'Upload',
|
|
610
|
+
getEvents: () => [{ id: 'upload-1', timestamp: 1, title: 'started', data: { password: 'secret' } }]
|
|
611
|
+
})
|
|
612
|
+
const metricStop = active.registerMetric('queue-size', { read: () => 3 })
|
|
613
|
+
const brokenMetricStop = active.registerMetric('broken', { read: () => { throw new Error('extension failed') } })
|
|
614
|
+
|
|
615
|
+
const client = createHTTPClient({
|
|
616
|
+
adapter: async () => new Response('ok', { status: 200 }),
|
|
617
|
+
headers: { 'X-Private-Trace': 'hidden', 'X-Visible': 'visible' }
|
|
618
|
+
})
|
|
619
|
+
await client.post('/users', { password: 'secret', name: 'Ada' })
|
|
620
|
+
|
|
621
|
+
expect(active.getNetworkRequests()[0]).toMatchObject({
|
|
622
|
+
headers: { 'x-visible': 'visible' },
|
|
623
|
+
requestBody: { password: '[Redacted]', name: 'Ada' }
|
|
624
|
+
})
|
|
625
|
+
expect(active.inspectExtension('resource', { id: 1 })).toEqual({ value: { id: 1 }, password: '[Redacted]' })
|
|
626
|
+
expect(active.getExtensionSnapshot()).toMatchObject({
|
|
627
|
+
inspectors: ['resource'],
|
|
628
|
+
timelines: ['upload'],
|
|
629
|
+
timelineEvents: { upload: [{ id: 'upload-1', data: { password: '[Redacted]' } }] },
|
|
630
|
+
metrics: { 'queue-size': 3, broken: 0 }
|
|
631
|
+
})
|
|
632
|
+
expect(active.getPerformanceEntries()).toEqual(expect.any(Array))
|
|
633
|
+
const exported = active.exportDiagnostics()
|
|
634
|
+
expect(exported).toMatchObject({
|
|
635
|
+
version: 1,
|
|
636
|
+
network: expect.any(Array),
|
|
637
|
+
extensions: expect.objectContaining({ timelines: ['upload'] })
|
|
638
|
+
})
|
|
639
|
+
active.clearNetworkRequests()
|
|
640
|
+
expect(active.getNetworkRequests()).toHaveLength(0)
|
|
641
|
+
active.importDiagnostics(exported)
|
|
642
|
+
expect(active.getNetworkRequests()).toHaveLength(1)
|
|
643
|
+
|
|
644
|
+
inspectorStop()
|
|
645
|
+
timelineStop()
|
|
646
|
+
metricStop()
|
|
647
|
+
brokenMetricStop()
|
|
648
|
+
expect(active.getExtensionSnapshot()).toEqual({ inspectors: [], timelines: [], timelineEvents: {}, metrics: {} })
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('默认禁止修改 Signal,显式开启后支持受控调试编辑', async () => {
|
|
652
|
+
active = createDevTools({ expose: false })
|
|
653
|
+
const count = state(0, 'count')
|
|
654
|
+
const signal = active.getSignals().find(item => item.name === 'count')!
|
|
655
|
+
expect(active.canMutate()).toBe(false)
|
|
656
|
+
expect(active.setSignalValue(signal.id, 1)).toBe(false)
|
|
657
|
+
expect(count.value).toBe(0)
|
|
658
|
+
|
|
659
|
+
active.dispose()
|
|
660
|
+
active = createDevTools({ expose: false, allowMutations: true })
|
|
661
|
+
const editable = state(0, 'editable')
|
|
662
|
+
const editableInfo = active.getSignals().find(item => item.name === 'editable')!
|
|
663
|
+
expect(active.canMutate()).toBe(true)
|
|
664
|
+
expect(active.setSignalValue(editableInfo.id, 2)).toBe(true)
|
|
665
|
+
await Promise.resolve()
|
|
666
|
+
expect(editable.value).toBe(2)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('关联 Router 导航和 loader 请求,并保留统一上下文', async () => {
|
|
670
|
+
active = createDevTools({ expose: false })
|
|
671
|
+
const router = createRouter({
|
|
672
|
+
history: createMemoryHistory('/'),
|
|
673
|
+
routes: [
|
|
674
|
+
{ path: '/', component: () => createText('home') },
|
|
675
|
+
{ path: '/reports', component: () => createText('reports'), loader: async () => ({ ok: true }) }
|
|
676
|
+
]
|
|
677
|
+
})
|
|
678
|
+
const detach = active.attachRouter(router)
|
|
679
|
+
await router.push('/reports')
|
|
680
|
+
expect(active.getRouterContext()).toMatchObject({ route: '/reports', dataRequests: [expect.objectContaining({ kind: 'loader', navigationId: 1, status: 'success' })] })
|
|
681
|
+
expect(active.getNetworkRequests()).toHaveLength(0)
|
|
682
|
+
detach()
|
|
683
|
+
router.destroy()
|
|
684
|
+
})
|
|
685
|
+
|
|
686
|
+
it('将 Hydration mismatch 收集为结构化错误', () => {
|
|
687
|
+
active = createDevTools({ expose: false })
|
|
688
|
+
document.body.innerHTML = '<h1>server</h1>'
|
|
689
|
+
expect(() => hydrate(() => createElement('p'), document.body)).toThrow('hydration')
|
|
690
|
+
expect(active.getErrors()).toMatchObject([{
|
|
691
|
+
phase: 'hydration',
|
|
692
|
+
code: 'VOBS_HYDRATION_MISMATCH',
|
|
693
|
+
hydration: { expected: '<p>', actual: expect.any(String), path: expect.any(String) }
|
|
694
|
+
}])
|
|
695
|
+
})
|
|
696
|
+
})
|