@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.
@@ -0,0 +1,282 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { SYNTHETIC_SID_PREFIX } from './sid-registry.js'
3
+ import { createWxmlInspector, type WxmlInspector } from './inspector.js'
4
+ import type { WxmlNode } from './types.js'
5
+
6
+ /** Minimal fake Vue instance leaf: a mounted element with no children of its own. */
7
+ function makeLeafInstance(tagName: string, props: Record<string, unknown>, el: HTMLElement) {
8
+ return { type: { __tagName: tagName }, props, subTree: { el, children: [] } }
9
+ }
10
+
11
+ function wait(ms: number): Promise<void> {
12
+ return new Promise((resolve) => setTimeout(resolve, ms))
13
+ }
14
+
15
+ describe('createWxmlInspector', () => {
16
+ let inspector: WxmlInspector
17
+
18
+ beforeEach(() => {
19
+ inspector = createWxmlInspector({ document })
20
+ })
21
+
22
+ afterEach(() => {
23
+ inspector.dispose()
24
+ delete (document.body as unknown as Record<string, unknown>).__vue_app__
25
+ document.body.innerHTML = ''
26
+ })
27
+
28
+ describe('getWxml', () => {
29
+ it('returns null when body has no mounted Vue app', () => {
30
+ expect(inspector.getWxml()).toBeNull()
31
+ })
32
+
33
+ it('walks _instance into a WxmlNode tree with synthetic sids on nodes and their children', () => {
34
+ // Mounted-DOM fixture: the sid registry deliberately refuses to resolve
35
+ // disconnected elements, and a walked Vue subtree is always mounted.
36
+ const rootEl = document.createElement('div')
37
+ const childEl = document.createElement('span')
38
+ rootEl.appendChild(childEl)
39
+ document.body.appendChild(rootEl)
40
+ const child = makeLeafInstance('text', {}, childEl)
41
+ const root = {
42
+ type: { __tagName: 'view' },
43
+ props: { class: 'user-cls' },
44
+ subTree: { el: rootEl, children: [{ component: child }] },
45
+ }
46
+ ;(document.body as unknown as Record<string, unknown>).__vue_app__ = { _instance: root }
47
+
48
+ const tree = inspector.getWxml()
49
+
50
+ expect(tree?.tagName).toBe('view')
51
+ expect(tree?.attrs).toEqual({ class: 'user-cls' })
52
+ expect(tree?.sid).toMatch(new RegExp(`^${SYNTHETIC_SID_PREFIX}`))
53
+ expect(tree?.children).toHaveLength(1)
54
+ expect(tree?.children[0]).toMatchObject({ tagName: 'text', attrs: {}, children: [] })
55
+ expect(tree?.children[0]?.sid).toMatch(new RegExp(`^${SYNTHETIC_SID_PREFIX}`))
56
+ // The minted sids must round-trip back to the exact elements they came from.
57
+ expect(inspector.elementFor(tree!.sid!)).toBe(rootEl)
58
+ expect(inspector.elementFor(tree!.children[0]!.sid!)).toBe(childEl)
59
+ })
60
+
61
+ it('uses an element pre-existing data-sid attribute instead of minting a synthetic one', () => {
62
+ const el = document.createElement('div')
63
+ el.setAttribute('data-sid', 'real-sid-7')
64
+ const root = makeLeafInstance('view', {}, el)
65
+ ;(document.body as unknown as Record<string, unknown>).__vue_app__ = { _instance: root }
66
+
67
+ expect(inspector.getWxml()?.sid).toBe('real-sid-7')
68
+ })
69
+
70
+ it('falls back to _container._vnode.component when _instance is absent', () => {
71
+ const el = document.createElement('div')
72
+ const inst = makeLeafInstance('view', {}, el)
73
+ ;(document.body as unknown as Record<string, unknown>).__vue_app__ = {
74
+ _container: { _vnode: { component: inst } },
75
+ }
76
+
77
+ expect(inspector.getWxml()?.tagName).toBe('view')
78
+ })
79
+
80
+ it('wraps a multi-root walk result in a synthetic #fragment node', () => {
81
+ const elA = document.createElement('div')
82
+ const elB = document.createElement('div')
83
+ const childA = makeLeafInstance('view', {}, elA)
84
+ const childB = makeLeafInstance('text', {}, elB)
85
+ // A nameless top-level instance (no `type`) is transparent, so walking it
86
+ // surfaces its two component children as separate roots.
87
+ const root = { subTree: { children: [{ component: childA }, { component: childB }] } }
88
+ ;(document.body as unknown as Record<string, unknown>).__vue_app__ = { _instance: root }
89
+
90
+ const tree = inspector.getWxml()
91
+
92
+ expect(tree?.tagName).toBe('#fragment')
93
+ expect(tree?.children).toHaveLength(2)
94
+ expect(tree?.children.map((c: WxmlNode) => c.tagName)).toEqual(['view', 'text'])
95
+ })
96
+
97
+ it('returns null when the walked tree has no content', () => {
98
+ const root = { subTree: { children: [] } }
99
+ ;(document.body as unknown as Record<string, unknown>).__vue_app__ = { _instance: root }
100
+
101
+ expect(inspector.getWxml()).toBeNull()
102
+ })
103
+
104
+ it('defaults to globalThis.document when no document option is given', () => {
105
+ const defaultInspector = createWxmlInspector()
106
+ expect(() => defaultInspector.getWxml()).not.toThrow()
107
+ defaultInspector.dispose()
108
+ })
109
+ })
110
+
111
+ describe('highlightElement', () => {
112
+ it('measures rect and the requested style subset without mutating the element', () => {
113
+ const el = document.createElement('div')
114
+ el.setAttribute('data-sid', 'measured-1')
115
+ el.style.display = 'flex'
116
+ el.style.position = 'absolute'
117
+ el.style.boxSizing = 'border-box'
118
+ el.style.margin = '4px'
119
+ el.style.padding = '2px'
120
+ el.style.color = 'rgb(255, 0, 0)'
121
+ el.style.backgroundColor = 'rgb(0, 0, 255)'
122
+ el.style.fontSize = '16px'
123
+ document.body.appendChild(el)
124
+
125
+ const fakeRect = { x: 10, y: 20, width: 100, height: 50, top: 20, left: 10, right: 110, bottom: 70, toJSON() {} }
126
+ el.getBoundingClientRect = vi.fn(() => fakeRect as unknown as DOMRect)
127
+
128
+ const beforeHtml = document.body.innerHTML
129
+
130
+ const inspection = inspector.highlightElement('measured-1')
131
+
132
+ expect(inspection?.sid).toBe('measured-1')
133
+ expect(inspection?.rect).toEqual({ x: 10, y: 20, width: 100, height: 50 })
134
+ expect(inspection?.style).toEqual({
135
+ display: 'flex',
136
+ position: 'absolute',
137
+ boxSizing: 'border-box',
138
+ margin: '4px',
139
+ padding: '2px',
140
+ color: 'rgb(255, 0, 0)',
141
+ backgroundColor: 'rgb(0, 0, 255)',
142
+ fontSize: '16px',
143
+ })
144
+ // A measurement must be read-only: no attribute/overlay side effects.
145
+ expect(document.body.innerHTML).toBe(beforeHtml)
146
+ expect(document.body.children).toHaveLength(1)
147
+ })
148
+
149
+ it('returns null for an sid with no matching element', () => {
150
+ expect(inspector.highlightElement('does-not-exist')).toBeNull()
151
+ })
152
+ })
153
+
154
+ describe('elementFor', () => {
155
+ it('resolves a data-sid attribute to its live element', () => {
156
+ const el = document.createElement('div')
157
+ el.setAttribute('data-sid', 'find-me')
158
+ document.body.appendChild(el)
159
+
160
+ expect(inspector.elementFor('find-me')).toBe(el)
161
+ })
162
+
163
+ it('returns null for an sid with no registered or matching element', () => {
164
+ expect(inspector.elementFor('nope')).toBeNull()
165
+ })
166
+ })
167
+
168
+ describe('setObserving', () => {
169
+ it('merges a burst of DOM mutations into a single debounced callback', async () => {
170
+ const container = document.createElement('div')
171
+ document.body.appendChild(container)
172
+ const onMutated = vi.fn()
173
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 20 })
174
+
175
+ observing.setObserving(true)
176
+ container.setAttribute('data-x', '1')
177
+ container.setAttribute('data-x', '2')
178
+ container.appendChild(document.createElement('span'))
179
+
180
+ await wait(80)
181
+
182
+ expect(onMutated).toHaveBeenCalledTimes(1)
183
+ observing.dispose()
184
+ })
185
+
186
+ it('does not call back before observing has been turned on', async () => {
187
+ const container = document.createElement('div')
188
+ document.body.appendChild(container)
189
+ const onMutated = vi.fn()
190
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 20 })
191
+
192
+ container.setAttribute('data-x', '1')
193
+ await wait(80)
194
+
195
+ expect(onMutated).not.toHaveBeenCalled()
196
+ observing.dispose()
197
+ })
198
+
199
+ it('stops delivering callbacks once observing is turned off', async () => {
200
+ const container = document.createElement('div')
201
+ document.body.appendChild(container)
202
+ const onMutated = vi.fn()
203
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 20 })
204
+
205
+ observing.setObserving(true)
206
+ observing.setObserving(false)
207
+ container.setAttribute('data-x', '1')
208
+ await wait(80)
209
+
210
+ expect(onMutated).not.toHaveBeenCalled()
211
+ observing.dispose()
212
+ })
213
+
214
+ it('cancels a debounced callback already pending when observing turns off before it fires', async () => {
215
+ const container = document.createElement('div')
216
+ document.body.appendChild(container)
217
+ const onMutated = vi.fn()
218
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 30 })
219
+
220
+ observing.setObserving(true)
221
+ container.setAttribute('data-x', '1')
222
+ await wait(5) // mutation observed and debounce timer armed, well short of debounceMs
223
+ observing.setObserving(false)
224
+ await wait(80)
225
+
226
+ expect(onMutated).not.toHaveBeenCalled()
227
+ observing.dispose()
228
+ })
229
+
230
+ it('does not double the callback when turned on twice in a row', async () => {
231
+ const container = document.createElement('div')
232
+ document.body.appendChild(container)
233
+ const onMutated = vi.fn()
234
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 20 })
235
+
236
+ observing.setObserving(true)
237
+ observing.setObserving(true)
238
+ container.setAttribute('data-x', '1')
239
+ await wait(80)
240
+
241
+ expect(onMutated).toHaveBeenCalledTimes(1)
242
+ observing.dispose()
243
+ })
244
+
245
+ it('resumes observing after being turned off and back on', async () => {
246
+ const container = document.createElement('div')
247
+ document.body.appendChild(container)
248
+ const onMutated = vi.fn()
249
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 20 })
250
+
251
+ observing.setObserving(true)
252
+ observing.setObserving(false)
253
+ observing.setObserving(true)
254
+ container.setAttribute('data-x', '1')
255
+ await wait(80)
256
+
257
+ expect(onMutated).toHaveBeenCalledTimes(1)
258
+ observing.dispose()
259
+ })
260
+ })
261
+
262
+ describe('dispose', () => {
263
+ it('produces no further callbacks after dispose, even for mutations already pending', async () => {
264
+ const container = document.createElement('div')
265
+ document.body.appendChild(container)
266
+ const onMutated = vi.fn()
267
+ const observing = createWxmlInspector({ document, onMutated, debounceMs: 20 })
268
+
269
+ observing.setObserving(true)
270
+ container.setAttribute('data-x', '1')
271
+ observing.dispose()
272
+ await wait(80)
273
+
274
+ expect(onMutated).not.toHaveBeenCalled()
275
+ })
276
+
277
+ it('is safe to call before observing was ever turned on', () => {
278
+ const observing = createWxmlInspector({ document })
279
+ expect(() => observing.dispose()).not.toThrow()
280
+ })
281
+ })
282
+ })
@@ -0,0 +1,144 @@
1
+ // Host-agnostic WXML inspector over a render-layer document. It owns the
2
+ // three read paths every host needs — Vue-tree walk (getWxml), sid → element
3
+ // resolution (elementFor), element measurement (highlightElement) — plus the
4
+ // visibility-gated DOM observer that debounces a setData burst into a single
5
+ // onMutated callback. Hosts wire the callback to their own transport
6
+ // (Electron bridge message, iframe postMessage, …) and draw any visual
7
+ // highlight themselves: every method here is strictly read-only on the page.
8
+ import { findElementBySid } from './sid-registry.js'
9
+ import { walkInstance, type ComponentInstance } from './wxml-extract.js'
10
+ import type { ElementInspection, WxmlNode } from './types.js'
11
+
12
+ export interface WxmlInspectorOptions {
13
+ /** Document hosting the mounted Vue app. Defaults to `globalThis.document`. */
14
+ document?: Document
15
+ /** Called (debounced) on every DOM change while observing is on. */
16
+ onMutated?: () => void
17
+ /** Debounce window for mutation bursts. Defaults to 200ms. */
18
+ debounceMs?: number
19
+ }
20
+
21
+ export interface WxmlInspector {
22
+ /** Walk `document.body.__vue_app__` into a WxmlNode tree. Multiple roots are
23
+ * wrapped in a synthetic `#fragment`; no app / empty tree → null. */
24
+ getWxml(): WxmlNode | null
25
+ /** Measure the element with `sid`: bounding rect + the computed-style subset
26
+ * the panel footer shows. Read-only — drawing a highlight is the host's job. */
27
+ highlightElement(sid: string): ElementInspection | null
28
+ /** Resolve the sid to its live DOM element (synthetic registry or data-sid). */
29
+ elementFor(sid: string): HTMLElement | null
30
+ /** Start/stop watching the page DOM. Idempotent in both directions; turning
31
+ * off cancels a pending debounced callback. */
32
+ setObserving(on: boolean): void
33
+ /** Stop observing; the instance produces no further callbacks. */
34
+ dispose(): void
35
+ }
36
+
37
+ /** Coalesce a burst of setData-driven mutations into one notify per frame-ish. */
38
+ const DEFAULT_DEBOUNCE_MS = 200
39
+
40
+ export function createWxmlInspector(options: WxmlInspectorOptions = {}): WxmlInspector {
41
+ const doc = options.document ?? globalThis.document
42
+ const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
43
+ const onMutated = options.onMutated
44
+
45
+ const getVueApp = (): ComponentInstance | null => {
46
+ try {
47
+ const body = doc?.body as unknown as Record<string, unknown> | null
48
+ const app = body?.__vue_app__ as Record<string, unknown> | undefined
49
+ if (!app) return null
50
+ if (app._instance) return app._instance as ComponentInstance
51
+ const container = app._container as Record<string, unknown> | undefined
52
+ const vnode = container?._vnode as Record<string, unknown> | undefined
53
+ return (vnode?.component as ComponentInstance | null) ?? null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ const getWxml = (): WxmlNode | null => {
60
+ const instance = getVueApp()
61
+ if (!instance) return null
62
+ const tree = walkInstance(instance, 0)
63
+ if (!tree) return null
64
+ return Array.isArray(tree)
65
+ ? { tagName: '#fragment', attrs: {}, children: tree }
66
+ : tree
67
+ }
68
+
69
+ const elementFor = (sid: string): HTMLElement | null => {
70
+ if (!sid) return null
71
+ return findElementBySid(doc, sid)
72
+ }
73
+
74
+ const highlightElement = (sid: string): ElementInspection | null => {
75
+ const el = elementFor(sid)
76
+ if (!el) return null
77
+ const rect = el.getBoundingClientRect()
78
+ const style = el.ownerDocument.defaultView?.getComputedStyle(el)
79
+ if (!style) return null
80
+ return {
81
+ sid,
82
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
83
+ style: {
84
+ display: style.display,
85
+ position: style.position,
86
+ boxSizing: style.boxSizing,
87
+ margin: style.margin,
88
+ padding: style.padding,
89
+ color: style.color,
90
+ backgroundColor: style.backgroundColor,
91
+ fontSize: style.fontSize,
92
+ },
93
+ }
94
+ }
95
+
96
+ // getWxml/highlightElement are read-only, so the observer never re-triggers
97
+ // itself. Debounced so a setData burst coalesces into a single callback.
98
+ let observer: MutationObserver | null = null
99
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
100
+
101
+ const notifyMutated = (): void => {
102
+ debounceTimer = null
103
+ onMutated?.()
104
+ }
105
+
106
+ const setObserving = (on: boolean): void => {
107
+ if (on) {
108
+ if (observer) return
109
+ observer = new MutationObserver(() => {
110
+ if (debounceTimer !== null) clearTimeout(debounceTimer)
111
+ debounceTimer = setTimeout(notifyMutated, debounceMs)
112
+ })
113
+ // The host may enable observing before the page DOM is up. Observing a
114
+ // null body throws, so defer to DOMContentLoaded when needed — but bail
115
+ // if observing was turned off again before the body arrived.
116
+ const begin = (): void => {
117
+ if (!observer || !doc.body) return
118
+ observer.observe(doc.body, {
119
+ childList: true,
120
+ subtree: true,
121
+ attributes: true,
122
+ characterData: true,
123
+ })
124
+ }
125
+ if (doc.body) begin()
126
+ else doc.addEventListener('DOMContentLoaded', begin, { once: true })
127
+ } else {
128
+ if (debounceTimer !== null) {
129
+ clearTimeout(debounceTimer)
130
+ debounceTimer = null
131
+ }
132
+ observer?.disconnect()
133
+ observer = null
134
+ }
135
+ }
136
+
137
+ return {
138
+ getWxml,
139
+ highlightElement,
140
+ elementFor,
141
+ setObserving,
142
+ dispose: () => setObserving(false),
143
+ }
144
+ }
@@ -0,0 +1,20 @@
1
+ // The host-transport contract behind the WXML panel: the panel's data wiring
2
+ // (seed, live push, visibility gating, element inspection) is written ONCE
3
+ // against this interface (see ConnectedWxmlPanel); each host only implements
4
+ // how the five operations travel — Electron IPC channels, iframe postMessage,
5
+ // or anything else.
6
+ import type { ElementInspection, WxmlNode } from './types.js'
7
+
8
+ export interface WxmlPanelSource {
9
+ /** Fetch the current tree snapshot (seed / manual refresh). */
10
+ getSnapshot(): Promise<WxmlNode | null>
11
+ /** Live tree pushes; returns an unsubscribe function. */
12
+ subscribe(onTree: (tree: WxmlNode | null) => void): () => void
13
+ /** Visibility gate: the producer only observes the page DOM (and pays the
14
+ * Vue-tree walk) while some panel is visible. */
15
+ setActive(on: boolean): void
16
+ /** Measure the element with `sid`; the producer draws its own highlight. */
17
+ inspect(sid: string): Promise<ElementInspection | null>
18
+ /** Drop the current measurement highlight. */
19
+ clearInspection(): void | Promise<void>
20
+ }