@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.
@@ -42,73 +42,27 @@ function resolveTemplateNameFromParent(instance) {
42
42
  function pascalToKebab(name) {
43
43
  return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
44
44
  }
45
- /**
46
- * Read the page's source path off Vue's provide/inject. dimina runtime.js 在
47
- * dd-page 的 setup() 里调用 `provide('path', path)`,Vue 把它存到
48
- * `instance.provides`。我们利用这个把页面节点的 tagName 从硬编码 `page`
49
- * 升级为页面全路径(如 `pages/index/index`),对齐微信开发者工具。
50
- */
51
- function resolvePagePath(instance) {
52
- // dimina runtime 在 dd-page 的 setup 里 `instance.proxy.__page__ = true` 并
53
- // `provide('path', path)`。两个标记同时具备才视为页面层级,避免 dd-page
54
- // 的子节点继承 provides.path 后被误判(Vue 用 Object.create 链式 provides)。
55
- const proxy = instance.proxy;
56
- if (proxy?.__page__ !== true)
57
- return null;
58
- const provides = instance.provides;
59
- const path = provides?.path;
60
- if (typeof path !== 'string' || !path)
61
- return null;
62
- return path.startsWith('/') ? path.slice(1) : path;
63
- }
64
- /**
65
- * A NATIVE dimina custom component (a `usingComponents` entry) calls
66
- * `provide('path', componentPath)` in its setup (render runtime), so its
67
- * provided `path` DIFFERS from its parent's. A plain descendant inherits the
68
- * same provides object (identical `path`) and is not a boundary; a Taro template
69
- * wrapper (`dd-tpl-*`) never provides, so it never differs either. Comparing
70
- * against the parent's provided path therefore isolates exactly the native
71
- * component roots — matching WeChat, which shows each custom component as a node
72
- * tagged with its full registered path (with a synthetic `#shadow-root` added by
73
- * `wrapInShadowRoot` since the path contains `/`). Pages are handled earlier via
74
- * their authoritative `__page__` marker, so this only fires for components.
75
- */
76
- function resolveComponentPath(instance) {
77
- const provides = instance.provides;
78
- const path = provides?.path;
79
- if (typeof path !== 'string' || !path)
80
- return null;
81
- const parent = instance.parent;
82
- const parentProvides = parent?.provides;
83
- if (path === parentProvides?.path)
84
- return null;
85
- return path.startsWith('/') ? path.slice(1) : path;
86
- }
87
- function resolveTagName(instance) {
45
+ export function resolveTagName(instance) {
88
46
  const type = instance.type;
89
47
  if (!type)
90
48
  return 'unknown';
91
- // 页面层级优先:dd-page 没有 __tagName/__name,且 home 页等无 usingComponents
92
- // 的页面 type.components undefined,会落到 resolveTemplateNameFromParent
93
- // 回到 'page'。在那之前直接用 provide('path') 的路径升级 tag 名。
94
- const pagePath = resolvePagePath(instance);
95
- if (pagePath)
96
- return pagePath;
97
- // Native custom-component boundary → tag with its full registered path (WeChat
98
- // parity). Must precede the __tagName/__name/dd- shortening below, which would
99
- // otherwise collapse `dd-foo` to the bare `foo` and lose the path.
100
- const componentPath = resolveComponentPath(instance);
101
- if (componentPath)
102
- return componentPath;
49
+ // Authoritative source-path identity: the render runtime sets each compiled
50
+ // page/custom-component's Vue `name` to its miniprogram module path. A name
51
+ // containing `/` IS that source path trust it directly (WeChat parity).
52
+ // It is per-type (not inherited via provides), so a descendant never leaks its
53
+ // ancestor's path, and it needs no live provide/inject chain to reconstruct.
54
+ const nameId = (type.name || type.__name);
55
+ if (nameId && nameId.includes('/')) {
56
+ return nameId.startsWith('/') ? nameId.slice(1) : nameId;
57
+ }
103
58
  if (typeof type.__tagName === 'string')
104
59
  return type.__tagName;
105
60
  const name = (type.__name || type.name);
106
61
  if (!name) {
107
- // A nameless component carrying a `__scopeId` is a compiled page, custom
108
- // component, or framework template wrapper. The page is already resolved
109
- // above via its authoritative `__page__` marker, so by here it is NOT a
110
- // page recover the registered tag (e.g. a Taro `taro_tmpl`/`tmpl_0_3`
111
- // wrapper) and fall back to `template`. Defaulting to `page` here would
62
+ // A nameless component carrying a `__scopeId` is a framework template
63
+ // wrapper (e.g. a Taro `taro_tmpl`/`tmpl_0_3`) pages and custom components
64
+ // carry a path `name` and are resolved above. Recover the registered tag and
65
+ // fall back to `template`; never default to `page` here, which would
112
66
  // mislabel every such wrapper as a second page root.
113
67
  if (type.__scopeId)
114
68
  return resolveTemplateNameFromParent(instance) || 'template';
@@ -184,6 +138,20 @@ function getElementSid(instance) {
184
138
  return sid;
185
139
  return registerSyntheticSid(el);
186
140
  }
141
+ /**
142
+ * The absolute component path a `dd-wrapper` carries in its `name` prop/attr, or
143
+ * null. dimina wraps EVERY custom-component template root in
144
+ * `<dd-wrapper name="/components/foo/foo">`; the path always starts with `/`,
145
+ * which a user-authored `name` attr essentially never does.
146
+ */
147
+ function wrapperPathName(instance) {
148
+ for (const raw of [instance.props, instance.attrs]) {
149
+ const n = raw?.name;
150
+ if (typeof n === 'string' && n.startsWith('/'))
151
+ return n;
152
+ }
153
+ return null;
154
+ }
187
155
  function isTransparentComponent(instance, tagName) {
188
156
  if (tagName === 'unknown')
189
157
  return true;
@@ -192,6 +160,16 @@ function isTransparentComponent(instance, tagName) {
192
160
  // not user content — pass its children straight through.
193
161
  if (FRAMEWORK_TEMPLATE_RE.test(tagName))
194
162
  return true;
163
+ // dimina wraps every custom-component template root in a
164
+ // `<dd-wrapper name="/path">`. Under the name-path model the ENCLOSING
165
+ // component instance already carries that same source path as its own node
166
+ // (its Vue `name` IS the path), so surfacing the wrapper too would emit a
167
+ // SECOND identical path node — the doubled `<components/foo/foo>` layer. The
168
+ // wrapper is redundant scaffolding: pass its children through. Gated on a
169
+ // path-shaped `name` so a user-authored `<wrapper name="x">` is never
170
+ // swallowed (dimina paths always start with `/`).
171
+ if (tagName === 'wrapper' && wrapperPathName(instance))
172
+ return true;
195
173
  if (tagName === 'template') {
196
174
  const props = instance.props;
197
175
  const is = props?.is;
@@ -303,48 +281,6 @@ function extractChildrenFromVNode(vnode, depth) {
303
281
  }
304
282
  return result;
305
283
  }
306
- /**
307
- * Reverse-map Dimina's `<wrapper name="/components/foo/foo">` (used to host
308
- * every user-defined component) back to the source-level tag the user wrote
309
- * in WXML (e.g. `<foo>`).
310
- *
311
- * The wrapper carries the registered component path in its `name` Vue prop.
312
- * By convention (also followed by miniprogram tooling), the registered key is
313
- * the last path segment, optionally stripping a trailing `/index`. We can't
314
- * see the page's `usingComponents` map from inside the Vue instance, so this
315
- * convention-based recovery is best-effort: if the user picked a different
316
- * registration key than the directory name (e.g. `usingComponents:
317
- * { myCounter: '/components/counter/counter' }`), the panel will show
318
- * `counter`, not `myCounter`.
319
- *
320
- * The path heuristic also resolves a name-collision risk: a user-written
321
- * `<counter name="x">` would land in the same `attrs.name` slot as the
322
- * wrapper-internal path. We only unwrap when the value looks like an absolute
323
- * component path (leading `/`), which dimina always emits but a user would
324
- * almost never type as a literal attr.
325
- */
326
- function unwrapCustomComponent(node) {
327
- if (node.tagName !== 'wrapper')
328
- return node;
329
- const path = node.attrs?.name;
330
- if (typeof path !== 'string' || !path.startsWith('/'))
331
- return node;
332
- // 去掉前导 `/` 后保留原路径形式(保留所有斜杠,不做 kebab/dash 转换)。
333
- // 仅当路径以 `/index` 结尾且剥离后仍至少剩一段时才剥(`/index` 单独存在则
334
- // 退回 wrapper,避免出现空 tagName)。
335
- const stripped = path.replace(/^\//, '');
336
- const withoutIndex = stripped.endsWith('/index') ? stripped.slice(0, -'/index'.length) : stripped;
337
- const recovered = withoutIndex || stripped;
338
- if (!recovered || recovered === 'index')
339
- return node;
340
- const nextAttrs = {};
341
- for (const [k, v] of Object.entries(node.attrs)) {
342
- if (k === 'name')
343
- continue;
344
- nextAttrs[k] = v;
345
- }
346
- return { ...node, tagName: recovered, attrs: nextAttrs };
347
- }
348
284
  /**
349
285
  * 把"用户授权"层级(页面 / 自定义组件,tagName 以路径形式呈现,含 `/`)
350
286
  * 的 children 包一层合成 `#shadow-root`,对齐微信开发者工具:组件本身
@@ -377,5 +313,5 @@ export function walkInstance(instance, depth) {
377
313
  const sid = getElementSid(instance);
378
314
  if (sid)
379
315
  node.sid = sid;
380
- return wrapInShadowRoot(unwrapCustomComponent(node));
316
+ return wrapInShadowRoot(node);
381
317
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dimina-kit/inspect",
3
- "version": "0.3.0-dev.20260711141929",
3
+ "version": "0.4.0-dev.20260716153350",
4
4
  "description": "Host-agnostic WXML tree extraction and inspection for dimina render layers: Vue runtime walk, stable-id registry, mutation-observing inspector, and a React tree panel.",
5
5
  "keywords": [
6
6
  "dimina",
@@ -62,6 +62,9 @@
62
62
  "publishConfig": {
63
63
  "access": "public"
64
64
  },
65
+ "dependencies": {
66
+ "@uiw/react-json-view": "2.0.0-alpha.41"
67
+ },
65
68
  "scripts": {
66
69
  "build": "tsc -p tsconfig.build.json",
67
70
  "check-types": "tsc --noEmit -p tsconfig.json",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tests for AppDataAccumulator.pageData(bridgeId).
3
+ *
4
+ * Contract:
5
+ * - Returns a single shallow-merged object combining `entry.data` of EVERY
6
+ * cache entry whose key starts with `${bridgeId}/`.
7
+ * - Returns {} for an unknown bridgeId and after clearBridge(bridgeId).
8
+ * - Only that bridge's entries are merged (other bridgeIds excluded).
9
+ *
10
+ * These tests pin page reactive-data reads (miniprogram-automator Page.getData)
11
+ * off the central accumulator.
12
+ */
13
+ import { describe, expect, it } from 'vitest'
14
+ import { AppDataAccumulator } from './appdata-accumulator.js'
15
+
16
+ /** Apply a page-instance init (full initial state) for a bridge. */
17
+ function applyInit(
18
+ acc: AppDataAccumulator,
19
+ bridgeId: string,
20
+ moduleId: string,
21
+ componentPath: string,
22
+ data: Record<string, unknown>,
23
+ ): void {
24
+ acc.apply({ mode: 'init', bridgeId, moduleId, componentPath, data })
25
+ }
26
+
27
+ /** Apply a setData patch (merge onto previous) for a bridge/module. */
28
+ function applyPatch(
29
+ acc: AppDataAccumulator,
30
+ bridgeId: string,
31
+ moduleId: string,
32
+ data: Record<string, unknown>,
33
+ ): void {
34
+ acc.apply({ mode: 'patch', bridgeId, moduleId, data })
35
+ }
36
+
37
+ describe('AppDataAccumulator.pageData', () => {
38
+ it('merges init + setData patch into the current reactive state for the bridge', () => {
39
+ const acc = new AppDataAccumulator()
40
+ applyInit(acc, 'b1', 'page_1', 'pages/index/index', { count: 0, title: 'hi' })
41
+ applyPatch(acc, 'b1', 'page_1', { count: 5 })
42
+
43
+ // count reflects the patch; title survives from init (setData merge semantics)
44
+ expect(acc.pageData('b1')).toEqual({ count: 5, title: 'hi' })
45
+ })
46
+
47
+ it('shallow-merges multiple page module entries belonging to the same bridge', () => {
48
+ const acc = new AppDataAccumulator()
49
+ applyInit(acc, 'b1', 'page_1', 'pages/index/index', { a: 1 })
50
+ // A second page module on the same bridge contributes its own keys.
51
+ applyInit(acc, 'b1', 'page_2', 'pages/index/index', { b: 2 })
52
+
53
+ expect(acc.pageData('b1')).toEqual({ a: 1, b: 2 })
54
+ })
55
+
56
+ it('isolates bridges — only the requested bridge\'s entries are merged', () => {
57
+ const acc = new AppDataAccumulator()
58
+ applyInit(acc, 'b1', 'page_1', 'pages/index/index', { name: 'amy' })
59
+ applyInit(acc, 'b2', 'page_1', 'pages/other/other', { name: 'bob' })
60
+ applyPatch(acc, 'b2', 'page_1', { extra: true })
61
+
62
+ expect(acc.pageData('b1')).toEqual({ name: 'amy' })
63
+ expect(acc.pageData('b2')).toEqual({ name: 'bob', extra: true })
64
+ // b1's result must not contain b2's keys.
65
+ expect(acc.pageData('b1')).not.toHaveProperty('extra')
66
+ })
67
+
68
+ it('returns {} for an unknown bridgeId (no entries)', () => {
69
+ const acc = new AppDataAccumulator()
70
+ applyInit(acc, 'b1', 'page_1', 'pages/index/index', { count: 0 })
71
+
72
+ expect(acc.pageData('does-not-exist')).toEqual({})
73
+ })
74
+
75
+ it('returns {} after clearBridge evicts the bridge', () => {
76
+ const acc = new AppDataAccumulator()
77
+ applyInit(acc, 'b1', 'page_1', 'pages/index/index', { count: 3 })
78
+ expect(acc.pageData('b1')).toEqual({ count: 3 })
79
+
80
+ acc.clearBridge('b1')
81
+ expect(acc.pageData('b1')).toEqual({})
82
+ })
83
+ })
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Framework-agnostic AppData accumulator.
3
+ *
4
+ * The dimina runtime emits two service→render messages relevant to the AppData
5
+ * panel, identical in shape on BOTH container paths:
6
+ * • update batches `{type:'ub', body:{bridgeId, updates:[{moduleId,data}]}}`
7
+ * — each `data` is a partial setData patch.
8
+ * • instance init: `type === 'page_*'`, body `{bridgeId, path, data}` with the
9
+ * COMPLETE initial state.
10
+ *
11
+ * Hosts differ only in where they tap that stream — an Electron preload
12
+ * sniffing Worker `message` events, a main-process service→render forward, or
13
+ * a same-origin web workbench observing the pageFrame's Worker. Every tap
14
+ * feeds this one accumulator so the decode/merge/page-only/init-gate policy
15
+ * can't drift between hosts.
16
+ */
17
+
18
+ /**
19
+ * The AppData snapshot — the full, cumulative panel state.
20
+ *
21
+ * - `bridges` lists every page bridge in chronological (insertion) order, each
22
+ * carrying its page route (`pagePath`), so renderer tabs render in a stable
23
+ * sequence.
24
+ * - `entries` maps `bridgeId → (componentPath | moduleId) → setData state`.
25
+ */
26
+ export interface AppDataSnapshot {
27
+ bridges: Array<{ id: string; pagePath: string | null }>
28
+ entries: Record<string, Record<string, unknown>>
29
+ }
30
+
31
+ export type DecodedEntry =
32
+ | { mode: 'patch'; bridgeId: string; moduleId: string; data: unknown }
33
+ | {
34
+ mode: 'init'
35
+ bridgeId: string
36
+ moduleId: string
37
+ componentPath: string
38
+ data: Record<string, unknown>
39
+ }
40
+
41
+ /** Normalized input to the accumulator (built from a DecodedEntry or the hook). */
42
+ export interface AppDataInput {
43
+ bridgeId?: string
44
+ moduleId?: string
45
+ componentPath?: string
46
+ data?: unknown
47
+ mode?: 'init' | 'patch'
48
+ }
49
+
50
+ interface CacheEntry {
51
+ componentPath?: string
52
+ data: Record<string, unknown>
53
+ }
54
+
55
+ /** Parse a service/worker message payload; a non-string is passed through as-is. */
56
+ function parseMessagePayload(message: unknown): unknown {
57
+ if (typeof message !== 'string') return message
58
+ try {
59
+ return JSON.parse(message)
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Decode a `ub` (update batch) body into patch entries, PAGE modules only —
67
+ * component-module updates are dropped (see `decodeWorkerMessage`).
68
+ */
69
+ function decodeUpdateBatchBody(rawBody: unknown): DecodedEntry[] | null {
70
+ const body = rawBody as { bridgeId?: unknown; updates?: unknown } | null
71
+ if (!body || typeof body !== 'object') return null
72
+ if (typeof body.bridgeId !== 'string' || !Array.isArray(body.updates)) return null
73
+ const out: DecodedEntry[] = []
74
+ for (const u of body.updates as Array<{ moduleId?: unknown; data?: unknown }>) {
75
+ if (!u || typeof u.moduleId !== 'string') continue
76
+ if (!u.moduleId.startsWith('page_')) continue
77
+ out.push({ mode: 'patch', bridgeId: body.bridgeId, moduleId: u.moduleId, data: u.data })
78
+ }
79
+ return out.length > 0 ? out : null
80
+ }
81
+
82
+ /** Decode a `page_*` instance-init body into its single init entry. */
83
+ function decodePageInitBody(moduleId: string, rawBody: unknown): DecodedEntry[] | null {
84
+ const body = rawBody as { bridgeId?: unknown; path?: unknown; data?: unknown } | null
85
+ if (!body || typeof body !== 'object') return null
86
+ if (typeof body.bridgeId !== 'string' || typeof body.path !== 'string') return null
87
+ if (!body.data || typeof body.data !== 'object') return null
88
+ return [{
89
+ mode: 'init',
90
+ bridgeId: body.bridgeId,
91
+ moduleId,
92
+ componentPath: body.path,
93
+ data: body.data as Record<string, unknown>,
94
+ }]
95
+ }
96
+
97
+ /**
98
+ * Decode a service→render message into AppData entries, or null when it is not
99
+ * AppData-relevant. Policy: surface PAGE entries only — component entries are
100
+ * dropped (they sometimes flow on a bridge id distinct from their owning page's
101
+ * and never receive pageUnload, which would manifest as ghost tabs).
102
+ */
103
+ export function decodeWorkerMessage(message: unknown): DecodedEntry[] | null {
104
+ const payload = parseMessagePayload(message)
105
+ if (!payload || typeof payload !== 'object') return null
106
+ const record = payload as { type?: unknown; body?: unknown }
107
+
108
+ if (record.type === 'ub') return decodeUpdateBatchBody(record.body)
109
+ if (typeof record.type === 'string' && record.type.startsWith('page_')) {
110
+ return decodePageInitBody(record.type, record.body)
111
+ }
112
+ return null
113
+ }
114
+
115
+ /** main→worker direction: container signals page teardown so cache can evict. */
116
+ export function decodeOutgoingMessage(message: unknown): { type: string; bridgeId?: string } | null {
117
+ const payload = parseMessagePayload(message)
118
+ if (!payload || typeof payload !== 'object') return null
119
+ const r = payload as { type?: unknown; body?: { bridgeId?: unknown } | null }
120
+ if (typeof r.type !== 'string') return null
121
+ return {
122
+ type: r.type,
123
+ bridgeId: typeof r.body?.bridgeId === 'string' ? r.body.bridgeId : undefined,
124
+ }
125
+ }
126
+
127
+ /** Convert a decoded entry to the normalized accumulator input. */
128
+ export function decodedToInput(entry: DecodedEntry): AppDataInput {
129
+ const input: AppDataInput = {
130
+ bridgeId: entry.bridgeId,
131
+ moduleId: entry.moduleId,
132
+ data: entry.data,
133
+ mode: entry.mode,
134
+ }
135
+ if (entry.mode === 'init') input.componentPath = entry.componentPath
136
+ return input
137
+ }
138
+
139
+ /**
140
+ * Cumulative per-(bridgeId, moduleId) setData state. Pure data structure — no
141
+ * Worker / IPC / DOM. Callers own transport (publish/emit) and the automation
142
+ * mirror.
143
+ */
144
+ export class AppDataAccumulator {
145
+ private readonly cache = new Map<string, CacheEntry>()
146
+ // Bridges in insertion order — drives the `bridges` array (stable tab order).
147
+ private readonly bridgeOrder: string[] = []
148
+ // Page path per bridge: set from `page_*` init's body.path (the page route).
149
+ private readonly bridgePagePath = new Map<string, string>()
150
+
151
+ private recordBridge(bridgeId: string): void {
152
+ if (!this.bridgeOrder.includes(bridgeId)) this.bridgeOrder.push(bridgeId)
153
+ }
154
+
155
+ /**
156
+ * Apply one entry. Returns true if it was accepted (a mutation worth
157
+ * republishing), false if dropped (missing ids, or the init-gate).
158
+ */
159
+ apply(input: AppDataInput): boolean {
160
+ if (!input.bridgeId || !input.moduleId) return false
161
+ // Drop ub patches whose bridge has never been initialised. dimina
162
+ // dispatches pageUnload → onUnload, whose setData produces a late `ub`
163
+ // arriving AFTER clearBridge; without this gate it would resurrect the
164
+ // unloaded bridge as a ghost tab.
165
+ if (input.mode !== 'init' && !this.bridgePagePath.has(input.bridgeId)) return false
166
+ const key = `${input.bridgeId}/${input.moduleId}`
167
+ const prev = this.cache.get(key)
168
+ const incoming = input.data && typeof input.data === 'object'
169
+ ? (input.data as Record<string, unknown>)
170
+ : {}
171
+ // init = full-state replace; patch = merge into previous (setData semantics)
172
+ const merged = input.mode === 'init'
173
+ ? { ...incoming }
174
+ : { ...(prev?.data ?? {}), ...incoming }
175
+ const componentPath = input.componentPath ?? prev?.componentPath
176
+ const next: CacheEntry = componentPath !== undefined
177
+ ? { componentPath, data: merged }
178
+ : { data: merged }
179
+ this.cache.set(key, next)
180
+ this.recordBridge(input.bridgeId)
181
+ if (input.mode === 'init' && input.moduleId.startsWith('page_') && input.componentPath) {
182
+ this.bridgePagePath.set(input.bridgeId, input.componentPath)
183
+ }
184
+ return true
185
+ }
186
+
187
+ /** Evict every entry for a bridge (page teardown). */
188
+ clearBridge(bridgeId: string): void {
189
+ const prefix = `${bridgeId}/`
190
+ for (const key of [...this.cache.keys()]) {
191
+ if (key.startsWith(prefix)) this.cache.delete(key)
192
+ }
193
+ const idx = this.bridgeOrder.indexOf(bridgeId)
194
+ if (idx >= 0) this.bridgeOrder.splice(idx, 1)
195
+ this.bridgePagePath.delete(bridgeId)
196
+ }
197
+
198
+ /** The full cumulative snapshot for the panel. */
199
+ snapshot(): AppDataSnapshot {
200
+ const bridges: Array<{ id: string; pagePath: string | null }> = []
201
+ for (const id of this.bridgeOrder) {
202
+ bridges.push({ id, pagePath: this.bridgePagePath.get(id) ?? null })
203
+ }
204
+ const entries: Record<string, Record<string, unknown>> = {}
205
+ for (const [key, entry] of this.cache) {
206
+ const slash = key.indexOf('/')
207
+ if (slash < 0) continue
208
+ const bridgeId = key.slice(0, slash)
209
+ const moduleId = key.slice(slash + 1)
210
+ if (!entries[bridgeId]) entries[bridgeId] = {}
211
+ const displayKey = entry.componentPath ?? moduleId
212
+ entries[bridgeId][displayKey] = entry.data
213
+ }
214
+ return { bridges, entries }
215
+ }
216
+
217
+ /**
218
+ * The current reactive page state for a bridge: shallow-merge of `entry.data`
219
+ * across every cache entry whose key starts with `${bridgeId}/`, in insertion
220
+ * order (later entries win on key conflicts). `{}` when no entries match
221
+ * (unknown bridge / after clearBridge). Pure, no side effects.
222
+ */
223
+ pageData(bridgeId: string): Record<string, unknown> {
224
+ const merged: Record<string, unknown> = {}
225
+ for (const [key, entry] of this.cache) {
226
+ const slash = key.indexOf('/')
227
+ if (slash < 0) continue
228
+ if (key.slice(0, slash) !== bridgeId) continue
229
+ Object.assign(merged, entry.data)
230
+ }
231
+ return merged
232
+ }
233
+
234
+ /** Flat `key → data` map for the `__simulatorData.getAppdata()` mirror. */
235
+ flat(): Record<string, unknown> {
236
+ const data: Record<string, unknown> = {}
237
+ for (const [key, entry] of this.cache) data[key] = entry.data
238
+ return data
239
+ }
240
+
241
+ clear(): void {
242
+ this.cache.clear()
243
+ this.bridgeOrder.length = 0
244
+ this.bridgePagePath.clear()
245
+ }
246
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Contract: AppDataPanel takes an `isRuntimeRunning?: boolean` prop (the
3
+ * integration layer computes it as `runtimeStatus?.phase === 'running'`).
4
+ * When the session is NOT running, the empty state must render distinct copy
5
+ * that says the session isn't running (must contain "未运行" or "启动") —
6
+ * NOT the existing "暂无页面数据" wording, which would otherwise print even
7
+ * while the simulator is stuck on a launch failure or a crash and
8
+ * misleadingly imply "the app is fine, it just has no page data yet".
9
+ *
10
+ * Once running, the existing "暂无页面数据(仅显示 Page 级 data)" copy must
11
+ * be unchanged (regression guard).
12
+ */
13
+ import { describe, it, expect } from 'vitest'
14
+ import { render } from '@testing-library/react'
15
+ import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
16
+
17
+ const EMPTY_STATE: AppDataState = { bridges: [], activeBridgeId: null, entries: {} }
18
+
19
+ function makeProps(isRuntimeRunning: boolean): Parameters<typeof AppDataPanel>[0] {
20
+ return {
21
+ state: EMPTY_STATE,
22
+ onSelectBridge: () => {},
23
+ isRuntimeRunning,
24
+ }
25
+ }
26
+
27
+ describe('AppDataPanel: empty-state copy reflects whether the session is actually running', () => {
28
+ it('shows a "not running" empty state (contains 未运行 or 启动) when the session never reached running', () => {
29
+ const { getByTestId } = render(<AppDataPanel {...makeProps(false)} />)
30
+ const text = getByTestId('appdata-panel').textContent ?? ''
31
+ expect(
32
+ text.includes('未运行') || text.includes('启动'),
33
+ `expected empty-state text to signal the session is not running; got: "${text}"`,
34
+ ).toBe(true)
35
+ })
36
+
37
+ it('keeps the existing "暂无页面数据" copy (unchanged) once running', () => {
38
+ const { getByTestId, getByText } = render(<AppDataPanel {...makeProps(true)} />)
39
+ expect(getByText('暂无页面数据(仅显示 Page 级 data)')).toBeTruthy()
40
+ const text = getByTestId('appdata-panel').textContent ?? ''
41
+ expect(
42
+ text.includes('未运行'),
43
+ 'a genuinely-empty RUNNING session must not be painted with the "not running" copy',
44
+ ).toBe(false)
45
+ })
46
+ })
@@ -0,0 +1,49 @@
1
+ /**
2
+ * AppDataPanel — no refresh button.
3
+ *
4
+ * The panel is now realtime-pushed (main pushes `SimulatorAppDataChannel.Event`
5
+ * on every service→render setData) and ready-seeded (`useNativeChannelSnapshot`'s
6
+ * `enabled: ready` effect), so the panel's own "↻ 刷新" button is redundant and
7
+ * must be removed. The panel no longer takes an `onRefresh` prop at all — seeding
8
+ * is driven at the tab level (`DockDebugTab`), not by the panel — so this test
9
+ * pins that the panel itself renders no user-facing refresh control.
10
+ */
11
+ import { describe, expect, it, vi } from 'vitest'
12
+ import { render } from '@testing-library/react'
13
+ import { AppDataPanel, type AppDataPanelState as AppDataState } from './appdata-panel-view.js'
14
+
15
+ function makeState(overrides: Partial<AppDataState> = {}): AppDataState {
16
+ return {
17
+ bridges: [{ id: 'b1', pagePath: 'pages/index/index' }],
18
+ activeBridgeId: 'b1',
19
+ entries: { b1: { 'pages/index/index': { count: 1 } } },
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ describe('AppDataPanel: no refresh button', () => {
25
+ it('does not render a button whose text contains "刷新"', () => {
26
+ const { container } = render(
27
+ <AppDataPanel state={makeState()} onSelectBridge={vi.fn()} />,
28
+ )
29
+
30
+ const refreshButtons = Array.from(container.querySelectorAll('button')).filter((b) =>
31
+ (b.textContent ?? '').includes('刷新'),
32
+ )
33
+ expect(refreshButtons).toHaveLength(0)
34
+ })
35
+
36
+ it('does not render a refresh button even with no bridges (empty state)', () => {
37
+ const { container } = render(
38
+ <AppDataPanel
39
+ state={makeState({ bridges: [], activeBridgeId: null, entries: {} })}
40
+ onSelectBridge={vi.fn()}
41
+ />,
42
+ )
43
+
44
+ const refreshButtons = Array.from(container.querySelectorAll('button')).filter((b) =>
45
+ (b.textContent ?? '').includes('刷新'),
46
+ )
47
+ expect(refreshButtons).toHaveLength(0)
48
+ })
49
+ })