@dimina-kit/inspect 0.4.0-dev.20260716163758 → 0.4.0-dev.20260717120050
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 +7 -1
- package/dist/appdata-panel-view.js +155 -58
- package/dist/appdata-tree.js +143 -0
- package/dist/connected-appdata-panel.js +9 -1
- package/dist/panel-view.js +8 -3
- package/package.json +1 -1
- package/src/appdata-edge-values.test.tsx +150 -0
- package/src/appdata-panel-view-wechat.test.tsx +268 -0
- package/src/appdata-panel-view.tsx +237 -107
- package/src/appdata-source.ts +5 -0
- package/src/appdata-tree-edit-hardening.test.tsx +384 -0
- package/src/appdata-tree-edit.test.tsx +247 -0
- package/src/appdata-tree.tsx +265 -0
- package/src/appdata-write-serialization.test.tsx +134 -0
- package/src/connected-appdata-panel.test.tsx +23 -15
- package/src/connected-appdata-panel.tsx +10 -0
- package/src/panel-view-root-expand.test.tsx +53 -0
- package/src/panel-view.tsx +7 -3
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// The AppData tree: one page's merged setData state as a collapsible,
|
|
2
|
+
// optionally editable tree (WeChat DevTools AppData semantics). Pure
|
|
3
|
+
// presentation + local expansion/edit state; committed edits are reported
|
|
4
|
+
// upward as (path, next, prev) — the panel owns the undo/redo stack and the
|
|
5
|
+
// actual write-back, and the rendered VALUES always come from `root`, never
|
|
6
|
+
// from a local echo of an edit.
|
|
7
|
+
import React, { useState } from 'react'
|
|
8
|
+
|
|
9
|
+
/** One expand/collapse-all toolbar action. Every keepalive tree instance sees
|
|
10
|
+
* the same command object; a tree applies it only when `bridgeId` is its own
|
|
11
|
+
* and `seq` is unseen — a background tree must not replay a command that was
|
|
12
|
+
* aimed at the tree visible when the button was clicked. */
|
|
13
|
+
export interface AppDataTreeCommand {
|
|
14
|
+
seq: number
|
|
15
|
+
mode: 'expanded' | 'collapsed'
|
|
16
|
+
bridgeId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AppDataTreeProps {
|
|
20
|
+
root: Record<string, unknown>
|
|
21
|
+
bridgeId: string
|
|
22
|
+
command: AppDataTreeCommand | null
|
|
23
|
+
/** Absent → read-only tree (no checkboxes, double-click is inert). */
|
|
24
|
+
onCommit?: (path: string, next: unknown, prev: unknown) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** WeChat setData path syntax: dots between keys, indices in brackets. */
|
|
28
|
+
function childPath(parent: string, key: string | number): string {
|
|
29
|
+
if (typeof key === 'number') return `${parent}[${key}]`
|
|
30
|
+
return parent === '' ? key : `${parent}.${key}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isContainer(v: unknown): v is object {
|
|
34
|
+
return typeof v === 'object' && v !== null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sortedEntries(value: object): Array<[string | number, unknown]> {
|
|
38
|
+
// Array.isArray narrows to any[]; the explicit unknown[] view keeps the
|
|
39
|
+
// elements typed (type-coverage counts every any-typed identifier).
|
|
40
|
+
if (Array.isArray(value)) return (value as unknown[]).map((v, i) => [i, v])
|
|
41
|
+
return Object.keys(value).sort().map((k) => [k, (value as Record<string, unknown>)[k]])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function countLabel(value: object): string {
|
|
45
|
+
return Array.isArray(value) ? `[${value.length}]` : `{${Object.keys(value).length}}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// default: only the root row is open; expanded/collapsed: everything is.
|
|
49
|
+
type Baseline = 'default' | 'expanded' | 'collapsed'
|
|
50
|
+
const ROOT_PATH = ''
|
|
51
|
+
|
|
52
|
+
interface TreeState {
|
|
53
|
+
baseline: Baseline
|
|
54
|
+
overrides: Map<string, boolean>
|
|
55
|
+
editingPath: string | null
|
|
56
|
+
draft: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isExpanded(state: TreeState, path: string): boolean {
|
|
60
|
+
const override = state.overrides.get(path)
|
|
61
|
+
if (override !== undefined) return override
|
|
62
|
+
if (state.baseline === 'expanded') return true
|
|
63
|
+
if (state.baseline === 'collapsed') return false
|
|
64
|
+
return path === ROOT_PATH
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface NodeContext {
|
|
68
|
+
state: TreeState
|
|
69
|
+
toggle: (path: string, value: unknown) => void
|
|
70
|
+
beginEdit: (path: string, initial: string) => void
|
|
71
|
+
setDraft: (draft: string) => void
|
|
72
|
+
endEdit: () => void
|
|
73
|
+
onCommit?: (path: string, next: unknown, prev: unknown) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ValueCell({ path, value, editable, ctx }: {
|
|
77
|
+
path: string
|
|
78
|
+
value: unknown
|
|
79
|
+
editable: boolean
|
|
80
|
+
ctx: NodeContext
|
|
81
|
+
}) {
|
|
82
|
+
if (typeof value === 'boolean') {
|
|
83
|
+
return (
|
|
84
|
+
<span className="inline-flex items-center gap-1 text-code-keyword">
|
|
85
|
+
{editable && (
|
|
86
|
+
<input
|
|
87
|
+
type="checkbox"
|
|
88
|
+
checked={value}
|
|
89
|
+
onChange={() => ctx.onCommit?.(path, !value, value)}
|
|
90
|
+
className="accent-accent"
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
{String(value)}
|
|
94
|
+
</span>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
if (value === null || value === undefined) {
|
|
98
|
+
return <span className="text-code-keyword">{String(value)}</span>
|
|
99
|
+
}
|
|
100
|
+
if (ctx.state.editingPath === path) {
|
|
101
|
+
const commit = (): void => {
|
|
102
|
+
const prev = value
|
|
103
|
+
if (typeof prev === 'number') {
|
|
104
|
+
const draft = ctx.state.draft.trim()
|
|
105
|
+
const next = Number(draft)
|
|
106
|
+
ctx.endEdit()
|
|
107
|
+
// Only finite numbers commit: Infinity/-Infinity (and 1e309-style
|
|
108
|
+
// overflow) are not serializable AppData values.
|
|
109
|
+
if (draft === '' || !Number.isFinite(next)) return
|
|
110
|
+
ctx.onCommit?.(path, next, prev)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const next = ctx.state.draft
|
|
114
|
+
ctx.endEdit()
|
|
115
|
+
ctx.onCommit?.(path, next, prev)
|
|
116
|
+
}
|
|
117
|
+
return (
|
|
118
|
+
<input
|
|
119
|
+
type="text"
|
|
120
|
+
autoFocus
|
|
121
|
+
value={ctx.state.draft}
|
|
122
|
+
onChange={(e) => ctx.setDraft(e.target.value)}
|
|
123
|
+
onKeyDown={(e) => {
|
|
124
|
+
if (e.key === 'Enter') commit()
|
|
125
|
+
else if (e.key === 'Escape') ctx.endEdit()
|
|
126
|
+
}}
|
|
127
|
+
onBlur={commit}
|
|
128
|
+
className="bg-surface-3 border border-accent rounded px-1 text-[12px] font-mono min-w-0 w-40"
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
const color = typeof value === 'number'
|
|
133
|
+
? { color: 'var(--color-code-number)' }
|
|
134
|
+
: undefined
|
|
135
|
+
return (
|
|
136
|
+
<span
|
|
137
|
+
data-testid="appdata-value"
|
|
138
|
+
className={typeof value === 'string' ? 'text-code-orange' : undefined}
|
|
139
|
+
style={color}
|
|
140
|
+
onDoubleClick={editable ? () => ctx.beginEdit(path, String(value)) : undefined}
|
|
141
|
+
>
|
|
142
|
+
{String(value)}
|
|
143
|
+
</span>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True when a key segment would be re-parsed by the runtime's lodash-style
|
|
148
|
+
* `toPath` (dots / brackets), or silently DROPPED by it (empty keys — toPath
|
|
149
|
+
* never pushes an empty segment, so `profile.` parses to just `['profile']`
|
|
150
|
+
* and a write would overwrite the parent). Array indices are numbers and
|
|
151
|
+
* always safe. */
|
|
152
|
+
function segmentUnsafe(key: string | number): boolean {
|
|
153
|
+
return typeof key === 'string' && (key === '' || /[.[\]]/.test(key))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function TreeNode({ path, label, value, depth, unsafeSegments, ctx }: {
|
|
157
|
+
path: string
|
|
158
|
+
label: string
|
|
159
|
+
value: unknown
|
|
160
|
+
depth: number
|
|
161
|
+
/** Some segment on this node's path (its own key included) contains `.`/`[`/`]`. */
|
|
162
|
+
unsafeSegments: boolean
|
|
163
|
+
ctx: NodeContext
|
|
164
|
+
}) {
|
|
165
|
+
const indent = { paddingLeft: depth * 14 }
|
|
166
|
+
if (isContainer(value)) {
|
|
167
|
+
const open = isExpanded(ctx.state, path)
|
|
168
|
+
return (
|
|
169
|
+
<div>
|
|
170
|
+
<div
|
|
171
|
+
className="flex items-center gap-1 px-2 py-px cursor-pointer hover:bg-surface-3 text-[12px] font-mono"
|
|
172
|
+
style={indent}
|
|
173
|
+
onClick={() => ctx.toggle(path, value)}
|
|
174
|
+
>
|
|
175
|
+
<span className="text-text-secondary w-3 shrink-0 text-center select-none">
|
|
176
|
+
{open ? '▾' : '▸'}
|
|
177
|
+
</span>
|
|
178
|
+
<span className="text-code-blue">{label}</span>
|
|
179
|
+
<span className="text-text-secondary">{countLabel(value)}</span>
|
|
180
|
+
</div>
|
|
181
|
+
{open && sortedEntries(value).map(([key, child]) => (
|
|
182
|
+
<TreeNode
|
|
183
|
+
key={String(key)}
|
|
184
|
+
path={childPath(path, key)}
|
|
185
|
+
label={String(key)}
|
|
186
|
+
value={child}
|
|
187
|
+
depth={depth + 1}
|
|
188
|
+
unsafeSegments={unsafeSegments || segmentUnsafe(key)}
|
|
189
|
+
ctx={ctx}
|
|
190
|
+
/>
|
|
191
|
+
))}
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
const primitive = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
196
|
+
// A multi-segment path with an unsafe segment cannot round-trip through the
|
|
197
|
+
// runtime's string-path `set()` — the patch key would be re-split on the
|
|
198
|
+
// dots/brackets inside the key and write a DIFFERENT field. A single-segment
|
|
199
|
+
// (top-level) key is safe regardless of content: the runtime's own-key check
|
|
200
|
+
// short-circuits before path parsing. `__proto__` is unwritable at ANY
|
|
201
|
+
// depth: the runtime's isUnsafeProperty drops the write outright. Unsafe
|
|
202
|
+
// rows render read-only.
|
|
203
|
+
const pathAmbiguous = (depth > 1 && unsafeSegments) || label === '__proto__'
|
|
204
|
+
const editable = primitive && ctx.onCommit !== undefined && !pathAmbiguous
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
className="flex items-center gap-1 px-2 py-px text-[12px] font-mono"
|
|
208
|
+
style={indent}
|
|
209
|
+
{...(editable ? { 'data-path': path } : {})}
|
|
210
|
+
>
|
|
211
|
+
<span className="w-3 shrink-0" />
|
|
212
|
+
<span className="text-code-blue">{label}</span>
|
|
213
|
+
<span className="text-text-secondary">:</span>
|
|
214
|
+
<ValueCell path={path} value={value} editable={editable} ctx={ctx} />
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function AppDataTree({ root, bridgeId, command, onCommit }: AppDataTreeProps) {
|
|
220
|
+
const [baseline, setBaseline] = useState<Baseline>('default')
|
|
221
|
+
const [overrides, setOverrides] = useState<Map<string, boolean>>(new Map())
|
|
222
|
+
const [appliedSeq, setAppliedSeq] = useState(0)
|
|
223
|
+
const [editingPath, setEditingPath] = useState<string | null>(null)
|
|
224
|
+
const [draft, setDraft] = useState('')
|
|
225
|
+
|
|
226
|
+
// Applying a toolbar command during render (state-adjust-in-render, same
|
|
227
|
+
// pattern as useActiveBridgeId) keeps the frame it lands on consistent.
|
|
228
|
+
if (command && command.bridgeId === bridgeId && command.seq !== appliedSeq) {
|
|
229
|
+
setBaseline(command.mode)
|
|
230
|
+
setOverrides(new Map())
|
|
231
|
+
setAppliedSeq(command.seq)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const state: TreeState = { baseline, overrides, editingPath, draft }
|
|
235
|
+
const ctx: NodeContext = {
|
|
236
|
+
state,
|
|
237
|
+
toggle: (path, value) => {
|
|
238
|
+
const next = new Map(overrides)
|
|
239
|
+
const opening = !isExpanded(state, path)
|
|
240
|
+
next.set(path, opening)
|
|
241
|
+
// Opening an array also opens its container elements: the elements are
|
|
242
|
+
// anonymous index rows, so surfacing their fields in the same click is
|
|
243
|
+
// what makes `list[0].id` reachable without a second dig.
|
|
244
|
+
if (opening && Array.isArray(value)) {
|
|
245
|
+
;(value as unknown[]).forEach((element, i) => {
|
|
246
|
+
if (isContainer(element)) next.set(childPath(path, i), true)
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
setOverrides(next)
|
|
250
|
+
},
|
|
251
|
+
beginEdit: (path, initial) => {
|
|
252
|
+
setEditingPath(path)
|
|
253
|
+
setDraft(initial)
|
|
254
|
+
},
|
|
255
|
+
setDraft,
|
|
256
|
+
endEdit: () => setEditingPath(null),
|
|
257
|
+
onCommit,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<div data-testid="appdata-tree" className="py-1">
|
|
262
|
+
<TreeNode path={ROOT_PATH} label="object" value={root} depth={0} unsafeSegments={false} ctx={ctx} />
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppDataPanel — a single write gate admits at most one in-flight write
|
|
3
|
+
* across every write path (commit and replay share it): while a write is
|
|
4
|
+
* pending, any further commit or replay request is dropped outright — no
|
|
5
|
+
* local echo, no queued follow-up, no second `onSetData` call. This is what
|
|
6
|
+
* keeps undo/redo stack order honest when a click races an in-flight async
|
|
7
|
+
* write:
|
|
8
|
+
*
|
|
9
|
+
* 1. A commit fired while a replay (撤销/重做) is in flight is dropped, so a
|
|
10
|
+
* resolving replay can never re-add a record that a same-turn commit
|
|
11
|
+
* already invalidated by clearing the redo stack.
|
|
12
|
+
* 2. A second commit fired while the first is still in flight is dropped, so
|
|
13
|
+
* the undo stack only ever grows by the write that was actually
|
|
14
|
+
* dispatched — never out of commit order.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
17
|
+
import { fireEvent, render, waitFor, within } from '@testing-library/react'
|
|
18
|
+
import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
|
|
19
|
+
|
|
20
|
+
function writableState(): AppDataState {
|
|
21
|
+
return {
|
|
22
|
+
bridges: [{ id: 'b1', pagePath: '/pages/index/index' }],
|
|
23
|
+
activeBridgeId: 'b1',
|
|
24
|
+
entries: {
|
|
25
|
+
b1: {
|
|
26
|
+
comp: {
|
|
27
|
+
flag: true,
|
|
28
|
+
label: 'hello',
|
|
29
|
+
seed: 'S0',
|
|
30
|
+
a: 'A0',
|
|
31
|
+
b: 'B0',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function checkboxFor(container: HTMLElement, path: string): HTMLInputElement {
|
|
39
|
+
const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
|
|
40
|
+
return row.querySelector('input[type="checkbox"]') as HTMLInputElement
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function valueElFor(container: HTMLElement, path: string): HTMLElement {
|
|
44
|
+
const row = container.querySelector(`[data-path="${path}"]`) as HTMLElement
|
|
45
|
+
return within(row).getByTestId('appdata-value')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Double-click a string/number row, replace its text, and commit on Enter. */
|
|
49
|
+
function commitText(container: HTMLElement, path: string, value: string): void {
|
|
50
|
+
fireEvent.dblClick(valueElFor(container, path))
|
|
51
|
+
const input = within(container).getByRole('textbox') as HTMLInputElement
|
|
52
|
+
fireEvent.change(input, { target: { value } })
|
|
53
|
+
fireEvent.keyDown(input, { key: 'Enter' })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolves/rejects on demand — lets a test hold `onSetData` open mid-dispatch
|
|
57
|
+
* to exercise pending/reentrancy behavior before settling it. */
|
|
58
|
+
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
|
59
|
+
let resolve!: (value: T) => void
|
|
60
|
+
const promise = new Promise<T>((res) => { resolve = res })
|
|
61
|
+
return { promise, resolve }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('AppDataPanel: a commit is dropped while a replay is in flight', () => {
|
|
65
|
+
it('never calls onSetData for the dropped commit, and settles into a single redo record — never a resurrected one', async () => {
|
|
66
|
+
const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
|
|
67
|
+
const { container, getByTitle } = render(
|
|
68
|
+
<AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
|
|
69
|
+
)
|
|
70
|
+
fireEvent.click(checkboxFor(container, 'flag'))
|
|
71
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
|
|
72
|
+
|
|
73
|
+
const write = deferred<boolean>()
|
|
74
|
+
onSetData.mockImplementation(() => write.promise)
|
|
75
|
+
onSetData.mockClear()
|
|
76
|
+
|
|
77
|
+
fireEvent.click(getByTitle('撤销'))
|
|
78
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
79
|
+
|
|
80
|
+
// A commit on an unrelated field lands mid-replay — the write gate must
|
|
81
|
+
// drop it before it ever reaches onSetData.
|
|
82
|
+
commitText(container, 'label', 'ignored')
|
|
83
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
84
|
+
|
|
85
|
+
write.resolve(true)
|
|
86
|
+
await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(false))
|
|
87
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
|
|
88
|
+
// Stack state matches "the dropped commit never happened": exactly the
|
|
89
|
+
// one replayed record moved to redo, nothing extra came back onto it.
|
|
90
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
91
|
+
|
|
92
|
+
fireEvent.click(getByTitle('重做'))
|
|
93
|
+
await waitFor(() => expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true))
|
|
94
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('AppDataPanel: a second commit is dropped while the first is in flight', () => {
|
|
99
|
+
it('calls onSetData once for the in-flight commit, disables 撤销/重做 while it is pending even with a nonempty stack, and undoes only the write that was actually dispatched', async () => {
|
|
100
|
+
const onSetData = vi.fn<(bridgeId: string, patch: Record<string, unknown>) => boolean | Promise<boolean>>(() => true)
|
|
101
|
+
const { container, getByTitle } = render(
|
|
102
|
+
<AppDataPanel state={writableState()} onSelectBridge={vi.fn()} onSetData={onSetData} />,
|
|
103
|
+
)
|
|
104
|
+
// Seed a synchronously-settled undo record so the stack is nonempty
|
|
105
|
+
// before the race below — otherwise "disabled" would be indistinguishable
|
|
106
|
+
// from an empty-stack disable, not the write gate's pending state.
|
|
107
|
+
commitText(container, 'seed', 'S1')
|
|
108
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false)
|
|
109
|
+
|
|
110
|
+
const pending: Array<(value: boolean) => void> = []
|
|
111
|
+
onSetData.mockImplementation(() => new Promise<boolean>((resolve) => { pending.push(resolve) }))
|
|
112
|
+
onSetData.mockClear()
|
|
113
|
+
|
|
114
|
+
commitText(container, 'a', 'A1')
|
|
115
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
116
|
+
// The write gate's pending state disables both buttons even though the
|
|
117
|
+
// undo stack is nonempty — an in-flight commit holds the gate exactly
|
|
118
|
+
// like an in-flight replay does.
|
|
119
|
+
expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(true)
|
|
120
|
+
expect((getByTitle('重做') as HTMLButtonElement).disabled).toBe(true)
|
|
121
|
+
|
|
122
|
+
commitText(container, 'b', 'B1')
|
|
123
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
124
|
+
|
|
125
|
+
pending[0]?.(true)
|
|
126
|
+
await waitFor(() => expect((getByTitle('撤销') as HTMLButtonElement).disabled).toBe(false))
|
|
127
|
+
expect(onSetData).toHaveBeenCalledTimes(1)
|
|
128
|
+
|
|
129
|
+
fireEvent.click(getByTitle('撤销'))
|
|
130
|
+
// Only `a` was ever committed — `b` never reached onSetData — so the
|
|
131
|
+
// record undo replays is necessarily `a`'s.
|
|
132
|
+
expect(onSetData).toHaveBeenLastCalledWith('b1', { a: 'A0' })
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -55,6 +55,14 @@ async function flush() {
|
|
|
55
55
|
})
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** Finds a Pages sidebar row by its rendered label (pagePath, or id when pagePath is null). */
|
|
59
|
+
function pageItem(label: string): HTMLElement {
|
|
60
|
+
const items = screen.getAllByTestId('appdata-page-item')
|
|
61
|
+
const found = items.find(el => (el.textContent ?? '').trim() === label)
|
|
62
|
+
if (!found) throw new Error(`no page item labeled "${label}" among [${items.map(el => el.textContent).join(', ')}]`)
|
|
63
|
+
return found
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
afterEach(() => {
|
|
59
67
|
cleanup()
|
|
60
68
|
vi.unstubAllGlobals()
|
|
@@ -254,7 +262,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
254
262
|
expect(screen.getByTestId('appdata-panel').textContent).toContain('暂无页面数据(仅显示 Page 级 data)')
|
|
255
263
|
})
|
|
256
264
|
|
|
257
|
-
it('
|
|
265
|
+
it('renders the Pages sidebar with a single item even when there is only one bridge', async () => {
|
|
258
266
|
const { source, getSnapshotQueue } = createFakeSource()
|
|
259
267
|
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: '/pages/index/index' }], {
|
|
260
268
|
b1: { 'pages/index/index': { count: 1 } },
|
|
@@ -262,10 +270,11 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
262
270
|
render(<ConnectedAppDataPanel source={source} />)
|
|
263
271
|
await flush()
|
|
264
272
|
|
|
265
|
-
expect(screen.
|
|
273
|
+
expect(screen.getByTestId('appdata-pages')).not.toBeNull()
|
|
274
|
+
expect(screen.getAllByTestId('appdata-page-item')).toHaveLength(1)
|
|
266
275
|
})
|
|
267
276
|
|
|
268
|
-
it('renders one
|
|
277
|
+
it('renders one Pages sidebar item per bridge, labeled by pagePath and falling back to id when pagePath is null', async () => {
|
|
269
278
|
const { source, getSnapshotQueue } = createFakeSource()
|
|
270
279
|
getSnapshotQueue.push(Promise.resolve(snapshot([
|
|
271
280
|
{ id: 'b1', pagePath: '/pages/index/index' },
|
|
@@ -277,10 +286,9 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
277
286
|
render(<ConnectedAppDataPanel source={source} />)
|
|
278
287
|
await flush()
|
|
279
288
|
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
expect(
|
|
283
|
-
expect(tab2.textContent).toBe('b2')
|
|
289
|
+
const labels = screen.getAllByTestId('appdata-page-item').map(el => (el.textContent ?? '').trim())
|
|
290
|
+
expect(labels).toContain('/pages/index/index')
|
|
291
|
+
expect(labels).toContain('b2')
|
|
284
292
|
})
|
|
285
293
|
|
|
286
294
|
it('keeps every bridge container mounted and toggles display instead of unmounting on tab switch', async () => {
|
|
@@ -303,7 +311,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
303
311
|
expect(c2.style.display).toBe('flex')
|
|
304
312
|
expect(c1.style.display).toBe('none')
|
|
305
313
|
|
|
306
|
-
fireEvent.click(
|
|
314
|
+
fireEvent.click(pageItem('/pages/index/index'))
|
|
307
315
|
|
|
308
316
|
expect(c1.style.display).toBe('flex')
|
|
309
317
|
expect(c2.style.display).toBe('none')
|
|
@@ -312,7 +320,7 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
312
320
|
expect(document.querySelector('[data-bridge-id="b2"]')).toBe(c2)
|
|
313
321
|
})
|
|
314
322
|
|
|
315
|
-
it('
|
|
323
|
+
it('merges every entry\'s data into one tree so no bridge data is lost through the connected wiring', async () => {
|
|
316
324
|
const { source, getSnapshotQueue } = createFakeSource()
|
|
317
325
|
getSnapshotQueue.push(Promise.resolve(snapshot([{ id: 'b1', pagePath: null }], {
|
|
318
326
|
b1: {
|
|
@@ -323,9 +331,9 @@ describe('ConnectedAppDataPanel: rendering contract', () => {
|
|
|
323
331
|
render(<ConnectedAppDataPanel source={source} />)
|
|
324
332
|
await flush()
|
|
325
333
|
|
|
326
|
-
const
|
|
327
|
-
expect(
|
|
328
|
-
expect(
|
|
334
|
+
const tree = within(document.querySelector('[data-bridge-id="b1"]') as HTMLElement).getByTestId('appdata-tree')
|
|
335
|
+
expect(tree.textContent).toContain('count')
|
|
336
|
+
expect(tree.textContent).toContain('visible')
|
|
329
337
|
})
|
|
330
338
|
})
|
|
331
339
|
|
|
@@ -373,7 +381,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
|
373
381
|
render(<ConnectedAppDataPanel source={source} />)
|
|
374
382
|
await flush()
|
|
375
383
|
|
|
376
|
-
fireEvent.click(
|
|
384
|
+
fireEvent.click(pageItem('/pages/index/index'))
|
|
377
385
|
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
378
386
|
|
|
379
387
|
act(() => {
|
|
@@ -393,7 +401,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
|
393
401
|
render(<ConnectedAppDataPanel source={source} />)
|
|
394
402
|
await flush()
|
|
395
403
|
|
|
396
|
-
fireEvent.click(
|
|
404
|
+
fireEvent.click(pageItem('/pages/index/index'))
|
|
397
405
|
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
398
406
|
|
|
399
407
|
act(() => {
|
|
@@ -415,7 +423,7 @@ describe('ConnectedAppDataPanel: active bridge auto-follow', () => {
|
|
|
415
423
|
await flush()
|
|
416
424
|
expect((document.querySelector('[data-bridge-id="b1"]') as HTMLElement).style.display).toBe('flex')
|
|
417
425
|
|
|
418
|
-
fireEvent.click(
|
|
426
|
+
fireEvent.click(pageItem('/pages/detail/detail'))
|
|
419
427
|
expect((document.querySelector('[data-bridge-id="b2"]') as HTMLElement).style.display).toBe('flex')
|
|
420
428
|
|
|
421
429
|
rerender(<ConnectedAppDataPanel source={source} activePagePath="/pages/detail/detail" />)
|
|
@@ -52,11 +52,21 @@ export function ConnectedAppDataPanel({
|
|
|
52
52
|
|
|
53
53
|
const { activeBridgeId, setActiveBridge } = useActiveBridgeId(snapshot.bridges, activePagePath)
|
|
54
54
|
|
|
55
|
+
// Only a source with a write-back channel makes the tree editable. The
|
|
56
|
+
// dispatch result flows through to the panel: its undo/redo stacks only
|
|
57
|
+
// advance when the runtime actually accepted the write. The authoritative
|
|
58
|
+
// new VALUE still arrives back through the snapshot push, never locally.
|
|
59
|
+
const setData = source.setData?.bind(source)
|
|
60
|
+
const onSetData = setData
|
|
61
|
+
? (bridgeId: string, patch: Record<string, unknown>): Promise<boolean> => setData(bridgeId, patch)
|
|
62
|
+
: undefined
|
|
63
|
+
|
|
55
64
|
return (
|
|
56
65
|
<AppDataPanel
|
|
57
66
|
state={{ bridges: snapshot.bridges, activeBridgeId, entries: snapshot.entries }}
|
|
58
67
|
onSelectBridge={setActiveBridge}
|
|
59
68
|
isRuntimeRunning={isRuntimeRunning}
|
|
69
|
+
onSetData={onSetData}
|
|
60
70
|
/>
|
|
61
71
|
)
|
|
62
72
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WxmlPanel root-expansion contract: the tree's root node renders expanded by
|
|
3
|
+
* default. A collapsed root hides the ENTIRE tree behind a single ▸ row, so
|
|
4
|
+
* the panel would open showing no structure at all — the first level must be
|
|
5
|
+
* visible without a manual expand click. Guards against expand heuristics
|
|
6
|
+
* that key off tag shape (e.g. component-path tags) and silently stop
|
|
7
|
+
* matching the root when the runtime's tag naming changes.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest'
|
|
10
|
+
import { render } from '@testing-library/react'
|
|
11
|
+
import { WxmlPanel } from './panel-view.js'
|
|
12
|
+
import type { WxmlNode } from './types.js'
|
|
13
|
+
|
|
14
|
+
const TREE: WxmlNode = {
|
|
15
|
+
tagName: 'page',
|
|
16
|
+
attrs: {},
|
|
17
|
+
children: [
|
|
18
|
+
{ tagName: 'view', attrs: { class: 'container' }, children: [] },
|
|
19
|
+
],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('WxmlPanel: root renders expanded by default', () => {
|
|
23
|
+
it('shows the root children without a manual expand click', () => {
|
|
24
|
+
const { container } = render(<WxmlPanel tree={TREE} />)
|
|
25
|
+
const text = container.textContent ?? ''
|
|
26
|
+
expect(text).toContain('page')
|
|
27
|
+
expect(
|
|
28
|
+
text,
|
|
29
|
+
'the first tree level must be visible on open — a collapsed root leaves the panel blank',
|
|
30
|
+
).toContain('container')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('keeps non-root plain-tag nodes collapsed by default', () => {
|
|
34
|
+
const nested: WxmlNode = {
|
|
35
|
+
tagName: 'page',
|
|
36
|
+
attrs: {},
|
|
37
|
+
children: [
|
|
38
|
+
{
|
|
39
|
+
tagName: 'view',
|
|
40
|
+
attrs: { class: 'outer' },
|
|
41
|
+
children: [{ tagName: 'text', attrs: { class: 'inner-marker' }, children: [] }],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
const { container } = render(<WxmlPanel tree={nested} />)
|
|
46
|
+
const text = container.textContent ?? ''
|
|
47
|
+
expect(text).toContain('outer')
|
|
48
|
+
expect(
|
|
49
|
+
text,
|
|
50
|
+
'depth-1 plain tags keep the collapsed default — root expansion must not cascade',
|
|
51
|
+
).not.toContain('inner-marker')
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/panel-view.tsx
CHANGED
|
@@ -22,7 +22,11 @@ interface WxmlTreeNodeProps {
|
|
|
22
22
|
* - 路径式 tag(含 `/`)= 页面或自定义组件,默认展开(让用户一眼看清组件层级)
|
|
23
23
|
* - 普通 DOM 节点(view/text 等)默认折叠,需要用户手动点开
|
|
24
24
|
*/
|
|
25
|
-
function isDefaultExpanded(node: WxmlNode): boolean {
|
|
25
|
+
function isDefaultExpanded(node: WxmlNode, depth: number): boolean {
|
|
26
|
+
// The root always opens expanded: collapsed, it hides the entire tree
|
|
27
|
+
// behind one ▸ row and the panel reads as blank — regardless of what tag
|
|
28
|
+
// shape the runtime gives the root (plain `page`, a component path, …).
|
|
29
|
+
if (depth === 0) return true
|
|
26
30
|
if (node.tagName === '#shadow-root') return true
|
|
27
31
|
if (node.tagName.includes('/')) return true
|
|
28
32
|
return false
|
|
@@ -77,7 +81,7 @@ function WxmlFragmentNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNode
|
|
|
77
81
|
// Shadow root — synthetic boundary for custom component internals.
|
|
78
82
|
// Clickable to collapse like WeChat DevTools; default expanded; no closing tag.
|
|
79
83
|
function WxmlShadowRootNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNodeProps) {
|
|
80
|
-
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node))
|
|
84
|
+
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node, depth))
|
|
81
85
|
const indent = depth * 16
|
|
82
86
|
const hasShadowChildren = (node.children ?? []).length > 0
|
|
83
87
|
return (
|
|
@@ -206,7 +210,7 @@ function WxmlBlockRow({
|
|
|
206
210
|
|
|
207
211
|
// Ordinary element node — decides between the inline-text and block layouts.
|
|
208
212
|
function WxmlElementNode({ node, depth, inspectedSid, onInspect }: WxmlTreeNodeProps) {
|
|
209
|
-
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node))
|
|
213
|
+
const [expanded, setExpanded] = useState(() => isDefaultExpanded(node, depth))
|
|
210
214
|
const indent = depth * 16
|
|
211
215
|
const isInspected = Boolean(node.sid && node.sid === inspectedSid)
|
|
212
216
|
const hasChildren = (node.children ?? []).length > 0
|