@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
package/dist/wxml-extract.js
CHANGED
|
@@ -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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
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(
|
|
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
|
+
"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
|
+
})
|