@dimina-kit/inspect 0.3.0 → 0.4.0-dev.20260716153350
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -13
- package/dist/appdata-accumulator.js +218 -0
- package/dist/appdata-panel-view.js +64 -0
- package/dist/appdata-source.js +1 -0
- package/dist/compile-panel-view.js +92 -0
- package/dist/compile-source.js +1 -0
- package/dist/compile-types.js +4 -0
- package/dist/connected-appdata-panel.js +32 -0
- package/dist/connected-compile-panel.js +72 -0
- package/dist/index.js +1 -0
- package/dist/panel.js +5 -0
- package/dist/use-active-bridge-id.js +59 -0
- package/dist/wxml-extract.js +39 -103
- package/package.json +4 -1
- package/src/appdata-accumulator.test.ts +83 -0
- package/src/appdata-accumulator.ts +246 -0
- package/src/appdata-panel-view-empty-state.test.tsx +46 -0
- package/src/appdata-panel-view.test.tsx +49 -0
- package/src/appdata-panel-view.tsx +165 -0
- package/src/appdata-source.ts +18 -0
- package/src/compile-panel-view-logs.test.tsx +269 -0
- package/src/compile-panel-view.test.tsx +194 -0
- package/src/compile-panel-view.tsx +236 -0
- package/src/compile-source.ts +32 -0
- package/src/compile-types.ts +35 -0
- package/src/connected-appdata-panel.test.tsx +426 -0
- package/src/connected-appdata-panel.tsx +62 -0
- package/src/connected-compile-panel.test.tsx +500 -0
- package/src/connected-compile-panel.tsx +91 -0
- package/src/index.ts +16 -0
- package/src/panel.tsx +5 -0
- package/src/use-active-bridge-id.test.tsx +202 -0
- package/src/use-active-bridge-id.ts +71 -0
- package/src/wxml-extract.test.ts +70 -0
- package/src/wxml-extract.ts +38 -100
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The pure AppData panel view: per-page bridge tabs over cumulative setData
|
|
2
|
+
// state, each component path rendered as a collapsible JSON tree. Pure
|
|
3
|
+
// presentation — bridge selection and data feeds live in the connected
|
|
4
|
+
// container (or the host, when it renders this view directly).
|
|
5
|
+
import React from 'react'
|
|
6
|
+
import * as jsonViewModule from '@uiw/react-json-view'
|
|
7
|
+
import type { AppDataSnapshot } from './appdata-accumulator.js'
|
|
8
|
+
|
|
9
|
+
// The package ships CJS-flavoured type declarations (no `"type": "module"`),
|
|
10
|
+
// so what `default` types as depends on the consumer's moduleResolution:
|
|
11
|
+
// under NodeNext it is the whole module.exports object with the component on
|
|
12
|
+
// `.default`; under Bundler (and in vite/vitest at runtime) it IS the
|
|
13
|
+
// component (a forwardRef exotic object, so a typeof-function probe can't
|
|
14
|
+
// tell the shapes apart; the interop namespace is the only shape carrying a
|
|
15
|
+
// `default` key). The conditional type and the `in` probe resolve the
|
|
16
|
+
// component under BOTH semantics — this package typechecks under NodeNext
|
|
17
|
+
// and is also typechecked from source by Bundler-resolution consumers.
|
|
18
|
+
type ModuleDefault = (typeof jsonViewModule)['default']
|
|
19
|
+
type JsonViewComponent = ModuleDefault extends { default: infer C } ? C : ModuleDefault
|
|
20
|
+
const moduleDefault: JsonViewComponent | { default: JsonViewComponent } = jsonViewModule.default
|
|
21
|
+
// A type predicate (not a bare `in` check): plain `in` narrowing intersects
|
|
22
|
+
// `Record<'default', unknown>` onto the component branch, degrading the
|
|
23
|
+
// conditional's type to unknown.
|
|
24
|
+
function isInteropNamespace(
|
|
25
|
+
m: JsonViewComponent | { default: JsonViewComponent },
|
|
26
|
+
): m is { default: JsonViewComponent } {
|
|
27
|
+
return 'default' in m
|
|
28
|
+
}
|
|
29
|
+
const JsonView: JsonViewComponent
|
|
30
|
+
= isInteropNamespace(moduleDefault) ? moduleDefault.default : moduleDefault
|
|
31
|
+
|
|
32
|
+
/** The view's full input state: a snapshot plus which bridge tab is active. */
|
|
33
|
+
export interface AppDataPanelState {
|
|
34
|
+
bridges: AppDataSnapshot['bridges']
|
|
35
|
+
activeBridgeId: string | null
|
|
36
|
+
entries: AppDataSnapshot['entries']
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function bridgeLabel(bridge: { id: string; pagePath: string | null }): string {
|
|
40
|
+
return bridge.pagePath ?? bridge.id
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Map the json-view CSS vars onto the panel's existing design tokens so the
|
|
44
|
+
// component blends with both dark and light themes. CSS variables defined
|
|
45
|
+
// here cascade to the json-view's internal vars (--w-rjv-*) via inline style.
|
|
46
|
+
const JSON_VIEW_STYLE: React.CSSProperties = {
|
|
47
|
+
'--w-rjv-font-family': 'var(--font-family-mono)',
|
|
48
|
+
'--w-rjv-background-color': 'transparent',
|
|
49
|
+
'--w-rjv-color': 'var(--color-code-blue)',
|
|
50
|
+
'--w-rjv-key-string': 'var(--color-code-blue)',
|
|
51
|
+
'--w-rjv-line-color': 'var(--color-border-subtle)',
|
|
52
|
+
'--w-rjv-arrow-color': 'var(--color-text-secondary)',
|
|
53
|
+
'--w-rjv-info-color': 'var(--color-code-label)',
|
|
54
|
+
'--w-rjv-curlybraces-color': 'var(--color-text-secondary)',
|
|
55
|
+
'--w-rjv-brackets-color': 'var(--color-text-secondary)',
|
|
56
|
+
'--w-rjv-quotes-color': 'var(--color-code-orange)',
|
|
57
|
+
'--w-rjv-quotes-string-color': 'var(--color-code-orange)',
|
|
58
|
+
'--w-rjv-type-string-color': 'var(--color-code-orange)',
|
|
59
|
+
'--w-rjv-type-int-color': 'var(--color-code-number)',
|
|
60
|
+
'--w-rjv-type-float-color': 'var(--color-code-number)',
|
|
61
|
+
'--w-rjv-type-bigint-color': 'var(--color-code-number)',
|
|
62
|
+
'--w-rjv-type-boolean-color': 'var(--color-code-keyword)',
|
|
63
|
+
'--w-rjv-type-null-color': 'var(--color-code-keyword)',
|
|
64
|
+
'--w-rjv-type-nan-color': 'var(--color-code-keyword)',
|
|
65
|
+
'--w-rjv-type-undefined-color': 'var(--color-code-keyword)',
|
|
66
|
+
'--w-rjv-type-date-color': 'var(--color-code-label)',
|
|
67
|
+
'--w-rjv-type-url-color': 'var(--color-code-blue)',
|
|
68
|
+
fontSize: 12,
|
|
69
|
+
padding: '6px 8px',
|
|
70
|
+
wordBreak: 'break-all',
|
|
71
|
+
whiteSpace: 'pre-wrap',
|
|
72
|
+
} as React.CSSProperties
|
|
73
|
+
|
|
74
|
+
export interface AppDataPanelProps {
|
|
75
|
+
state: AppDataPanelState
|
|
76
|
+
onSelectBridge: (id: string) => void
|
|
77
|
+
/** Whether the mini-program's runtime session is `running` — distinguishes "小程序未运行" from a true empty-data vacuum below. Defaults to true so callers that don't track runtime status keep the plain empty-data text. */
|
|
78
|
+
isRuntimeRunning?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function AppDataPanel({
|
|
82
|
+
state,
|
|
83
|
+
onSelectBridge,
|
|
84
|
+
isRuntimeRunning = true,
|
|
85
|
+
}: AppDataPanelProps) {
|
|
86
|
+
const { bridges, activeBridgeId, entries } = state
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col overflow-hidden flex-1" data-testid="appdata-panel">
|
|
89
|
+
{bridges.length > 1 && (
|
|
90
|
+
<div className="flex gap-1 px-2 py-1 border-b border-border-subtle shrink-0 overflow-x-auto bg-bg-panel">
|
|
91
|
+
{bridges.map((b) => {
|
|
92
|
+
const isActive = b.id === activeBridgeId
|
|
93
|
+
return (
|
|
94
|
+
<button
|
|
95
|
+
key={b.id}
|
|
96
|
+
onClick={() => onSelectBridge(b.id)}
|
|
97
|
+
title={b.id}
|
|
98
|
+
className={
|
|
99
|
+
'shrink-0 px-2 py-0.5 text-[11px] rounded border transition-colors '
|
|
100
|
+
+ (isActive
|
|
101
|
+
? 'border-accent text-accent bg-surface-3'
|
|
102
|
+
: 'border-border-subtle text-text-dim hover:border-accent hover:text-accent')
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
{bridgeLabel(b)}
|
|
106
|
+
</button>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
{bridges.length === 0 ? (
|
|
112
|
+
<div className="text-[12px] text-text-dim text-center px-4 py-6">
|
|
113
|
+
{isRuntimeRunning ? '暂无页面数据(仅显示 Page 级 data)' : '小程序未运行'}
|
|
114
|
+
</div>
|
|
115
|
+
) : (
|
|
116
|
+
// A bridge with zero entries still gets its keepalive container (with
|
|
117
|
+
// the per-bridge empty text) so live pushes land in a mounted tab.
|
|
118
|
+
// Keepalive: render every bridge's entries; hide non-active ones via
|
|
119
|
+
// `display: none` so the JsonView instances stay mounted and preserve
|
|
120
|
+
// their expand/collapse state across tab switches.
|
|
121
|
+
<div className="flex-1 overflow-hidden relative">
|
|
122
|
+
{bridges.map((b) => {
|
|
123
|
+
const isActive = b.id === activeBridgeId
|
|
124
|
+
const bridgeEntries = entries[b.id] ?? {}
|
|
125
|
+
const keys = Object.keys(bridgeEntries)
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
key={b.id}
|
|
129
|
+
data-bridge-id={b.id}
|
|
130
|
+
className="absolute inset-0 flex flex-col gap-2 p-2 overflow-y-auto"
|
|
131
|
+
style={{ display: isActive ? 'flex' : 'none' }}
|
|
132
|
+
>
|
|
133
|
+
{keys.length === 0 ? (
|
|
134
|
+
<div className="text-[12px] text-text-dim text-center px-4 py-6">
|
|
135
|
+
暂无页面数据(仅显示 Page 级 data)
|
|
136
|
+
</div>
|
|
137
|
+
) : (
|
|
138
|
+
keys.map((comp) => (
|
|
139
|
+
<div
|
|
140
|
+
key={`${b.id}::${comp}`}
|
|
141
|
+
className="border border-border-subtle rounded overflow-hidden shrink-0"
|
|
142
|
+
>
|
|
143
|
+
<div className="bg-surface-3 px-2 py-0.5 text-[11px] text-code-label truncate">
|
|
144
|
+
{comp}
|
|
145
|
+
</div>
|
|
146
|
+
<JsonView
|
|
147
|
+
value={(bridgeEntries[comp] ?? {}) as object}
|
|
148
|
+
collapsed={1}
|
|
149
|
+
displayDataTypes={false}
|
|
150
|
+
displayObjectSize={false}
|
|
151
|
+
enableClipboard={false}
|
|
152
|
+
indentWidth={12}
|
|
153
|
+
style={JSON_VIEW_STYLE}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
))
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The host-transport contract behind the AppData panel: the panel's data
|
|
2
|
+
// wiring (seed, live full-snapshot pushes, visibility gating) is written ONCE
|
|
3
|
+
// against this interface (see ConnectedAppDataPanel); each host only
|
|
4
|
+
// implements how the operations travel — Electron IPC channels, a same-origin
|
|
5
|
+
// Worker-message tap feeding an AppDataAccumulator, or anything else.
|
|
6
|
+
import type { AppDataSnapshot } from './appdata-accumulator.js'
|
|
7
|
+
|
|
8
|
+
export interface AppDataPanelSource {
|
|
9
|
+
/** Fetch the current cumulative snapshot (seed on panel activation). */
|
|
10
|
+
getSnapshot(): Promise<AppDataSnapshot>
|
|
11
|
+
/** Live pushes — each push carries the FULL cumulative snapshot, not a
|
|
12
|
+
* delta (merging setData patches is the producer-side accumulator's job);
|
|
13
|
+
* returns an unsubscribe function. */
|
|
14
|
+
subscribe(onSnapshot: (snapshot: AppDataSnapshot) => void): () => void
|
|
15
|
+
/** Visibility gate: hosts whose feed costs something (listeners, walks)
|
|
16
|
+
* only keep it armed while some panel is visible. */
|
|
17
|
+
setActive(on: boolean): void
|
|
18
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CompilePanel `logs` prop contract (dmcc 日志链路). Additive to the base
|
|
3
|
+
* compile-panel suite — its `{ events, onClear }` assertions are untouched.
|
|
4
|
+
*
|
|
5
|
+
* Pinned contract:
|
|
6
|
+
* - `CompilePanel` carries an OPTIONAL `logs?: CompileLogEntry[]` prop
|
|
7
|
+
* (`{ at: number; stream: 'stdout' | 'stderr'; text: string }`,
|
|
8
|
+
* oldest-first like `events`). Optional + default empty, so a render
|
|
9
|
+
* without `logs` keeps its exact behaviour (incl. the 暂无编译 empty state).
|
|
10
|
+
* - Log lines render as part of ONE timeline merged with events by `at`
|
|
11
|
+
* (oldest first, matching the event row order). Merging is a VIEW
|
|
12
|
+
* concern — feed state stays with the owner.
|
|
13
|
+
* - Log rows are identifiable: `[data-compile-log]` carrying
|
|
14
|
+
* `data-stream="stdout" | "stderr"` (the styling hook that makes stderr
|
|
15
|
+
* lines distinguishable), the line text, and an HH:MM:SS timestamp like
|
|
16
|
+
* event rows.
|
|
17
|
+
* - The `[data-compile-current]` badge stays EVENT-driven: a newer log line
|
|
18
|
+
* must not hijack the latest-status badge.
|
|
19
|
+
* - Logs alone (no events) suppress the 暂无编译 empty state.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
22
|
+
import type { ComponentType } from 'react'
|
|
23
|
+
import { render } from '@testing-library/react'
|
|
24
|
+
|
|
25
|
+
interface CompileEvent {
|
|
26
|
+
at: number
|
|
27
|
+
status: string
|
|
28
|
+
message: string
|
|
29
|
+
hotReload?: boolean
|
|
30
|
+
/** Shared monotonic arrival counter across events AND logs. */
|
|
31
|
+
seq?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface CompileLogEntry {
|
|
35
|
+
at: number
|
|
36
|
+
stream: 'stdout' | 'stderr'
|
|
37
|
+
text: string
|
|
38
|
+
/** Shared monotonic arrival counter across events AND logs. */
|
|
39
|
+
seq?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CompilePanelProps {
|
|
43
|
+
events: CompileEvent[]
|
|
44
|
+
logs?: CompileLogEntry[]
|
|
45
|
+
onClear: () => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function loadCompilePanel(): Promise<ComponentType<CompilePanelProps>> {
|
|
49
|
+
const mod = (await import('./compile-panel-view.js')) as {
|
|
50
|
+
CompilePanel?: ComponentType<CompilePanelProps>
|
|
51
|
+
}
|
|
52
|
+
expect(
|
|
53
|
+
mod.CompilePanel,
|
|
54
|
+
'compile-panel-view.tsx must have a named export `CompilePanel`',
|
|
55
|
+
).toBeTruthy()
|
|
56
|
+
return mod.CompilePanel!
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Local-time timestamp helper — HH:MM:SS assertions stay timezone-proof. */
|
|
60
|
+
function at(h: number, m: number, s: number): number {
|
|
61
|
+
return new Date(2026, 5, 12, h, m, s).getTime()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function renderPanel(
|
|
65
|
+
events: CompileEvent[],
|
|
66
|
+
logs: CompileLogEntry[],
|
|
67
|
+
onClear = vi.fn(),
|
|
68
|
+
) {
|
|
69
|
+
const CompilePanel = await loadCompilePanel()
|
|
70
|
+
const utils = render(<CompilePanel events={events} logs={logs} onClear={onClear} />)
|
|
71
|
+
return { ...utils, onClear }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function logRowsOf(container: HTMLElement): HTMLElement[] {
|
|
75
|
+
return Array.from(container.querySelectorAll<HTMLElement>('[data-compile-log]'))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function timelineTextsOf(container: HTMLElement): string[] {
|
|
79
|
+
// DOM order across BOTH row kinds = the rendered timeline order.
|
|
80
|
+
return Array.from(
|
|
81
|
+
container.querySelectorAll<HTMLElement>('[data-compile-row], [data-compile-log]'),
|
|
82
|
+
).map((el) => el.textContent ?? '')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
describe('CompilePanel: logs prop(dmcc 日志链路)', () => {
|
|
86
|
+
it('renders log lines as [data-compile-log] rows with the line text', async () => {
|
|
87
|
+
const { container } = await renderPanel(
|
|
88
|
+
[],
|
|
89
|
+
[
|
|
90
|
+
{ at: at(9, 0, 0), stream: 'stdout', text: '✔ 收集配置信息' },
|
|
91
|
+
{ at: at(9, 0, 1), stream: 'stdout', text: '✔ 输出编译产物' },
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const rows = logRowsOf(container)
|
|
96
|
+
expect(
|
|
97
|
+
rows,
|
|
98
|
+
'each log entry must render a [data-compile-log] row — the assertable marker that distinguishes log lines from event rows',
|
|
99
|
+
).toHaveLength(2)
|
|
100
|
+
const texts = rows.map((r) => r.textContent ?? '')
|
|
101
|
+
expect(texts.some((t) => t.includes('✔ 收集配置信息'))).toBe(true)
|
|
102
|
+
expect(texts.some((t) => t.includes('✔ 输出编译产物'))).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('tags every log row with data-stream so stderr lines are distinguishable', async () => {
|
|
106
|
+
const { container } = await renderPanel(
|
|
107
|
+
[],
|
|
108
|
+
[
|
|
109
|
+
{ at: at(9, 1, 0), stream: 'stdout', text: '✔ 编译页面逻辑' },
|
|
110
|
+
{
|
|
111
|
+
at: at(9, 1, 5),
|
|
112
|
+
stream: 'stderr',
|
|
113
|
+
text: '[logic] esbuild 转换失败 /tmp/p/pages/index/index.js: Transform failed with 1 error:',
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const rows = logRowsOf(container)
|
|
119
|
+
expect(rows).toHaveLength(2)
|
|
120
|
+
const byText = (needle: string) =>
|
|
121
|
+
rows.find((r) => (r.textContent ?? '').includes(needle))
|
|
122
|
+
expect(
|
|
123
|
+
byText('✔ 编译页面逻辑')!.getAttribute('data-stream'),
|
|
124
|
+
'stdout lines must carry data-stream="stdout"',
|
|
125
|
+
).toBe('stdout')
|
|
126
|
+
expect(
|
|
127
|
+
byText('esbuild 转换失败')!.getAttribute('data-stream'),
|
|
128
|
+
'stderr lines must carry data-stream="stderr" — the styling hook for error-ish lines',
|
|
129
|
+
).toBe('stderr')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('merges events and logs into ONE timeline ordered by `at`, oldest first', async () => {
|
|
133
|
+
const { container } = await renderPanel(
|
|
134
|
+
[
|
|
135
|
+
{ at: at(10, 0, 0), status: 'compiling', message: '事件一' },
|
|
136
|
+
{ at: at(10, 0, 4), status: 'ready', message: '事件二' },
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
{ at: at(10, 0, 2), stream: 'stdout', text: '日志一' },
|
|
140
|
+
{ at: at(10, 0, 6), stream: 'stdout', text: '日志二' },
|
|
141
|
+
],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const texts = timelineTextsOf(container)
|
|
145
|
+
expect(
|
|
146
|
+
texts,
|
|
147
|
+
'events ([data-compile-row]) and logs ([data-compile-log]) must interleave in one timeline',
|
|
148
|
+
).toHaveLength(4)
|
|
149
|
+
const indexOf = (needle: string) => texts.findIndex((t) => t.includes(needle))
|
|
150
|
+
expect(indexOf('事件一')).toBe(0)
|
|
151
|
+
expect(indexOf('日志一')).toBe(1)
|
|
152
|
+
expect(indexOf('事件二')).toBe(2)
|
|
153
|
+
expect(indexOf('日志二')).toBe(3)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('shows an HH:MM:SS timestamp on log rows (same format as event rows)', async () => {
|
|
157
|
+
const { container } = await renderPanel(
|
|
158
|
+
[],
|
|
159
|
+
[{ at: at(11, 5, 9), stream: 'stdout', text: '✔ 输出编译产物' }],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const row = logRowsOf(container)[0]!
|
|
163
|
+
expect(row.textContent).toMatch(/11:05:09/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('keeps the [data-compile-current] badge EVENT-driven even when a log line is newer', async () => {
|
|
167
|
+
const { container } = await renderPanel(
|
|
168
|
+
[{ at: at(12, 0, 0), status: 'ready', message: '编译完成(最新事件)' }],
|
|
169
|
+
[{ at: at(12, 0, 30), stream: 'stderr', text: '[compat] Unsupported wx API: wx.switchTab (/pages/tabbar-me/tabbar-me.js:7)' }],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const badge = container.querySelector<HTMLElement>('[data-compile-current]')
|
|
173
|
+
expect(badge, 'the current-status badge must still render').not.toBeNull()
|
|
174
|
+
expect(
|
|
175
|
+
badge!.getAttribute('data-status'),
|
|
176
|
+
'the badge reflects the latest EVENT — log chatter must not hijack the compile status',
|
|
177
|
+
).toBe('ready')
|
|
178
|
+
expect(badge!.textContent).toContain('编译完成(最新事件)')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('suppresses the 暂无编译 empty state when there are logs but no events', async () => {
|
|
182
|
+
const { container } = await renderPanel(
|
|
183
|
+
[],
|
|
184
|
+
[{ at: at(13, 0, 0), stream: 'stdout', text: '✔ 收集配置信息' }],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
expect(
|
|
188
|
+
container.textContent,
|
|
189
|
+
'a non-empty log means the panel has content — the empty-state copy must not show',
|
|
190
|
+
).not.toMatch(/暂无编译/)
|
|
191
|
+
expect(logRowsOf(container)).toHaveLength(1)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('still shows the empty state when both events and logs are empty', async () => {
|
|
195
|
+
const { container } = await renderPanel([], [])
|
|
196
|
+
expect(container.textContent).toMatch(/暂无编译/)
|
|
197
|
+
expect(logRowsOf(container)).toHaveLength(0)
|
|
198
|
+
expect(container.querySelectorAll('[data-compile-row]')).toHaveLength(0)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `at` is a Date.now() millisecond stamp, so a
|
|
204
|
+
* status event and the log lines of the same compile routinely COLLIDE on the
|
|
205
|
+
* same `at`. The current merge pushes ALL events first, then ALL logs, then
|
|
206
|
+
* stable-sorts by `at` — so within a same-`at` tie, events always rank above
|
|
207
|
+
* logs no matter which actually arrived first. The pin: ties keep ARRIVAL
|
|
208
|
+
* order (the suggested carrier is a shared monotonic `seq` across both
|
|
209
|
+
* stores; any equivalent arrival marker works — only the rendered order is
|
|
210
|
+
* pinned). Across different `at` values the oldest-first order is untouched.
|
|
211
|
+
*/
|
|
212
|
+
describe('CompilePanel: same-at ties keep arrival order, not type priority', () => {
|
|
213
|
+
const T = at(14, 30, 0)
|
|
214
|
+
|
|
215
|
+
it('a log line that ARRIVED before a same-at event renders above it', async () => {
|
|
216
|
+
const { container } = await renderPanel(
|
|
217
|
+
// Arrival: log first (seq 0), event second (seq 1) — same `at`.
|
|
218
|
+
[{ at: T, seq: 1, status: 'ready', message: '编译完成' }],
|
|
219
|
+
[{ at: T, seq: 0, stream: 'stdout', text: '✔ 输出编译产物' }],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const texts = timelineTextsOf(container)
|
|
223
|
+
expect(texts).toHaveLength(2)
|
|
224
|
+
const logIndex = texts.findIndex((t) => t.includes('✔ 输出编译产物'))
|
|
225
|
+
const eventIndex = texts.findIndex((t) => t.includes('编译完成'))
|
|
226
|
+
expect(logIndex).toBeGreaterThanOrEqual(0)
|
|
227
|
+
expect(eventIndex).toBeGreaterThanOrEqual(0)
|
|
228
|
+
expect(
|
|
229
|
+
logIndex,
|
|
230
|
+
'same-at tie: the timeline must keep ARRIVAL order — the type-grouped concat + stable sort pins events '
|
|
231
|
+
+ 'above logs regardless of which actually came first, so the ready event renders above the very log '
|
|
232
|
+
+ 'lines that preceded it',
|
|
233
|
+
).toBeLessThan(eventIndex)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('an event that ARRIVED before a same-at log line renders above it (arrival order, not log priority)', async () => {
|
|
237
|
+
const { container } = await renderPanel(
|
|
238
|
+
// Arrival: event first (seq 0), log second (seq 1) — same `at`.
|
|
239
|
+
[{ at: T, seq: 0, status: 'compiling', message: '正在编译...' }],
|
|
240
|
+
[{ at: T, seq: 1, stream: 'stdout', text: '✔ 收集配置信息' }],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
const texts = timelineTextsOf(container)
|
|
244
|
+
expect(texts).toHaveLength(2)
|
|
245
|
+
const eventIndex = texts.findIndex((t) => t.includes('正在编译'))
|
|
246
|
+
const logIndex = texts.findIndex((t) => t.includes('✔ 收集配置信息'))
|
|
247
|
+
expect(
|
|
248
|
+
eventIndex,
|
|
249
|
+
'the fix must be ARRIVAL order, not a blanket "logs before events" inversion — an event that arrived '
|
|
250
|
+
+ 'first stays above its same-at followers',
|
|
251
|
+
).toBeLessThan(logIndex)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('different `at` values keep the existing oldest-first order regardless of seq', async () => {
|
|
255
|
+
const { container } = await renderPanel(
|
|
256
|
+
[{ at: at(14, 30, 5), seq: 0, status: 'ready', message: '编译完成' }],
|
|
257
|
+
[{ at: at(14, 30, 1), seq: 1, stream: 'stdout', text: '✔ 收集配置信息' }],
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const texts = timelineTextsOf(container)
|
|
261
|
+
const eventIndex = texts.findIndex((t) => t.includes('编译完成'))
|
|
262
|
+
const logIndex = texts.findIndex((t) => t.includes('✔ 收集配置信息'))
|
|
263
|
+
expect(
|
|
264
|
+
logIndex,
|
|
265
|
+
'the arrival tie-break must only apply WITHIN a same-at tie — across different `at` values the timeline '
|
|
266
|
+
+ 'stays oldest-first (the earlier log at 14:30:01 renders above the later event at 14:30:05)',
|
|
267
|
+
).toBeLessThan(eventIndex)
|
|
268
|
+
})
|
|
269
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编译信息 tab — CompilePanel component contract.
|
|
3
|
+
*
|
|
4
|
+
* Target file: `compile-panel-view.tsx`, named export `CompilePanel`,
|
|
5
|
+
* props `{ events: CompileEvent[]; onClear: () => void }` where `events` is
|
|
6
|
+
* the host's chronological (oldest-first) compile-event log.
|
|
7
|
+
*
|
|
8
|
+
* Pinned contract:
|
|
9
|
+
* - Empty state: no events → copy matching /暂无编译/, no rows.
|
|
10
|
+
* - Current-status badge: `[data-compile-current]` reflects the LATEST
|
|
11
|
+
* event — `data-status` attribute = its status, text contains its message.
|
|
12
|
+
* - History list: rows are `[data-compile-row]`, rendered OLDEST FIRST
|
|
13
|
+
* (chronological, matching the input array — the panel is a console-style
|
|
14
|
+
* log that auto-scrolls to the newest row at the bottom), each carrying
|
|
15
|
+
* `data-status` (the styling hook that makes error rows distinguishable)
|
|
16
|
+
* and an HH:MM:SS timestamp.
|
|
17
|
+
* - hotReload events render a recognizable 热更新 chip; plain events don't.
|
|
18
|
+
* - A ready event immediately preceded by a compiling event shows the
|
|
19
|
+
* elapsed time in `[data-compile-duration]` (e.g. 2.2s); unpaired ready
|
|
20
|
+
* events (first event, or previous event isn't compiling) show none.
|
|
21
|
+
* - The 清空 button calls `onClear`.
|
|
22
|
+
*
|
|
23
|
+
* The module is loaded via a dynamic import so a missing named export fails
|
|
24
|
+
* with an explicit assertion message instead of an import-time throw.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
27
|
+
import type { ComponentType } from 'react'
|
|
28
|
+
import { render, fireEvent } from '@testing-library/react'
|
|
29
|
+
|
|
30
|
+
/** Structural duplicate of the `CompileEvent` export from compile-types. */
|
|
31
|
+
interface CompileEvent {
|
|
32
|
+
at: number
|
|
33
|
+
status: string
|
|
34
|
+
message: string
|
|
35
|
+
hotReload?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface CompilePanelProps {
|
|
39
|
+
events: CompileEvent[]
|
|
40
|
+
onClear: () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadCompilePanel(): Promise<ComponentType<CompilePanelProps>> {
|
|
44
|
+
const mod = (await import('./compile-panel-view.js')) as {
|
|
45
|
+
CompilePanel?: ComponentType<CompilePanelProps>
|
|
46
|
+
}
|
|
47
|
+
expect(
|
|
48
|
+
mod.CompilePanel,
|
|
49
|
+
'compile-panel-view.tsx must have a named export `CompilePanel`',
|
|
50
|
+
).toBeTruthy()
|
|
51
|
+
return mod.CompilePanel!
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Local-time timestamp helper — HH:MM:SS assertions stay timezone-proof. */
|
|
55
|
+
function at(h: number, m: number, s: number): number {
|
|
56
|
+
return new Date(2026, 5, 12, h, m, s).getTime()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function renderPanel(events: CompileEvent[], onClear = vi.fn()) {
|
|
60
|
+
const CompilePanel = await loadCompilePanel()
|
|
61
|
+
const utils = render(<CompilePanel events={events} onClear={onClear} />)
|
|
62
|
+
return { ...utils, onClear }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function rowsOf(container: HTMLElement): HTMLElement[] {
|
|
66
|
+
return Array.from(container.querySelectorAll<HTMLElement>('[data-compile-row]'))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('CompilePanel (编译 tab body)', () => {
|
|
70
|
+
it('renders an empty state (暂无编译…) and no rows when there are no events', async () => {
|
|
71
|
+
const { container } = await renderPanel([])
|
|
72
|
+
expect(
|
|
73
|
+
container.textContent,
|
|
74
|
+
'with no events the panel must show an empty-state copy instead of a blank area',
|
|
75
|
+
).toMatch(/暂无编译/)
|
|
76
|
+
expect(rowsOf(container)).toHaveLength(0)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('shows a current-status badge for the LATEST event (status attribute + message text)', async () => {
|
|
80
|
+
const { container } = await renderPanel([
|
|
81
|
+
{ at: at(9, 0, 0), status: 'compiling', message: '正在编译...' },
|
|
82
|
+
{ at: at(9, 0, 2), status: 'ready', message: '编译完成(最新)' },
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
const badge = container.querySelector<HTMLElement>('[data-compile-current]')
|
|
86
|
+
expect(
|
|
87
|
+
badge,
|
|
88
|
+
'the panel header must carry a [data-compile-current] badge reflecting the most recent event',
|
|
89
|
+
).not.toBeNull()
|
|
90
|
+
expect(
|
|
91
|
+
badge!.getAttribute('data-status'),
|
|
92
|
+
'the badge must expose the latest status via data-status (the assertable styling hook)',
|
|
93
|
+
).toBe('ready')
|
|
94
|
+
expect(badge!.textContent).toContain('编译完成(最新)')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('lists history OLDEST FIRST with an HH:MM:SS timestamp and the message on each row', async () => {
|
|
98
|
+
const { container } = await renderPanel([
|
|
99
|
+
{ at: at(9, 5, 7), status: 'compiling', message: '第一条' },
|
|
100
|
+
{ at: at(9, 5, 9), status: 'ready', message: '第二条' },
|
|
101
|
+
])
|
|
102
|
+
|
|
103
|
+
const rows = rowsOf(container)
|
|
104
|
+
expect(rows).toHaveLength(2)
|
|
105
|
+
expect(
|
|
106
|
+
rows[0]!.textContent,
|
|
107
|
+
'rows must be chronological: the oldest event is the first row',
|
|
108
|
+
).toContain('第一条')
|
|
109
|
+
expect(rows[0]!.textContent).toMatch(/09:05:07/)
|
|
110
|
+
expect(rows[1]!.textContent).toContain('第二条')
|
|
111
|
+
expect(rows[1]!.textContent).toMatch(/09:05:09/)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('marks error rows distinguishably via data-status="error"', async () => {
|
|
115
|
+
const { container } = await renderPanel([
|
|
116
|
+
{ at: at(10, 0, 0), status: 'ready', message: '编译完成' },
|
|
117
|
+
{ at: at(10, 0, 5), status: 'error', message: '编译失败: syntax error' },
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
const rows = rowsOf(container)
|
|
121
|
+
expect(rows).toHaveLength(2)
|
|
122
|
+
// Oldest first → the error event is the last row.
|
|
123
|
+
expect(
|
|
124
|
+
rows[1]!.getAttribute('data-status'),
|
|
125
|
+
'error rows must carry data-status="error" so they can be styled (and asserted) distinctly',
|
|
126
|
+
).toBe('error')
|
|
127
|
+
expect(rows[0]!.getAttribute('data-status')).toBe('ready')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('renders a 已重启 chip on hotReload events only', async () => {
|
|
131
|
+
const { container } = await renderPanel([
|
|
132
|
+
{ at: at(11, 0, 0), status: 'ready', message: '普通编译完成' },
|
|
133
|
+
{ at: at(11, 0, 5), status: 'ready', message: '编译完成,已重启', hotReload: true },
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
const rows = rowsOf(container)
|
|
137
|
+
expect(rows).toHaveLength(2)
|
|
138
|
+
// Oldest first → the hotReload event is the last row.
|
|
139
|
+
expect(
|
|
140
|
+
rows[1]!.textContent,
|
|
141
|
+
'a hotReload event must carry a recognizable 已重启 marker',
|
|
142
|
+
).toMatch(/已重启/)
|
|
143
|
+
expect(
|
|
144
|
+
rows[0]!.textContent,
|
|
145
|
+
'plain events must NOT carry the 已重启 marker',
|
|
146
|
+
).not.toMatch(/已重启/)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('shows the elapsed time on a ready event paired with the immediately preceding compiling event', async () => {
|
|
150
|
+
const t0 = at(12, 0, 0)
|
|
151
|
+
const { container } = await renderPanel([
|
|
152
|
+
{ at: t0, status: 'compiling', message: '正在编译...' },
|
|
153
|
+
{ at: t0 + 2200, status: 'ready', message: '编译完成' },
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
const rows = rowsOf(container)
|
|
157
|
+
// Oldest first → the paired ready event is the last row.
|
|
158
|
+
const duration = rows[1]!.querySelector<HTMLElement>('[data-compile-duration]')
|
|
159
|
+
expect(
|
|
160
|
+
duration,
|
|
161
|
+
'a ready row paired with the previous compiling row must show the elapsed time in [data-compile-duration]',
|
|
162
|
+
).not.toBeNull()
|
|
163
|
+
expect(duration!.textContent).toMatch(/2\.2\s*s/)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('shows NO duration when the ready event cannot be paired with a preceding compiling event', async () => {
|
|
167
|
+
// Case 1: ready is the very first event (nothing to pair with).
|
|
168
|
+
const first = await renderPanel([
|
|
169
|
+
{ at: at(13, 0, 0), status: 'ready', message: '编译完成' },
|
|
170
|
+
])
|
|
171
|
+
expect(first.container.querySelector('[data-compile-duration]')).toBeNull()
|
|
172
|
+
first.unmount()
|
|
173
|
+
|
|
174
|
+
// Case 2: the immediately previous event is not compiling.
|
|
175
|
+
const second = await renderPanel([
|
|
176
|
+
{ at: at(13, 1, 0), status: 'compiling', message: '正在编译...' },
|
|
177
|
+
{ at: at(13, 1, 2), status: 'error', message: '编译失败' },
|
|
178
|
+
{ at: at(13, 1, 9), status: 'ready', message: '编译完成' },
|
|
179
|
+
])
|
|
180
|
+
expect(
|
|
181
|
+
second.container.querySelector('[data-compile-duration]'),
|
|
182
|
+
'pairing is strictly with the IMMEDIATELY preceding event — an intervening error breaks the pair',
|
|
183
|
+
).toBeNull()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('the 清空 button calls onClear', async () => {
|
|
187
|
+
const { getByRole, onClear } = await renderPanel([
|
|
188
|
+
{ at: at(14, 0, 0), status: 'ready', message: '编译完成' },
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
fireEvent.click(getByRole('button', { name: '清空' }))
|
|
192
|
+
expect(onClear).toHaveBeenCalledTimes(1)
|
|
193
|
+
})
|
|
194
|
+
})
|