@dimina-kit/inspect 0.3.0-dev.20260711141929 → 0.4.0-dev.20260716153350
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -13
- package/dist/appdata-accumulator.js +218 -0
- package/dist/appdata-panel-view.js +64 -0
- package/dist/appdata-source.js +1 -0
- package/dist/compile-panel-view.js +92 -0
- package/dist/compile-source.js +1 -0
- package/dist/compile-types.js +4 -0
- package/dist/connected-appdata-panel.js +32 -0
- package/dist/connected-compile-panel.js +72 -0
- package/dist/index.js +1 -0
- package/dist/panel.js +5 -0
- package/dist/use-active-bridge-id.js +59 -0
- package/dist/wxml-extract.js +39 -103
- package/package.json +4 -1
- package/src/appdata-accumulator.test.ts +83 -0
- package/src/appdata-accumulator.ts +246 -0
- package/src/appdata-panel-view-empty-state.test.tsx +46 -0
- package/src/appdata-panel-view.test.tsx +49 -0
- package/src/appdata-panel-view.tsx +165 -0
- package/src/appdata-source.ts +18 -0
- package/src/compile-panel-view-logs.test.tsx +269 -0
- package/src/compile-panel-view.test.tsx +194 -0
- package/src/compile-panel-view.tsx +236 -0
- package/src/compile-source.ts +32 -0
- package/src/compile-types.ts +35 -0
- package/src/connected-appdata-panel.test.tsx +426 -0
- package/src/connected-appdata-panel.tsx +62 -0
- package/src/connected-compile-panel.test.tsx +500 -0
- package/src/connected-compile-panel.tsx +91 -0
- package/src/index.ts +16 -0
- package/src/panel.tsx +5 -0
- package/src/use-active-bridge-id.test.tsx +202 -0
- package/src/use-active-bridge-id.ts +71 -0
- package/src/wxml-extract.test.ts +70 -0
- package/src/wxml-extract.ts +38 -100
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
|
3
|
+
import { ConnectedAppDataPanel } from './connected-appdata-panel.js'
|
|
4
|
+
import type { AppDataPanelSource } from './appdata-source.js'
|
|
5
|
+
import type { AppDataSnapshot } from './appdata-accumulator.js'
|
|
6
|
+
|
|
7
|
+
const EMPTY_SNAPSHOT: AppDataSnapshot = { bridges: [], entries: {} }
|
|
8
|
+
|
|
9
|
+
function snapshot(
|
|
10
|
+
bridges: AppDataSnapshot['bridges'],
|
|
11
|
+
entries: AppDataSnapshot['entries'] = {},
|
|
12
|
+
): AppDataSnapshot {
|
|
13
|
+
return { bridges, entries }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createDeferred<T>() {
|
|
17
|
+
let resolve!: (value: T) => void
|
|
18
|
+
const promise = new Promise<T>((res) => {
|
|
19
|
+
resolve = res
|
|
20
|
+
})
|
|
21
|
+
return { promise, resolve }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A programmable AppDataPanelSource: getSnapshot() consumes one queued
|
|
26
|
+
* promise per call (defaulting to an empty snapshot), subscribe() records
|
|
27
|
+
* the latest push callback so tests can simulate live AppDataSnapshots, and
|
|
28
|
+
* every method is a spy so call count / order / arguments can be asserted.
|
|
29
|
+
*/
|
|
30
|
+
function createFakeSource() {
|
|
31
|
+
const unsubscribe = vi.fn()
|
|
32
|
+
const getSnapshotQueue: Array<Promise<AppDataSnapshot>> = []
|
|
33
|
+
let latestOnSnapshot: ((snap: AppDataSnapshot) => void) | null = null
|
|
34
|
+
const source: AppDataPanelSource = {
|
|
35
|
+
getSnapshot: vi.fn(() => getSnapshotQueue.shift() ?? Promise.resolve(EMPTY_SNAPSHOT)),
|
|
36
|
+
subscribe: vi.fn((onSnapshot: (snap: AppDataSnapshot) => void) => {
|
|
37
|
+
latestOnSnapshot = onSnapshot
|
|
38
|
+
return unsubscribe
|
|
39
|
+
}),
|
|
40
|
+
setActive: vi.fn(),
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
source,
|
|
44
|
+
unsubscribe,
|
|
45
|
+
getSnapshotQueue,
|
|
46
|
+
pushSnapshot: (snap: AppDataSnapshot) => latestOnSnapshot?.(snap),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Flushes the microtask queue inside `act` so promise-driven setState calls settle. */
|
|
51
|
+
async function flush() {
|
|
52
|
+
await act(async () => {
|
|
53
|
+
await Promise.resolve()
|
|
54
|
+
await Promise.resolve()
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
cleanup()
|
|
60
|
+
vi.unstubAllGlobals()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('ConnectedAppDataPanel: seeding', () => {
|
|
64
|
+
it('seeds via getSnapshot exactly once when enabled and active are true from mount', async () => {
|
|
65
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
66
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: '/pages/index/index' }], {
|
|
67
|
+
b1: { 'pages/index/index': { count: 1 } },
|
|
68
|
+
})))
|
|
69
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
70
|
+
await flush()
|
|
71
|
+
|
|
72
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
73
|
+
expect(document.querySelector('[data-bridge-id="b1"]')).not.toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('does not call getSnapshot while inactive, then seeds on the active rising edge', async () => {
|
|
77
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
78
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }])))
|
|
79
|
+
const { rerender } = render(<ConnectedAppDataPanel source={source} active={false} />)
|
|
80
|
+
await flush()
|
|
81
|
+
expect(source.getSnapshot).not.toHaveBeenCalled()
|
|
82
|
+
|
|
83
|
+
rerender(<ConnectedAppDataPanel source={source} active />)
|
|
84
|
+
await flush()
|
|
85
|
+
|
|
86
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('re-seeds via getSnapshot on the second active false-to-true rising edge', async () => {
|
|
90
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
91
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }])))
|
|
92
|
+
const { rerender } = render(<ConnectedAppDataPanel source={source} active />)
|
|
93
|
+
await flush()
|
|
94
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
95
|
+
|
|
96
|
+
rerender(<ConnectedAppDataPanel source={source} active={false} />)
|
|
97
|
+
await flush()
|
|
98
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
99
|
+
|
|
100
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b2', pagePath: null }])))
|
|
101
|
+
rerender(<ConnectedAppDataPanel source={source} active />)
|
|
102
|
+
await flush()
|
|
103
|
+
|
|
104
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(2)
|
|
105
|
+
expect(document.querySelector('[data-bridge-id="b2"]')).not.toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('makes zero source calls while disabled', async () => {
|
|
109
|
+
const { source } = createFakeSource()
|
|
110
|
+
render(<ConnectedAppDataPanel source={source} enabled={false} />)
|
|
111
|
+
await flush()
|
|
112
|
+
|
|
113
|
+
expect(source.getSnapshot).not.toHaveBeenCalled()
|
|
114
|
+
expect(source.subscribe).not.toHaveBeenCalled()
|
|
115
|
+
expect(source.setActive).not.toHaveBeenCalled()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('ConnectedAppDataPanel: live snapshot push', () => {
|
|
120
|
+
it('replaces the entire rendered snapshot when subscribe pushes a new one', async () => {
|
|
121
|
+
const { source, getSnapshotQueue, pushSnapshot } = createFakeSource()
|
|
122
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }], {
|
|
123
|
+
b1: { 'pages/index/index': { count: 1 } },
|
|
124
|
+
})))
|
|
125
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
126
|
+
await flush()
|
|
127
|
+
expect(document.querySelector('[data-bridge-id="b1"]')).not.toBeNull()
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
pushSnapshot(snapshot([{ id: 'b1', pagePath: null }, { id: 'b2', pagePath: null }], {
|
|
131
|
+
b1: { 'pages/index/index': { count: 2 } },
|
|
132
|
+
b2: { 'pages/detail/detail': { count: 9 } },
|
|
133
|
+
}))
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
expect(document.querySelector('[data-bridge-id="b2"]')).not.toBeNull()
|
|
137
|
+
expect(source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('ConnectedAppDataPanel: visibility and lifecycle', () => {
|
|
142
|
+
it('forwards active prop changes to source.setActive in both directions', async () => {
|
|
143
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
144
|
+
getSnapshotQueue.push(Promise.resolve(EMPTY_SNAPSHOT))
|
|
145
|
+
const { rerender } = render(<ConnectedAppDataPanel source={source} active />)
|
|
146
|
+
await flush()
|
|
147
|
+
|
|
148
|
+
rerender(<ConnectedAppDataPanel source={source} active={false} />)
|
|
149
|
+
await flush()
|
|
150
|
+
expect(source.setActive).toHaveBeenLastCalledWith(false)
|
|
151
|
+
|
|
152
|
+
rerender(<ConnectedAppDataPanel source={source} active />)
|
|
153
|
+
await flush()
|
|
154
|
+
expect(source.setActive).toHaveBeenLastCalledWith(true)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('calls setActive(false) and unsubscribes on unmount', async () => {
|
|
158
|
+
const { source, unsubscribe, getSnapshotQueue } = createFakeSource()
|
|
159
|
+
getSnapshotQueue.push(Promise.resolve(EMPTY_SNAPSHOT))
|
|
160
|
+
const { unmount } = render(<ConnectedAppDataPanel source={source} />)
|
|
161
|
+
await flush()
|
|
162
|
+
|
|
163
|
+
unmount()
|
|
164
|
+
|
|
165
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1)
|
|
166
|
+
expect(source.setActive).toHaveBeenLastCalledWith(false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('tears down the old source and reseeds/resubscribes the new one on a source swap', async () => {
|
|
170
|
+
const first = createFakeSource()
|
|
171
|
+
first.getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }])))
|
|
172
|
+
const { rerender } = render(<ConnectedAppDataPanel source={first.source} />)
|
|
173
|
+
await flush()
|
|
174
|
+
|
|
175
|
+
const second = createFakeSource()
|
|
176
|
+
second.getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b2', pagePath: null }])))
|
|
177
|
+
rerender(<ConnectedAppDataPanel source={second.source} />)
|
|
178
|
+
await flush()
|
|
179
|
+
|
|
180
|
+
expect(first.unsubscribe).toHaveBeenCalledTimes(1)
|
|
181
|
+
expect(first.source.setActive).toHaveBeenLastCalledWith(false)
|
|
182
|
+
expect(second.source.getSnapshot).toHaveBeenCalledTimes(1)
|
|
183
|
+
expect(second.source.subscribe).toHaveBeenCalledTimes(1)
|
|
184
|
+
expect(document.querySelector('[data-bridge-id="b2"]')).not.toBeNull()
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('ConnectedAppDataPanel: stale resolutions', () => {
|
|
189
|
+
it('drops a late getSnapshot resolution after unmount without throwing', async () => {
|
|
190
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
191
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
192
|
+
const deferred = createDeferred<AppDataSnapshot>()
|
|
193
|
+
getSnapshotQueue.push(deferred.promise)
|
|
194
|
+
const { unmount } = render(<ConnectedAppDataPanel source={source} />)
|
|
195
|
+
|
|
196
|
+
unmount()
|
|
197
|
+
|
|
198
|
+
await act(async () => {
|
|
199
|
+
deferred.resolve(snapshot([{ id: 'late', pagePath: null }]))
|
|
200
|
+
await Promise.resolve()
|
|
201
|
+
await Promise.resolve()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
205
|
+
errorSpy.mockRestore()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('drops a late getSnapshot resolution after a source swap without rendering stale data', async () => {
|
|
209
|
+
const first = createFakeSource()
|
|
210
|
+
const deferred = createDeferred<AppDataSnapshot>()
|
|
211
|
+
first.getSnapshotQueue.push(deferred.promise)
|
|
212
|
+
const { rerender } = render(<ConnectedAppDataPanel source={first.source} />)
|
|
213
|
+
await flush()
|
|
214
|
+
|
|
215
|
+
const second = createFakeSource()
|
|
216
|
+
second.getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'fresh', pagePath: null }])))
|
|
217
|
+
rerender(<ConnectedAppDataPanel source={second.source} />)
|
|
218
|
+
await flush()
|
|
219
|
+
expect(document.querySelector('[data-bridge-id="fresh"]')).not.toBeNull()
|
|
220
|
+
|
|
221
|
+
await act(async () => {
|
|
222
|
+
deferred.resolve(snapshot([{ id: 'stale', pagePath: null }]))
|
|
223
|
+
await Promise.resolve()
|
|
224
|
+
await Promise.resolve()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
expect(document.querySelector('[data-bridge-id="stale"]')).toBeNull()
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
232
|
+
it('shows the running-but-empty text when isRuntimeRunning is true and there is no data', async () => {
|
|
233
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
234
|
+
getSnapshotQueue.push(Promise.resolve(EMPTY_SNAPSHOT))
|
|
235
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
236
|
+
await flush()
|
|
237
|
+
|
|
238
|
+
expect(screen.getByTestId('appdata-panel').textContent).toContain('暂无页面数据(仅显示 Page 级 data)')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('shows the not-running text when isRuntimeRunning is false and there is no data', async () => {
|
|
242
|
+
const { source } = createFakeSource()
|
|
243
|
+
render(<ConnectedAppDataPanel source={source} enabled={false} isRuntimeRunning={false} />)
|
|
244
|
+
|
|
245
|
+
expect(screen.getByTestId('appdata-panel').textContent).toContain('小程序未运行')
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('shows the empty state when bridges exist but every bridge has zero entries', async () => {
|
|
249
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
250
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }], { b1: {} })))
|
|
251
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
252
|
+
await flush()
|
|
253
|
+
|
|
254
|
+
expect(screen.getByTestId('appdata-panel').textContent).toContain('暂无页面数据(仅显示 Page 级 data)')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('hides the tab bar when there is only a single bridge', async () => {
|
|
258
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
259
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: '/pages/index/index' }], {
|
|
260
|
+
b1: { 'pages/index/index': { count: 1 } },
|
|
261
|
+
})))
|
|
262
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
263
|
+
await flush()
|
|
264
|
+
|
|
265
|
+
expect(screen.queryByTitle('b1')).toBeNull()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('renders one tab button per bridge, labeled by pagePath and falling back to id when pagePath is null', async () => {
|
|
269
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
270
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([
|
|
271
|
+
{ id: 'b1', pagePath: '/pages/index/index' },
|
|
272
|
+
{ id: 'b2', pagePath: null },
|
|
273
|
+
], {
|
|
274
|
+
b1: { 'pages/index/index': { count: 1 } },
|
|
275
|
+
b2: { 'pages/detail/detail': { count: 2 } },
|
|
276
|
+
})))
|
|
277
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
278
|
+
await flush()
|
|
279
|
+
|
|
280
|
+
const tab1 = screen.getByTitle('b1')
|
|
281
|
+
const tab2 = screen.getByTitle('b2')
|
|
282
|
+
expect(tab1.textContent).toBe('/pages/index/index')
|
|
283
|
+
expect(tab2.textContent).toBe('b2')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('keeps every bridge container mounted and toggles display instead of unmounting on tab switch', async () => {
|
|
287
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
288
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([
|
|
289
|
+
{ id: 'b1', pagePath: '/pages/index/index' },
|
|
290
|
+
{ id: 'b2', pagePath: '/pages/detail/detail' },
|
|
291
|
+
], {
|
|
292
|
+
b1: { 'pages/index/index': { count: 1 } },
|
|
293
|
+
b2: { 'pages/detail/detail': { count: 2 } },
|
|
294
|
+
})))
|
|
295
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
296
|
+
await flush()
|
|
297
|
+
|
|
298
|
+
const c1 = document.querySelector('[data-bridge-id="b1"]') as HTMLElement
|
|
299
|
+
const c2 = document.querySelector('[data-bridge-id="b2"]') as HTMLElement
|
|
300
|
+
expect(c1).not.toBeNull()
|
|
301
|
+
expect(c2).not.toBeNull()
|
|
302
|
+
// auto-follow with no activePagePath lands on the last bridge (b2).
|
|
303
|
+
expect(c2.style.display).toBe('flex')
|
|
304
|
+
expect(c1.style.display).toBe('none')
|
|
305
|
+
|
|
306
|
+
fireEvent.click(screen.getByTitle('b1'))
|
|
307
|
+
|
|
308
|
+
expect(c1.style.display).toBe('flex')
|
|
309
|
+
expect(c2.style.display).toBe('none')
|
|
310
|
+
// Both stay mounted across the switch (keepalive) rather than being torn down.
|
|
311
|
+
expect(document.querySelector('[data-bridge-id="b1"]')).toBe(c1)
|
|
312
|
+
expect(document.querySelector('[data-bridge-id="b2"]')).toBe(c2)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('renders one data section per entry key, headed by the key itself', async () => {
|
|
316
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
317
|
+
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }], {
|
|
318
|
+
b1: {
|
|
319
|
+
'pages/index/index': { count: 1 },
|
|
320
|
+
'components/foo/foo': { visible: true },
|
|
321
|
+
},
|
|
322
|
+
})))
|
|
323
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
324
|
+
await flush()
|
|
325
|
+
|
|
326
|
+
const container = within(document.querySelector('[data-bridge-id="b1"]') as HTMLElement)
|
|
327
|
+
expect(container.getByText('pages/index/index')).not.toBeNull()
|
|
328
|
+
expect(container.getByText('components/foo/foo')).not.toBeNull()
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
333
|
+
const TWO_BRIDGES = [
|
|
334
|
+
{ id: 'b1', pagePath: '/pages/index/index' },
|
|
335
|
+
{ id: 'b2', pagePath: '/pages/detail/detail' },
|
|
336
|
+
]
|
|
337
|
+
const TWO_BRIDGE_ENTRIES = {
|
|
338
|
+
b1: { 'pages/index/index': { count: 1 } },
|
|
339
|
+
b2: { 'pages/detail/detail': { count: 2 } },
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
it('selects the bridge whose pagePath matches activePagePath, ignoring a leading-slash difference', async () => {
|
|
343
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
344
|
+
getSnapshotQueue.push(Promise.resolve(snapshot(TWO_BRIDGES, TWO_BRIDGE_ENTRIES)))
|
|
345
|
+
render(<ConnectedAppDataPanel source={source} activePagePath="pages/detail/detail" />)
|
|
346
|
+
await flush()
|
|
347
|
+
|
|
348
|
+
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
349
|
+
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('none')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('falls back to the last bridge when activePagePath matches no bridge', async () => {
|
|
353
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
354
|
+
getSnapshotQueue.push(Promise.resolve(snapshot(TWO_BRIDGES, TWO_BRIDGE_ENTRIES)))
|
|
355
|
+
render(<ConnectedAppDataPanel source={source} activePagePath="/pages/unknown/unknown" />)
|
|
356
|
+
await flush()
|
|
357
|
+
|
|
358
|
+
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('falls back to the last bridge when activePagePath is not provided', async () => {
|
|
362
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
363
|
+
getSnapshotQueue.push(Promise.resolve(snapshot(TWO_BRIDGES, TWO_BRIDGE_ENTRIES)))
|
|
364
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
365
|
+
await flush()
|
|
366
|
+
|
|
367
|
+
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('keeps a manually selected tab active across snapshot pushes that add no new bridge', async () => {
|
|
371
|
+
const { source, getSnapshotQueue, pushSnapshot } = createFakeSource()
|
|
372
|
+
getSnapshotQueue.push(Promise.resolve(snapshot(TWO_BRIDGES, TWO_BRIDGE_ENTRIES)))
|
|
373
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
374
|
+
await flush()
|
|
375
|
+
|
|
376
|
+
fireEvent.click(screen.getByTitle('b1'))
|
|
377
|
+
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
378
|
+
|
|
379
|
+
act(() => {
|
|
380
|
+
pushSnapshot(snapshot(TWO_BRIDGES, {
|
|
381
|
+
b1: { 'pages/index/index': { count: 100 } },
|
|
382
|
+
b2: { 'pages/detail/detail': { count: 200 } },
|
|
383
|
+
}))
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
387
|
+
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('none')
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('resets the manual selection back to auto-follow when a bridge id never seen before appears', async () => {
|
|
391
|
+
const { source, getSnapshotQueue, pushSnapshot } = createFakeSource()
|
|
392
|
+
getSnapshotQueue.push(Promise.resolve(snapshot(TWO_BRIDGES, TWO_BRIDGE_ENTRIES)))
|
|
393
|
+
render(<ConnectedAppDataPanel source={source} />)
|
|
394
|
+
await flush()
|
|
395
|
+
|
|
396
|
+
fireEvent.click(screen.getByTitle('b1'))
|
|
397
|
+
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
398
|
+
|
|
399
|
+
act(() => {
|
|
400
|
+
pushSnapshot(snapshot([...TWO_BRIDGES, { id: 'b3', pagePath: '/pages/new/new' }], {
|
|
401
|
+
...TWO_BRIDGE_ENTRIES,
|
|
402
|
+
b3: { 'pages/new/new': { count: 3 } },
|
|
403
|
+
}))
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// Auto-follow resumes: with no activePagePath, the newest (last) bridge wins again.
|
|
407
|
+
expect((document.querySelector('[data-bridge-id="b3"]') as HTMLElement).style.display).toBe('flex')
|
|
408
|
+
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('none')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('resets the manual selection back to auto-follow when activePagePath changes', async () => {
|
|
412
|
+
const { source, getSnapshotQueue } = createFakeSource()
|
|
413
|
+
getSnapshotQueue.push(Promise.resolve(snapshot(TWO_BRIDGES, TWO_BRIDGE_ENTRIES)))
|
|
414
|
+
const { rerender } = render(<ConnectedAppDataPanel source={source} activePagePath="/pages/index/index" />)
|
|
415
|
+
await flush()
|
|
416
|
+
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
417
|
+
|
|
418
|
+
fireEvent.click(screen.getByTitle('b2'))
|
|
419
|
+
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
420
|
+
|
|
421
|
+
rerender(<ConnectedAppDataPanel source={source} activePagePath="/pages/detail/detail" />)
|
|
422
|
+
|
|
423
|
+
// The activePagePath prop change overrides the stale manual pick with the new auto-follow target.
|
|
424
|
+
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
425
|
+
})
|
|
426
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The AppData panel's data wiring, written once against AppDataPanelSource:
|
|
2
|
+
// seed on the (enabled && active) rising edge, stay live by replacing state
|
|
3
|
+
// with each pushed full snapshot, forward the visibility gate, and own the
|
|
4
|
+
// bridge-tab selection (auto-follow of the simulator's active page + manual
|
|
5
|
+
// picks). Hosts render this with their transport implementation (Electron
|
|
6
|
+
// IPC, a same-origin Worker tap, …) and the pure AppDataPanel view underneath
|
|
7
|
+
// never needs host-specific code.
|
|
8
|
+
import { useState } from 'react'
|
|
9
|
+
import { AppDataPanel } from './appdata-panel-view.js'
|
|
10
|
+
import { useActiveBridgeId } from './use-active-bridge-id.js'
|
|
11
|
+
import { useSourceWiring } from './use-source-wiring.js'
|
|
12
|
+
import type { AppDataPanelSource } from './appdata-source.js'
|
|
13
|
+
import type { AppDataSnapshot } from './appdata-accumulator.js'
|
|
14
|
+
|
|
15
|
+
export interface ConnectedAppDataPanelProps {
|
|
16
|
+
source: AppDataPanelSource
|
|
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 snapshot. Defaults to
|
|
21
|
+
* true. */
|
|
22
|
+
enabled?: boolean
|
|
23
|
+
isRuntimeRunning?: boolean
|
|
24
|
+
/** The simulator's active page path; the bridge tabs auto-follow it. */
|
|
25
|
+
activePagePath?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const EMPTY_SNAPSHOT: AppDataSnapshot = { bridges: [], entries: {} }
|
|
29
|
+
|
|
30
|
+
export function ConnectedAppDataPanel({
|
|
31
|
+
source,
|
|
32
|
+
active = true,
|
|
33
|
+
enabled = true,
|
|
34
|
+
isRuntimeRunning = true,
|
|
35
|
+
activePagePath = '',
|
|
36
|
+
}: ConnectedAppDataPanelProps) {
|
|
37
|
+
const [snapshot, setSnapshot] = useState<AppDataSnapshot>(EMPTY_SNAPSHOT)
|
|
38
|
+
|
|
39
|
+
useSourceWiring({
|
|
40
|
+
source,
|
|
41
|
+
enabled,
|
|
42
|
+
active,
|
|
43
|
+
subscribe: s => s.subscribe((next) => {
|
|
44
|
+
setSnapshot(next)
|
|
45
|
+
}),
|
|
46
|
+
seed: (s, isDisposed) => {
|
|
47
|
+
void s.getSnapshot().then((next) => {
|
|
48
|
+
if (!isDisposed()) setSnapshot(next)
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const { activeBridgeId, setActiveBridge } = useActiveBridgeId(snapshot.bridges, activePagePath)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<AppDataPanel
|
|
57
|
+
state={{ bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }}
|
|
58
|
+
onSelectBridge={setActiveBridge}
|
|
59
|
+
isRuntimeRunning={isRuntimeRunning}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|