@dimina-kit/inspect 0.3.0-dev.20260711141929
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 +56 -0
- package/dist/connected-panel.js +28 -0
- package/dist/connected-storage-panel.js +33 -0
- package/dist/index.js +4 -0
- package/dist/inspector.js +122 -0
- package/dist/panel-source.js +1 -0
- package/dist/panel-view.js +136 -0
- package/dist/panel.js +7 -0
- package/dist/sid-registry.js +45 -0
- package/dist/storage-panel-view.js +127 -0
- package/dist/storage-reducer.js +25 -0
- package/dist/storage-source.js +1 -0
- package/dist/storage-types.js +4 -0
- package/dist/types.js +5 -0
- package/dist/use-source-wiring.js +52 -0
- package/dist/wxml-extract.js +381 -0
- package/package.json +72 -0
- package/src/connected-panel.test.tsx +224 -0
- package/src/connected-panel.tsx +53 -0
- package/src/connected-storage-panel.test.tsx +289 -0
- package/src/connected-storage-panel.tsx +62 -0
- package/src/index.ts +16 -0
- package/src/inspector.test.ts +282 -0
- package/src/inspector.ts +144 -0
- package/src/panel-source.ts +20 -0
- package/src/panel-view.tsx +355 -0
- package/src/panel.tsx +7 -0
- package/src/sid-registry.ts +45 -0
- package/src/storage-panel-view.tsx +252 -0
- package/src/storage-reducer.test.ts +80 -0
- package/src/storage-reducer.ts +26 -0
- package/src/storage-source.ts +28 -0
- package/src/storage-types.ts +26 -0
- package/src/types.ts +35 -0
- package/src/use-source-wiring.ts +69 -0
- package/src/wxml-extract.ts +366 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { ConnectedWxmlPanel } from './connected-panel.js'
|
|
4
|
+
import type { WxmlPanelSource } from './panel-source.js'
|
|
5
|
+
import type { WxmlNode } from './types.js'
|
|
6
|
+
|
|
7
|
+
const TREE_A: WxmlNode = { tagName: 'view', attrs: {}, children: [], sid: 'sid-a' }
|
|
8
|
+
const TREE_B: WxmlNode = { tagName: 'text', attrs: {}, children: [], sid: 'sid-b' }
|
|
9
|
+
|
|
10
|
+
function createDeferred<T>() {
|
|
11
|
+
let resolve!: (value: T) => void
|
|
12
|
+
const promise = new Promise<T>((res) => {
|
|
13
|
+
resolve = res
|
|
14
|
+
})
|
|
15
|
+
return { promise, resolve }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A programmable WxmlPanelSource: getSnapshot() consumes one queued promise
|
|
20
|
+
* per call (defaulting to `null`), subscribe() records the latest push
|
|
21
|
+
* callback so tests can simulate a live tree, and every method is a spy so
|
|
22
|
+
* call count / order / arguments can be asserted.
|
|
23
|
+
*/
|
|
24
|
+
function createFakeSource() {
|
|
25
|
+
const unsubscribe = vi.fn()
|
|
26
|
+
const getSnapshotQueue: Array<Promise<WxmlNode | null>> = []
|
|
27
|
+
let latestOnTree: ((tree: WxmlNode | null) => void) | null = null
|
|
28
|
+
const source: WxmlPanelSource = {
|
|
29
|
+
getSnapshot: vi.fn(() => getSnapshotQueue.shift() ?? Promise.resolve(null)),
|
|
30
|
+
subscribe: vi.fn((onTree: (tree: WxmlNode | null) => void) => {
|
|
31
|
+
latestOnTree = onTree
|
|
32
|
+
return unsubscribe
|
|
33
|
+
}),
|
|
34
|
+
setActive: vi.fn(),
|
|
35
|
+
inspect: vi.fn(async () => null),
|
|
36
|
+
clearInspection: vi.fn(),
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
source,
|
|
40
|
+
unsubscribe,
|
|
41
|
+
getSnapshotQueue,
|
|
42
|
+
pushTree: (tree: WxmlNode | null) => latestOnTree?.(tree),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Flushes the microtask queue inside `act` so promise-driven setState calls settle. */
|
|
47
|
+
async function flush() {
|
|
48
|
+
await act(async () => {
|
|
49
|
+
await Promise.resolve()
|
|
50
|
+
await Promise.resolve()
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
cleanup()
|
|
56
|
+
vi.unstubAllGlobals()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('ConnectedWxmlPanel', () => {
|
|
60
|
+
it('seeds the tree via getSnapshot once when enabled and active are true from mount', async () => {
|
|
61
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
62
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
63
|
+
render(<ConnectedWxmlPanel source={source} />)
|
|
64
|
+
await flush()
|
|
65
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
66
|
+
expect(source.subscribe).toHaveBeenCalledTimes(1)
|
|
67
|
+
expect(document.querySelector('[data-wxml-sid="sid-a"]')).not.toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('never calls getSnapshot, subscribe, or setActive while disabled', async () => {
|
|
71
|
+
const { source } = createFakeSource()
|
|
72
|
+
render(<ConnectedWxmlPanel source={source} enabled={false} />)
|
|
73
|
+
await flush()
|
|
74
|
+
expect(source.getSnapshot).not.toHaveBeenCalled()
|
|
75
|
+
expect(source.subscribe).not.toHaveBeenCalled()
|
|
76
|
+
expect(source.setActive).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('forwards isRuntimeRunning to the empty state while no tree has arrived yet', () => {
|
|
80
|
+
const { source } = createFakeSource()
|
|
81
|
+
render(<ConnectedWxmlPanel source={source} enabled={false} isRuntimeRunning={false} />)
|
|
82
|
+
expect(screen.getByTestId('wxml-panel').textContent).toContain('小程序未运行')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('renders live tree pushes from subscribe without issuing another getSnapshot call', async () => {
|
|
86
|
+
const { source, getSnapshotQueue, pushTree } = createFakeSource()
|
|
87
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
88
|
+
render(<ConnectedWxmlPanel source={source} />)
|
|
89
|
+
await flush()
|
|
90
|
+
|
|
91
|
+
act(() => {
|
|
92
|
+
pushTree(TREE_B)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(document.querySelector('[data-wxml-sid="sid-b"]')).not.toBeNull()
|
|
96
|
+
expect(document.querySelector('[data-wxml-sid="sid-a"]')).toBeNull()
|
|
97
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('unsubscribes but keeps the last rendered tree when enabled flips to false', async () => {
|
|
101
|
+
const { source, unsubscribe, getSnapshotQueue } = createFakeSource()
|
|
102
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
103
|
+
const { rerender } = render(<ConnectedWxmlPanel source={source} />)
|
|
104
|
+
await flush()
|
|
105
|
+
|
|
106
|
+
rerender(<ConnectedWxmlPanel source={source} enabled={false} />)
|
|
107
|
+
await flush()
|
|
108
|
+
|
|
109
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
|
110
|
+
expect(document.querySelector('[data-wxml-sid="sid-a"]')).not.toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('forwards setActive on both directions of an active flip while enabled', async () => {
|
|
114
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
115
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
116
|
+
const { rerender } = render(<ConnectedWxmlPanel source={source} active />)
|
|
117
|
+
await flush()
|
|
118
|
+
|
|
119
|
+
rerender(<ConnectedWxmlPanel source={source} active={false} />)
|
|
120
|
+
await flush()
|
|
121
|
+
expect(source.setActive).toHaveBeenLastCalledWith(false)
|
|
122
|
+
|
|
123
|
+
rerender(<ConnectedWxmlPanel source={source} active />)
|
|
124
|
+
await flush()
|
|
125
|
+
expect(source.setActive).toHaveBeenLastCalledWith(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('re-seeds via getSnapshot on the active false-to-true rising edge', async () => {
|
|
129
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
130
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
131
|
+
const { rerender } = render(<ConnectedWxmlPanel source={source} active />)
|
|
132
|
+
await flush()
|
|
133
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
134
|
+
|
|
135
|
+
rerender(<ConnectedWxmlPanel source={source} active={false} />)
|
|
136
|
+
await flush()
|
|
137
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
138
|
+
|
|
139
|
+
getSnapshotQueue.push(Promise.resolve(TREE_B))
|
|
140
|
+
rerender(<ConnectedWxmlPanel source={source} active />)
|
|
141
|
+
await flush()
|
|
142
|
+
|
|
143
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(2)
|
|
144
|
+
expect(document.querySelector('[data-wxml-sid="sid-b"]')).not.toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('calls setActive(false) and unsubscribes on unmount', async () => {
|
|
148
|
+
const { source, unsubscribe, getSnapshotQueue } = createFakeSource()
|
|
149
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
150
|
+
const { unmount } = render(<ConnectedWxmlPanel source={source} />)
|
|
151
|
+
await flush()
|
|
152
|
+
|
|
153
|
+
unmount()
|
|
154
|
+
|
|
155
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
|
156
|
+
expect(source.setActive).toHaveBeenLastCalledWith(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('forwards hover inspect(sid) calls to the source', async () => {
|
|
160
|
+
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
|
161
|
+
cb(0)
|
|
162
|
+
return 0
|
|
163
|
+
})
|
|
164
|
+
vi.stubGlobal('cancelAnimationFrame', () => {})
|
|
165
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
166
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
167
|
+
render(<ConnectedWxmlPanel source={source} />)
|
|
168
|
+
await flush()
|
|
169
|
+
|
|
170
|
+
const row = document.querySelector('[data-wxml-sid="sid-a"]') as HTMLElement
|
|
171
|
+
fireEvent.mouseEnter(row)
|
|
172
|
+
await flush()
|
|
173
|
+
|
|
174
|
+
expect(source.inspect).toHaveBeenCalledWith('sid-a')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('clears the inspection when the mouse leaves the panel root', async () => {
|
|
178
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
179
|
+
getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
180
|
+
render(<ConnectedWxmlPanel source={source} />)
|
|
181
|
+
await flush()
|
|
182
|
+
|
|
183
|
+
fireEvent.mouseLeave(screen.getByTestId('wxml-panel'))
|
|
184
|
+
|
|
185
|
+
expect(source.clearInspection).toHaveBeenCalledTimes(1)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('does not warn or throw when getSnapshot resolves after the component has unmounted', async () => {
|
|
189
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
190
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
191
|
+
const deferred = createDeferred<WxmlNode | null>()
|
|
192
|
+
getSnapshotQueue.push(deferred.promise)
|
|
193
|
+
const { unmount } = render(<ConnectedWxmlPanel source={source} />)
|
|
194
|
+
|
|
195
|
+
unmount()
|
|
196
|
+
|
|
197
|
+
await act(async () => {
|
|
198
|
+
deferred.resolve(TREE_A)
|
|
199
|
+
await Promise.resolve()
|
|
200
|
+
await Promise.resolve()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
204
|
+
errorSpy.mockRestore()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('tears down the old source and re-seeds/re-subscribes the new one when source changes', async () => {
|
|
208
|
+
const first = createFakeSource()
|
|
209
|
+
first.getSnapshotQueue.push(Promise.resolve(TREE_A))
|
|
210
|
+
const { rerender } = render(<ConnectedWxmlPanel source={first.source} />)
|
|
211
|
+
await flush()
|
|
212
|
+
|
|
213
|
+
const second = createFakeSource()
|
|
214
|
+
second.getSnapshotQueue.push(Promise.resolve(TREE_B))
|
|
215
|
+
rerender(<ConnectedWxmlPanel source={second.source} />)
|
|
216
|
+
await flush()
|
|
217
|
+
|
|
218
|
+
expect(first.unsubscribe).toHaveBeenCalledTimes(1)
|
|
219
|
+
expect(first.source.setActive).toHaveBeenLastCalledWith(false)
|
|
220
|
+
expect(second.source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
221
|
+
expect(second.source.subscribe).toHaveBeenCalledTimes(1)
|
|
222
|
+
expect(document.querySelector('[data-wxml-sid="sid-b"]')).not.toBeNull()
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The WXML panel's data wiring, written once against WxmlPanelSource: seed on
|
|
2
|
+
// the (enabled && active) rising edge, stay live through the push
|
|
3
|
+
// subscription, forward the visibility gate, and route hover inspection.
|
|
4
|
+
// Hosts render this with their transport implementation (Electron IPC,
|
|
5
|
+
// preview-iframe postMessage, …) and the pure WxmlPanel view underneath never
|
|
6
|
+
// needs host-specific code.
|
|
7
|
+
import { useState } from 'react'
|
|
8
|
+
import { WxmlPanel } from './panel-view.js'
|
|
9
|
+
import { useSourceWiring } from './use-source-wiring.js'
|
|
10
|
+
import type { WxmlPanelSource } from './panel-source.js'
|
|
11
|
+
import type { WxmlNode } from './types.js'
|
|
12
|
+
|
|
13
|
+
export interface ConnectedWxmlPanelProps {
|
|
14
|
+
source: WxmlPanelSource
|
|
15
|
+
/** Panel visibility (the host's tab-active state). Defaults to true. */
|
|
16
|
+
active?: boolean
|
|
17
|
+
/** Data availability gate (e.g. compile ready). While false the panel makes
|
|
18
|
+
* no source calls at all and keeps the last rendered tree. Defaults to true. */
|
|
19
|
+
enabled?: boolean
|
|
20
|
+
isRuntimeRunning?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ConnectedWxmlPanel({
|
|
24
|
+
source,
|
|
25
|
+
active = true,
|
|
26
|
+
enabled = true,
|
|
27
|
+
isRuntimeRunning = true,
|
|
28
|
+
}: ConnectedWxmlPanelProps) {
|
|
29
|
+
const [tree, setTree] = useState<WxmlNode | null>(null)
|
|
30
|
+
|
|
31
|
+
useSourceWiring({
|
|
32
|
+
source,
|
|
33
|
+
enabled,
|
|
34
|
+
active,
|
|
35
|
+
subscribe: s => s.subscribe(setTree),
|
|
36
|
+
seed: (s, isDisposed) => {
|
|
37
|
+
void s.getSnapshot().then((next) => {
|
|
38
|
+
if (!isDisposed()) setTree(next)
|
|
39
|
+
})
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<WxmlPanel
|
|
45
|
+
tree={tree}
|
|
46
|
+
onInspectElement={sid => source.inspect(sid)}
|
|
47
|
+
onClearInspection={async () => {
|
|
48
|
+
await source.clearInspection()
|
|
49
|
+
}}
|
|
50
|
+
isRuntimeRunning={isRuntimeRunning}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import { ConnectedStoragePanel } from './connected-storage-panel.js'
|
|
4
|
+
import type { StoragePanelSource } from './storage-source.js'
|
|
5
|
+
import type { StorageEvent, StorageItem, StorageWriteResult } from './storage-types.js'
|
|
6
|
+
|
|
7
|
+
const OK: StorageWriteResult = { ok: true }
|
|
8
|
+
|
|
9
|
+
function createDeferred<T>() {
|
|
10
|
+
let resolve!: (value: T) => void
|
|
11
|
+
const promise = new Promise<T>((res) => {
|
|
12
|
+
resolve = res
|
|
13
|
+
})
|
|
14
|
+
return { promise, resolve }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A programmable StoragePanelSource: getSnapshot() consumes one queued
|
|
19
|
+
* promise per call (defaulting to an empty list), subscribe() records the
|
|
20
|
+
* latest push callback so tests can simulate live StorageEvents, and every
|
|
21
|
+
* method is a spy so call count / order / arguments can be asserted.
|
|
22
|
+
* clearAll is intentionally omitted by default — tests that need it attach
|
|
23
|
+
* it explicitly to exercise the optional-capability contract.
|
|
24
|
+
*/
|
|
25
|
+
function createFakeSource() {
|
|
26
|
+
const unsubscribe = vi.fn()
|
|
27
|
+
const getSnapshotQueue: Array<Promise<StorageItem[]>> = []
|
|
28
|
+
let latestOnEvent: ((evt: StorageEvent) => void) | null = null
|
|
29
|
+
const source: StoragePanelSource = {
|
|
30
|
+
getSnapshot: vi.fn(() => getSnapshotQueue.shift() ?? Promise.resolve([])),
|
|
31
|
+
subscribe: vi.fn((onEvent: (evt: StorageEvent) => void) => {
|
|
32
|
+
latestOnEvent = onEvent
|
|
33
|
+
return unsubscribe
|
|
34
|
+
}),
|
|
35
|
+
setActive: vi.fn(),
|
|
36
|
+
setItem: vi.fn(async () => OK),
|
|
37
|
+
removeItem: vi.fn(async () => OK),
|
|
38
|
+
clear: vi.fn(async () => OK),
|
|
39
|
+
getPrefix: vi.fn(async () => 'appid_'),
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
source,
|
|
43
|
+
unsubscribe,
|
|
44
|
+
getSnapshotQueue,
|
|
45
|
+
pushEvent: (evt: StorageEvent) => latestOnEvent?.(evt),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Flushes the microtask queue inside `act` so promise-driven setState calls settle. */
|
|
50
|
+
async function flush() {
|
|
51
|
+
await act(async () => {
|
|
52
|
+
await Promise.resolve()
|
|
53
|
+
await Promise.resolve()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
cleanup()
|
|
59
|
+
vi.unstubAllGlobals()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('ConnectedStoragePanel: seeding', () => {
|
|
63
|
+
it('seeds items via getSnapshot exactly once when enabled and active are true from mount', async () => {
|
|
64
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
65
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'foo', value: 'bar' }]))
|
|
66
|
+
render(<ConnectedStoragePanel source={source} />)
|
|
67
|
+
await flush()
|
|
68
|
+
|
|
69
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
70
|
+
await screen.findByText('foo')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not call getSnapshot while inactive, then seeds on the active rising edge', async () => {
|
|
74
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
75
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'a', value: '1' }]))
|
|
76
|
+
const { rerender } = render(<ConnectedStoragePanel source={source} active={false} />)
|
|
77
|
+
await flush()
|
|
78
|
+
expect(source.getSnapshot).not.toHaveBeenCalled()
|
|
79
|
+
|
|
80
|
+
rerender(<ConnectedStoragePanel source={source} active />)
|
|
81
|
+
await flush()
|
|
82
|
+
|
|
83
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
84
|
+
await screen.findByText('a')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('re-seeds via getSnapshot on the second active false-to-true rising edge', async () => {
|
|
88
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
89
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'a', value: '1' }]))
|
|
90
|
+
const { rerender } = render(<ConnectedStoragePanel source={source} active />)
|
|
91
|
+
await flush()
|
|
92
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
93
|
+
|
|
94
|
+
rerender(<ConnectedStoragePanel source={source} active={false} />)
|
|
95
|
+
await flush()
|
|
96
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
97
|
+
|
|
98
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'b', value: '2' }]))
|
|
99
|
+
rerender(<ConnectedStoragePanel source={source} active />)
|
|
100
|
+
await flush()
|
|
101
|
+
|
|
102
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(2)
|
|
103
|
+
await screen.findByText('b')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('makes zero source calls while disabled', async () => {
|
|
107
|
+
const { source } = createFakeSource()
|
|
108
|
+
render(<ConnectedStoragePanel source={source} enabled={false} />)
|
|
109
|
+
await flush()
|
|
110
|
+
|
|
111
|
+
expect(source.getSnapshot).not.toHaveBeenCalled()
|
|
112
|
+
expect(source.subscribe).not.toHaveBeenCalled()
|
|
113
|
+
expect(source.setActive).not.toHaveBeenCalled()
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('ConnectedStoragePanel: live events', () => {
|
|
118
|
+
it('applies pushed added/updated/removed/cleared events to the rendered list', async () => {
|
|
119
|
+
const { source, getSnapshotQueue, pushEvent } = createFakeSource()
|
|
120
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'a', value: '1' }]))
|
|
121
|
+
render(<ConnectedStoragePanel source={source} />)
|
|
122
|
+
await flush()
|
|
123
|
+
await screen.findByText('a')
|
|
124
|
+
|
|
125
|
+
act(() => {
|
|
126
|
+
pushEvent({ type: 'added', key: 'b', newValue: '2' })
|
|
127
|
+
})
|
|
128
|
+
await screen.findByText('b')
|
|
129
|
+
|
|
130
|
+
act(() => {
|
|
131
|
+
pushEvent({ type: 'updated', key: 'a', oldValue: '1', newValue: '99' })
|
|
132
|
+
})
|
|
133
|
+
await screen.findByText('99')
|
|
134
|
+
|
|
135
|
+
act(() => {
|
|
136
|
+
pushEvent({ type: 'removed', key: 'b' })
|
|
137
|
+
})
|
|
138
|
+
await waitFor(() => expect(screen.queryByText('b')).toBeNull())
|
|
139
|
+
|
|
140
|
+
act(() => {
|
|
141
|
+
pushEvent({ type: 'cleared' })
|
|
142
|
+
})
|
|
143
|
+
await screen.findByText('暂无 Storage 数据')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('ConnectedStoragePanel: visibility and lifecycle', () => {
|
|
148
|
+
it('forwards active prop changes to source.setActive in both directions', async () => {
|
|
149
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
150
|
+
getSnapshotQueue.push(Promise.resolve([]))
|
|
151
|
+
const { rerender } = render(<ConnectedStoragePanel source={source} active />)
|
|
152
|
+
await flush()
|
|
153
|
+
|
|
154
|
+
rerender(<ConnectedStoragePanel source={source} active={false} />)
|
|
155
|
+
await flush()
|
|
156
|
+
expect(source.setActive).toHaveBeenLastCalledWith(false)
|
|
157
|
+
|
|
158
|
+
rerender(<ConnectedStoragePanel source={source} active />)
|
|
159
|
+
await flush()
|
|
160
|
+
expect(source.setActive).toHaveBeenLastCalledWith(true)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('calls setActive(false) and unsubscribes on unmount', async () => {
|
|
164
|
+
const { source, unsubscribe, getSnapshotQueue } = createFakeSource()
|
|
165
|
+
getSnapshotQueue.push(Promise.resolve([]))
|
|
166
|
+
const { unmount } = render(<ConnectedStoragePanel source={source} />)
|
|
167
|
+
await flush()
|
|
168
|
+
|
|
169
|
+
unmount()
|
|
170
|
+
|
|
171
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
|
172
|
+
expect(source.setActive).toHaveBeenLastCalledWith(false)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('tears down the old source and reseeds/resubscribes the new one on a source swap', async () => {
|
|
176
|
+
const first = createFakeSource()
|
|
177
|
+
first.getSnapshotQueue.push(Promise.resolve([{ key: 'a', value: '1' }]))
|
|
178
|
+
const { rerender } = render(<ConnectedStoragePanel source={first.source} />)
|
|
179
|
+
await flush()
|
|
180
|
+
|
|
181
|
+
const second = createFakeSource()
|
|
182
|
+
second.getSnapshotQueue.push(Promise.resolve([{ key: 'b', value: '2' }]))
|
|
183
|
+
rerender(<ConnectedStoragePanel source={second.source} />)
|
|
184
|
+
await flush()
|
|
185
|
+
|
|
186
|
+
expect(first.unsubscribe).toHaveBeenCalledTimes(1)
|
|
187
|
+
expect(first.source.setActive).toHaveBeenLastCalledWith(false)
|
|
188
|
+
expect(second.source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
189
|
+
expect(second.source.subscribe).toHaveBeenCalledTimes(1)
|
|
190
|
+
await screen.findByText('b')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('ConnectedStoragePanel: stale resolutions', () => {
|
|
195
|
+
it('drops a late getSnapshot resolution after unmount without throwing', async () => {
|
|
196
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
197
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
198
|
+
const deferred = createDeferred<StorageItem[]>()
|
|
199
|
+
getSnapshotQueue.push(deferred.promise)
|
|
200
|
+
const { unmount } = render(<ConnectedStoragePanel source={source} />)
|
|
201
|
+
|
|
202
|
+
unmount()
|
|
203
|
+
|
|
204
|
+
await act(async () => {
|
|
205
|
+
deferred.resolve([{ key: 'late', value: 'x' }])
|
|
206
|
+
await Promise.resolve()
|
|
207
|
+
await Promise.resolve()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
211
|
+
errorSpy.mockRestore()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('drops a late getSnapshot resolution after a source swap without rendering stale data', async () => {
|
|
215
|
+
const first = createFakeSource()
|
|
216
|
+
const deferred = createDeferred<StorageItem[]>()
|
|
217
|
+
first.getSnapshotQueue.push(deferred.promise)
|
|
218
|
+
const { rerender } = render(<ConnectedStoragePanel source={first.source} />)
|
|
219
|
+
await flush()
|
|
220
|
+
|
|
221
|
+
const second = createFakeSource()
|
|
222
|
+
second.getSnapshotQueue.push(Promise.resolve([{ key: 'fresh', value: '1' }]))
|
|
223
|
+
rerender(<ConnectedStoragePanel source={second.source} />)
|
|
224
|
+
await flush()
|
|
225
|
+
await screen.findByText('fresh')
|
|
226
|
+
|
|
227
|
+
await act(async () => {
|
|
228
|
+
deferred.resolve([{ key: 'stale', value: 'x' }])
|
|
229
|
+
await Promise.resolve()
|
|
230
|
+
await Promise.resolve()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
expect(screen.queryByText('stale')).toBeNull()
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describe('ConnectedStoragePanel: write operations', () => {
|
|
238
|
+
it('calls source.removeItem with the row key when its delete control is clicked', async () => {
|
|
239
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
240
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'doomed', value: 'x' }]))
|
|
241
|
+
render(<ConnectedStoragePanel source={source} />)
|
|
242
|
+
await screen.findByText('doomed')
|
|
243
|
+
|
|
244
|
+
const deleteBtn = screen.getByTitle('删除')
|
|
245
|
+
fireEvent.click(deleteBtn)
|
|
246
|
+
|
|
247
|
+
await waitFor(() => expect(source.removeItem).toHaveBeenCalledWith('doomed'))
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('ConnectedStoragePanel: clearAll optionality', () => {
|
|
252
|
+
it('does not render a "清空所有" button when the source has no clearAll', async () => {
|
|
253
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
254
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'a', value: '1' }]))
|
|
255
|
+
render(<ConnectedStoragePanel source={source} />)
|
|
256
|
+
await screen.findByText('a')
|
|
257
|
+
|
|
258
|
+
expect(screen.queryByText('清空所有')).toBeNull()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('renders and wires a "清空所有" button when the source provides clearAll', async () => {
|
|
262
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
263
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'a', value: '1' }]))
|
|
264
|
+
const clearAll = vi.fn(async () => OK)
|
|
265
|
+
source.clearAll = clearAll
|
|
266
|
+
vi.stubGlobal('confirm', vi.fn(() => true))
|
|
267
|
+
render(<ConnectedStoragePanel source={source} />)
|
|
268
|
+
await screen.findByText('a')
|
|
269
|
+
|
|
270
|
+
const btn = screen.getByText('清空所有')
|
|
271
|
+
fireEvent.click(btn)
|
|
272
|
+
|
|
273
|
+
await waitFor(() => expect(clearAll).toHaveBeenCalledTimes(1))
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
describe('ConnectedStoragePanel: disabled retains last render', () => {
|
|
278
|
+
it('keeps the last rendered items visible when enabled flips to false', async () => {
|
|
279
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
280
|
+
getSnapshotQueue.push(Promise.resolve([{ key: 'kept', value: '1' }]))
|
|
281
|
+
const { rerender } = render(<ConnectedStoragePanel source={source} />)
|
|
282
|
+
await screen.findByText('kept')
|
|
283
|
+
|
|
284
|
+
rerender(<ConnectedStoragePanel source={source} enabled={false} />)
|
|
285
|
+
await flush()
|
|
286
|
+
|
|
287
|
+
expect(screen.getByText('kept')).not.toBeNull()
|
|
288
|
+
})
|
|
289
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The Storage panel's data wiring, written once against StoragePanelSource:
|
|
2
|
+
// seed on the (enabled && active) rising edge, stay live by reducing the
|
|
3
|
+
// push subscription's StorageEvents into the item list, forward the
|
|
4
|
+
// visibility gate, and route the panel's writes. Hosts render this with
|
|
5
|
+
// their transport implementation (Electron IPC, same-origin localStorage +
|
|
6
|
+
// `storage` events, …) and the pure StoragePanel view underneath never
|
|
7
|
+
// needs host-specific code.
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import { StoragePanel } from './storage-panel-view.js'
|
|
10
|
+
import { applyStorageEvent } from './storage-reducer.js'
|
|
11
|
+
import { useSourceWiring } from './use-source-wiring.js'
|
|
12
|
+
import type { StoragePanelSource } from './storage-source.js'
|
|
13
|
+
import type { StorageItem } from './storage-types.js'
|
|
14
|
+
|
|
15
|
+
export interface ConnectedStoragePanelProps {
|
|
16
|
+
source: StoragePanelSource
|
|
17
|
+
/** Panel visibility (the host's tab-active state). Defaults to true. */
|
|
18
|
+
active?: boolean
|
|
19
|
+
/** Data availability gate (e.g. compile ready). While false the panel makes
|
|
20
|
+
* no source calls at all and keeps the last rendered items. Defaults to true. */
|
|
21
|
+
enabled?: boolean
|
|
22
|
+
isRuntimeRunning?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ConnectedStoragePanel({
|
|
26
|
+
source,
|
|
27
|
+
active = true,
|
|
28
|
+
enabled = true,
|
|
29
|
+
isRuntimeRunning = true,
|
|
30
|
+
}: ConnectedStoragePanelProps) {
|
|
31
|
+
const [items, setItems] = useState<StorageItem[]>([])
|
|
32
|
+
|
|
33
|
+
useSourceWiring({
|
|
34
|
+
source,
|
|
35
|
+
enabled,
|
|
36
|
+
active,
|
|
37
|
+
subscribe: s => s.subscribe((evt) => {
|
|
38
|
+
setItems(prev => applyStorageEvent(prev, evt))
|
|
39
|
+
}),
|
|
40
|
+
seed: (s, isDisposed) => {
|
|
41
|
+
void s.getSnapshot().then((next) => {
|
|
42
|
+
if (!isDisposed()) setItems(next)
|
|
43
|
+
})
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// clearAll is re-bound per source: passing it through only when the source
|
|
48
|
+
// has the capability is what makes the view hide the origin-wide wipe.
|
|
49
|
+
const clearAll = source.clearAll?.bind(source)
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<StoragePanel
|
|
53
|
+
items={items}
|
|
54
|
+
onSet={(key, value) => source.setItem(key, value)}
|
|
55
|
+
onRemove={key => source.removeItem(key)}
|
|
56
|
+
onClear={() => source.clear()}
|
|
57
|
+
onClearAll={clearAll}
|
|
58
|
+
getPrefix={() => source.getPrefix()}
|
|
59
|
+
isRuntimeRunning={isRuntimeRunning}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type { WxmlNode, ElementInspection } from './types.js'
|
|
2
|
+
export {
|
|
3
|
+
SYNTHETIC_SID_PREFIX,
|
|
4
|
+
registerSyntheticSid,
|
|
5
|
+
findElementBySid,
|
|
6
|
+
} from './sid-registry.js'
|
|
7
|
+
export { walkInstance, type ComponentInstance } from './wxml-extract.js'
|
|
8
|
+
export {
|
|
9
|
+
createWxmlInspector,
|
|
10
|
+
type WxmlInspector,
|
|
11
|
+
type WxmlInspectorOptions,
|
|
12
|
+
} from './inspector.js'
|
|
13
|
+
export type { WxmlPanelSource } from './panel-source.js'
|
|
14
|
+
export type { StorageItem, StorageEvent, StorageWriteResult } from './storage-types.js'
|
|
15
|
+
export type { StoragePanelSource } from './storage-source.js'
|
|
16
|
+
export { applyStorageEvent } from './storage-reducer.js'
|