de-shell 0.2.0__py3-none-any.whl
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.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figureBridge.ts — routing backend state into figure iframes.
|
|
3
|
+
*
|
|
4
|
+
* An anyplotlib figure lives in an iframe and is fed by `postMessage`. The
|
|
5
|
+
* backend sends only CHANGES, so a message that arrives before its iframe has
|
|
6
|
+
* mounted a listener is gone for good — silently, because posting into an
|
|
7
|
+
* unmounted frame is a no-op, not an error. That single fact is why this is a
|
|
8
|
+
* stateful bridge rather than a forwarding function: every state is RETAINED so
|
|
9
|
+
* a frame can be replayed when it loads.
|
|
10
|
+
*
|
|
11
|
+
* Lifted from SpyDE's implementation, which had already paid for the three
|
|
12
|
+
* non-obvious parts:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Replay takes an explicit target.** A figure can be mounted more than
|
|
15
|
+
* once under the same figId (SpyDE's report cell and its presented slide),
|
|
16
|
+
* and the registry holds ONE element per id. Resolving the target from the
|
|
17
|
+
* map means whichever mounted last wins, so a freshly-loaded frame would
|
|
18
|
+
* push its state into its SIBLING and receive nothing. Which mount won was a
|
|
19
|
+
* race, so a presented deck came up blank on some machines and not others.
|
|
20
|
+
* A frame that has just loaded passes ITSELF.
|
|
21
|
+
*
|
|
22
|
+
* 2. **Binary frames stash per PANEL, not per pixel field.** `key` is the pixel
|
|
23
|
+
* field ("image_b64" / "detail_b64" / …) and is identical across panels; the
|
|
24
|
+
* panel is identified by `header.geom`. Keying by `key` alone let each panel
|
|
25
|
+
* overwrite the previous one, so a multi-panel figure retained exactly one
|
|
26
|
+
* frame however many panels it had — and replayed as one drawn panel and the
|
|
27
|
+
* rest blank-but-for-their-scale-bars.
|
|
28
|
+
*
|
|
29
|
+
* 3. **Transfer the buffer, but replay a COPY.** Transferring detaches the
|
|
30
|
+
* original, which would empty the stash the first time it was replayed.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface BinaryFrame {
|
|
34
|
+
key: string
|
|
35
|
+
header: unknown
|
|
36
|
+
buffer: Uint8Array
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A `{current}` box, so these drop straight into a React ref position. */
|
|
40
|
+
export interface RefLike<T> {
|
|
41
|
+
current: T
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FigureBridge {
|
|
45
|
+
/** figId → the iframe currently registered for it. */
|
|
46
|
+
iframes: RefLike<Map<string, HTMLIFrameElement>>
|
|
47
|
+
/** figId → (state key → latest value). */
|
|
48
|
+
states: RefLike<Map<string, Map<string, unknown>>>
|
|
49
|
+
/** figId → (`geom::pixelField` → latest binary frame). */
|
|
50
|
+
binaryStates: RefLike<Map<string, Map<string, BinaryFrame>>>
|
|
51
|
+
|
|
52
|
+
registerIframe(figId: string, el: HTMLIFrameElement | null): void
|
|
53
|
+
applyState(figId: string, key: string, value: unknown): void
|
|
54
|
+
applyBinary(figId: string, key: string, header: unknown, bytes: Uint8Array): void
|
|
55
|
+
replay(figId: string, target?: HTMLIFrameElement): void
|
|
56
|
+
evict(figId: string): void
|
|
57
|
+
post(figId: string, message: Record<string, unknown>, target?: HTMLIFrameElement): boolean
|
|
58
|
+
dump(classify?: (el: HTMLIFrameElement | undefined) => string): Record<string, unknown>[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createFigureBridge(
|
|
62
|
+
log: (label: string, detail: Record<string, unknown>) => void = () => {},
|
|
63
|
+
): FigureBridge {
|
|
64
|
+
const iframes: RefLike<Map<string, HTMLIFrameElement>> = { current: new Map() }
|
|
65
|
+
const states: RefLike<Map<string, Map<string, unknown>>> = { current: new Map() }
|
|
66
|
+
const binaryStates: RefLike<Map<string, Map<string, BinaryFrame>>> = { current: new Map() }
|
|
67
|
+
|
|
68
|
+
function post(figId: string, message: Record<string, unknown>,
|
|
69
|
+
target?: HTMLIFrameElement): boolean {
|
|
70
|
+
const iframe = target ?? iframes.current.get(figId)
|
|
71
|
+
if (!iframe?.contentWindow) return false
|
|
72
|
+
iframe.contentWindow.postMessage(message, '*')
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function registerIframe(figId: string, el: HTMLIFrameElement | null): void {
|
|
77
|
+
if (el) iframes.current.set(figId, el)
|
|
78
|
+
else iframes.current.delete(figId)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function applyState(figId: string, key: string, value: unknown): void {
|
|
82
|
+
let slot = states.current.get(figId)
|
|
83
|
+
if (!slot) { slot = new Map(); states.current.set(figId, slot) }
|
|
84
|
+
slot.set(key, value)
|
|
85
|
+
post(figId, { type: 'awi_state', key, value })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function applyBinary(figId: string, key: string, header: unknown,
|
|
89
|
+
bytes: Uint8Array): void {
|
|
90
|
+
if (bytes) {
|
|
91
|
+
let slot = binaryStates.current.get(figId)
|
|
92
|
+
if (!slot) { slot = new Map(); binaryStates.current.set(figId, slot) }
|
|
93
|
+
const geom = (header as { geom?: string } | undefined)?.geom
|
|
94
|
+
// Mirrors the figure ESM's own slot convention, `geom::pixelKey`.
|
|
95
|
+
const stashKey = geom ? `${geom}::${key}` : key
|
|
96
|
+
// slice() because the postMessage below TRANSFERS the buffer and detaches
|
|
97
|
+
// the original — the stash has to own its own copy.
|
|
98
|
+
slot.set(stashKey, { key, header, buffer: bytes.slice() })
|
|
99
|
+
}
|
|
100
|
+
const iframe = iframes.current.get(figId)
|
|
101
|
+
iframe?.contentWindow?.postMessage(
|
|
102
|
+
{ type: 'awi_state_binary', key, header, buffer: bytes },
|
|
103
|
+
'*',
|
|
104
|
+
bytes?.buffer ? [bytes.buffer] : [],
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function replay(figId: string, target?: HTMLIFrameElement): void {
|
|
109
|
+
const iframe = target ?? iframes.current.get(figId)
|
|
110
|
+
if (!iframe?.contentWindow) return
|
|
111
|
+
const slot = states.current.get(figId)
|
|
112
|
+
if (slot) {
|
|
113
|
+
for (const [key, value] of slot) {
|
|
114
|
+
iframe.contentWindow.postMessage({ type: 'awi_state', key, value }, '*')
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const binSlot = binaryStates.current.get(figId)
|
|
118
|
+
if (binSlot) {
|
|
119
|
+
for (const { key, header, buffer } of binSlot.values()) {
|
|
120
|
+
// A COPY: transfer detaches, and the stash must survive a later remount
|
|
121
|
+
// (a re-tile, a dev StrictMode double-mount, a second presented copy).
|
|
122
|
+
const copy = buffer.slice()
|
|
123
|
+
iframe.contentWindow.postMessage(
|
|
124
|
+
{ type: 'awi_state_binary', key, header, buffer: copy }, '*', [copy.buffer],
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
log('replayState', {
|
|
129
|
+
figId,
|
|
130
|
+
jsonKeys: slot ? slot.size : 0,
|
|
131
|
+
binaryKeys: binSlot ? binSlot.size : 0,
|
|
132
|
+
target: iframe.getAttribute('data-testid'),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function evict(figId: string): void {
|
|
137
|
+
states.current.delete(figId)
|
|
138
|
+
binaryStates.current.delete(figId)
|
|
139
|
+
iframes.current.delete(figId)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function dump(classify?: (el: HTMLIFrameElement | undefined) => string) {
|
|
143
|
+
const rows: Record<string, unknown>[] = []
|
|
144
|
+
const figIds = new Set<string>([
|
|
145
|
+
...states.current.keys(),
|
|
146
|
+
...binaryStates.current.keys(),
|
|
147
|
+
...iframes.current.keys(),
|
|
148
|
+
])
|
|
149
|
+
for (const figId of figIds) {
|
|
150
|
+
const el = iframes.current.get(figId)
|
|
151
|
+
const rect = el?.getBoundingClientRect()
|
|
152
|
+
const bin = binaryStates.current.get(figId)
|
|
153
|
+
rows.push({
|
|
154
|
+
figId,
|
|
155
|
+
jsonKeys: states.current.get(figId)?.size ?? 0,
|
|
156
|
+
binaryKeys: bin?.size ?? 0,
|
|
157
|
+
binaryKeyNames: bin ? Array.from(bin.keys()).join(',') : '',
|
|
158
|
+
registeredIn: classify ? classify(el) : (el ? 'mounted' : 'NONE'),
|
|
159
|
+
size: rect ? `${Math.round(rect.width)}x${Math.round(rect.height)}` : 'n/a',
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
return rows
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
iframes, states, binaryStates,
|
|
167
|
+
registerIframe, applyState, applyBinary, replay, evict, post, dump,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @de/shell-renderer — the React-side kernel shared by SpyDE, de-groundcrew and
|
|
3
|
+
* de-autopilot.
|
|
4
|
+
*
|
|
5
|
+
* What is here is the machinery every shell app needs to talk to a Python
|
|
6
|
+
* backend and put figures on screen: the core message protocol, the figure
|
|
7
|
+
* bridge, and the iframe host. Layouts, sidebars and domain UI stay in the apps
|
|
8
|
+
* — the whole point of the split is that SpyDE's MDI workspace and Ground Crew's
|
|
9
|
+
* fixed panes are different answers over the SAME registry.
|
|
10
|
+
*/
|
|
11
|
+
export {
|
|
12
|
+
createFigureBridge, useFigureBridge, useFigureEventForwarding,
|
|
13
|
+
} from './figureBridge.react'
|
|
14
|
+
export type { FigureBridge, BinaryFrame, RefLike } from './figureBridge'
|
|
15
|
+
|
|
16
|
+
export { FigureFrame } from './FigureFrame'
|
|
17
|
+
export type { FigureFrameProps } from './FigureFrame'
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
shellReducer, shellInitialState, toShellAction, LOG_MAX, STREAM_MAX,
|
|
21
|
+
} from './shellState'
|
|
22
|
+
export type {
|
|
23
|
+
ShellState, ShellAction, LogEntry, SubItem, EnvPhase, EnvSetupState,
|
|
24
|
+
} from './shellState'
|
|
25
|
+
|
|
26
|
+
export { asShellMessage } from './protocol'
|
|
27
|
+
export type {
|
|
28
|
+
MsgBase, ShellMessage,
|
|
29
|
+
ReadyMessage, StatusMessage, ErrorMessage, ProgressMessage,
|
|
30
|
+
BackendExitedMessage, EnvSetupMessage,
|
|
31
|
+
FigureMessage, StateUpdateMessage, StateUpdateBinaryMessage,
|
|
32
|
+
WindowClosedMessage, WindowTitleMessage, WindowComputingMessage,
|
|
33
|
+
LogMessage, LogBackfillMessage, LogLevelMessage,
|
|
34
|
+
} from './protocol'
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.ts — the CORE PLOTAPP messages, shared by every shell app.
|
|
3
|
+
*
|
|
4
|
+
* The Python backend emits JSON over stdout; the Electron main process relays it
|
|
5
|
+
* to the renderer. Each message is discriminated by `type`. What is modelled
|
|
6
|
+
* here is the subset with the same meaning in SpyDE, de-groundcrew and
|
|
7
|
+
* de-autopilot — lifecycle, figures, figure state, logging, progress.
|
|
8
|
+
*
|
|
9
|
+
* Everything domain-specific (SpyDE's report/movie/drift/vectors, Ground Crew's
|
|
10
|
+
* frame stats) is the app's own union, which EXTENDS this one. That works
|
|
11
|
+
* additively because every variant carries an index signature: reading a field
|
|
12
|
+
* this file doesn't model is allowed and surfaces as `unknown`, rather than
|
|
13
|
+
* being a compile error. This types the shape the renderer relies on; it is not
|
|
14
|
+
* a schema.
|
|
15
|
+
*
|
|
16
|
+
* `backend_exited` is synthesised by @de/shell-main's backendProcess, not a real
|
|
17
|
+
* PLOTAPP line.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Any field not explicitly modelled is still readable (as `unknown`). */
|
|
21
|
+
export interface MsgBase {
|
|
22
|
+
[k: string]: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReadyMessage extends MsgBase {
|
|
26
|
+
type: 'ready'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StatusMessage extends MsgBase {
|
|
30
|
+
type: 'status'
|
|
31
|
+
text: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ErrorMessage extends MsgBase {
|
|
35
|
+
type: 'error'
|
|
36
|
+
text: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Progress of a heavy backend action. `done >= total`, or `total <= 0`, clears. */
|
|
40
|
+
export interface ProgressMessage extends MsgBase {
|
|
41
|
+
type: 'progress'
|
|
42
|
+
done: number
|
|
43
|
+
total: number
|
|
44
|
+
label?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BackendExitedMessage extends MsgBase {
|
|
48
|
+
type: 'backend_exited'
|
|
49
|
+
code: number | null
|
|
50
|
+
/** Set when main synthesises this for a packaged env-setup failure, as
|
|
51
|
+
* distinct from a plain runtime death. */
|
|
52
|
+
reason?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** First-run Python environment setup, parsed from `uv` output by the main
|
|
56
|
+
* process (envProgress.ts). `start`/`done` bracket the run. */
|
|
57
|
+
export interface EnvSetupMessage extends MsgBase {
|
|
58
|
+
type: 'env_setup'
|
|
59
|
+
event: 'start' | 'progress' | 'done'
|
|
60
|
+
phase?: 'resolving' | 'downloading' | 'installing' | 'building' | 'torch' | 'working'
|
|
61
|
+
step?: string
|
|
62
|
+
percent?: number | null
|
|
63
|
+
raw?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A figure to mount. Carries EITHER inline `html` (mounted via srcdoc) or a
|
|
67
|
+
* `file_url` served through the app's figure scheme — see the note in
|
|
68
|
+
* FigureFrame about which and why. */
|
|
69
|
+
export interface FigureMessage extends MsgBase {
|
|
70
|
+
type: 'figure'
|
|
71
|
+
window_id: number
|
|
72
|
+
fig_id: string
|
|
73
|
+
html?: string
|
|
74
|
+
file_url?: string | null
|
|
75
|
+
title?: string
|
|
76
|
+
is_navigator?: boolean
|
|
77
|
+
/** Image width/height, so a host can size the pane to the data. */
|
|
78
|
+
aspect?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An anyplotlib state change, forwarded into the figure iframe. */
|
|
82
|
+
export interface StateUpdateMessage extends MsgBase {
|
|
83
|
+
type: 'state_update'
|
|
84
|
+
fig_id: string
|
|
85
|
+
key: string
|
|
86
|
+
value: unknown
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A raw pixel frame: bytes rather than base64. `header.geom` names the panel —
|
|
90
|
+
* load-bearing for retention, see figureBridge. */
|
|
91
|
+
export interface StateUpdateBinaryMessage extends MsgBase {
|
|
92
|
+
type: 'state_update_binary'
|
|
93
|
+
fig_id: string
|
|
94
|
+
key: string
|
|
95
|
+
header?: Record<string, unknown>
|
|
96
|
+
buffer: Uint8Array
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface WindowClosedMessage extends MsgBase {
|
|
100
|
+
type: 'window_closed'
|
|
101
|
+
window_id: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface WindowTitleMessage extends MsgBase {
|
|
105
|
+
type: 'window_title'
|
|
106
|
+
window_id: number
|
|
107
|
+
title: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Drives the floating translucent "Calculating…" chip over one window. */
|
|
111
|
+
export interface WindowComputingMessage extends MsgBase {
|
|
112
|
+
type: 'window_computing'
|
|
113
|
+
window_id: number
|
|
114
|
+
computing: boolean
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One application-log record streamed from the backend. `area` is the
|
|
118
|
+
* subsystem tag the app registered (see de_shell.log_stream). */
|
|
119
|
+
export interface LogMessage extends MsgBase {
|
|
120
|
+
type: 'log'
|
|
121
|
+
level: string
|
|
122
|
+
name: string
|
|
123
|
+
area: string
|
|
124
|
+
msg: string
|
|
125
|
+
time: number
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface LogBackfillMessage extends MsgBase {
|
|
129
|
+
type: 'log_backfill'
|
|
130
|
+
entries: Array<Record<string, unknown>>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface LogLevelMessage extends MsgBase {
|
|
134
|
+
type: 'log_level'
|
|
135
|
+
level: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The core union. An app extends it:
|
|
139
|
+
*
|
|
140
|
+
* type MyMessage = ShellMessage | MyDomainMessage | …
|
|
141
|
+
*/
|
|
142
|
+
export type ShellMessage =
|
|
143
|
+
| ReadyMessage
|
|
144
|
+
| StatusMessage
|
|
145
|
+
| ErrorMessage
|
|
146
|
+
| ProgressMessage
|
|
147
|
+
| BackendExitedMessage
|
|
148
|
+
| EnvSetupMessage
|
|
149
|
+
| FigureMessage
|
|
150
|
+
| StateUpdateMessage
|
|
151
|
+
| StateUpdateBinaryMessage
|
|
152
|
+
| WindowClosedMessage
|
|
153
|
+
| WindowTitleMessage
|
|
154
|
+
| WindowComputingMessage
|
|
155
|
+
| LogMessage
|
|
156
|
+
| LogBackfillMessage
|
|
157
|
+
| LogLevelMessage
|
|
158
|
+
|
|
159
|
+
/** Narrow a raw IPC payload to the union. The cast is the point: `type` is the
|
|
160
|
+
* discriminator and every variant tolerates unmodelled fields, so this is a
|
|
161
|
+
* narrowing step rather than validation. */
|
|
162
|
+
export function asShellMessage(msg: Record<string, unknown>): ShellMessage {
|
|
163
|
+
return msg as unknown as ShellMessage
|
|
164
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shellState.test.ts — the chrome reducer.
|
|
3
|
+
*
|
|
4
|
+
* The interesting assertions are the composition ones: an app's state extends
|
|
5
|
+
* ShellState and its reducer delegates, so the shell must (a) preserve fields it
|
|
6
|
+
* knows nothing about and (b) return the state untouched for actions it does not
|
|
7
|
+
* own — otherwise the delegation silently eats the app's own actions.
|
|
8
|
+
*/
|
|
9
|
+
import { test, describe } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
shellReducer, shellInitialState, toShellAction, LOG_MAX, STREAM_MAX,
|
|
14
|
+
type ShellState, type LogEntry,
|
|
15
|
+
} from './shellState.ts'
|
|
16
|
+
|
|
17
|
+
/** A state shaped like an app's: the shell's fields plus one of its own. */
|
|
18
|
+
interface AppState extends ShellState { mine: number }
|
|
19
|
+
const appState: AppState = { ...shellInitialState, mine: 42 }
|
|
20
|
+
|
|
21
|
+
const entry = (msg: string): LogEntry =>
|
|
22
|
+
({ level: 'INFO', name: 'x', area: 'a', msg, time: 0 })
|
|
23
|
+
|
|
24
|
+
describe('composition', () => {
|
|
25
|
+
test('an unknown action returns the SAME state object', () => {
|
|
26
|
+
// Identity, not just equality: a new object every dispatch would re-render
|
|
27
|
+
// the whole app on any action the shell does not own.
|
|
28
|
+
const next = shellReducer(appState, { type: 'NOPE' } as never)
|
|
29
|
+
assert.equal(next, appState)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("app fields survive an action the shell DOES own", () => {
|
|
33
|
+
const next = shellReducer(appState, { type: 'STATUS', text: 'hi' })
|
|
34
|
+
assert.equal(next.mine, 42)
|
|
35
|
+
assert.equal(next.status, 'hi')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('logs', () => {
|
|
40
|
+
test('records accumulate in order', () => {
|
|
41
|
+
let s = shellReducer(appState, { type: 'LOG', entries: [entry('a')] })
|
|
42
|
+
s = shellReducer(s, { type: 'LOG', entries: [entry('b')] })
|
|
43
|
+
assert.deepEqual(s.logEntries.map(e => e.msg), ['a', 'b'])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('an empty batch is a no-op with no new object', () => {
|
|
47
|
+
const s = shellReducer(appState, { type: 'LOG', entries: [] })
|
|
48
|
+
assert.equal(s, appState)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('the ring is bounded and keeps the NEWEST', () => {
|
|
52
|
+
const many = Array.from({ length: LOG_MAX + 50 }, (_, i) => entry(String(i)))
|
|
53
|
+
const s = shellReducer(appState, { type: 'LOG', entries: many })
|
|
54
|
+
assert.equal(s.logEntries.length, LOG_MAX)
|
|
55
|
+
assert.equal(s.logEntries.at(-1)!.msg, String(LOG_MAX + 49))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('backfill REPLACES rather than appends', () => {
|
|
59
|
+
// It is a re-send of history for a freshly-opened panel; appending would
|
|
60
|
+
// double every record already shown.
|
|
61
|
+
let s = shellReducer(appState, { type: 'LOG', entries: [entry('old')] })
|
|
62
|
+
s = shellReducer(s, { type: 'LOG_BACKFILL', entries: [entry('x'), entry('y')] })
|
|
63
|
+
assert.deepEqual(s.logEntries.map(e => e.msg), ['x', 'y'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('clear empties the panel', () => {
|
|
67
|
+
let s = shellReducer(appState, { type: 'LOG', entries: [entry('a'), entry('b')] })
|
|
68
|
+
s = shellReducer(s, { type: 'LOG_CLEAR' })
|
|
69
|
+
assert.deepEqual(s.logEntries, [])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('clearing an empty log changes nothing', () => {
|
|
73
|
+
// Identity, not a fresh object: a reducer that allocated on every no-op
|
|
74
|
+
// would re-render the panel for nothing.
|
|
75
|
+
assert.equal(shellReducer(appState, { type: 'LOG_CLEAR' }), appState)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('stream lines are bounded too', () => {
|
|
79
|
+
let s: AppState = appState
|
|
80
|
+
for (let i = 0; i < STREAM_MAX + 20; i++) {
|
|
81
|
+
s = shellReducer(s, { type: 'STREAM', text: String(i), kind: 'stdout' })
|
|
82
|
+
}
|
|
83
|
+
assert.ok(s.streamLines.length <= STREAM_MAX + 1)
|
|
84
|
+
assert.equal(s.streamLines.at(-1)!.text, String(STREAM_MAX + 19))
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('backend death and env setup', () => {
|
|
89
|
+
test('backend death clears the setup overlay so the two cannot stack', () => {
|
|
90
|
+
let s = shellReducer(appState, { type: 'ENV_SETUP_START' })
|
|
91
|
+
assert.ok(s.envSetup)
|
|
92
|
+
s = shellReducer(s, { type: 'BACKEND_EXITED', code: 1, reason: 'uv sync failed' })
|
|
93
|
+
assert.equal(s.envSetup, null)
|
|
94
|
+
assert.equal(s.ready, false)
|
|
95
|
+
assert.deepEqual(s.backendExited, { code: 1, reason: 'uv sync failed' })
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('progress keeps the last meaningful phase/step when a line parses to nothing', () => {
|
|
99
|
+
let s = shellReducer(appState, { type: 'ENV_SETUP_START' })
|
|
100
|
+
s = shellReducer(s, {
|
|
101
|
+
type: 'ENV_SETUP_PROGRESS', phase: 'downloading', step: 'torch', percent: 10, raw: 'a',
|
|
102
|
+
})
|
|
103
|
+
s = shellReducer(s, { type: 'ENV_SETUP_PROGRESS', percent: null, raw: 'noise' })
|
|
104
|
+
assert.equal(s.envSetup!.phase, 'downloading')
|
|
105
|
+
assert.equal(s.envSetup!.step, 'torch')
|
|
106
|
+
assert.deepEqual(s.envSetup!.lines, ['a', 'noise'])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('progress without a preceding start still works', () => {
|
|
110
|
+
// env_setup progress can be the FIRST message a slow first launch sends.
|
|
111
|
+
const s = shellReducer(appState, {
|
|
112
|
+
type: 'ENV_SETUP_PROGRESS', percent: 5, raw: 'x',
|
|
113
|
+
})
|
|
114
|
+
assert.ok(s.envSetup)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('done clears the overlay', () => {
|
|
118
|
+
let s = shellReducer(appState, { type: 'ENV_SETUP_START' })
|
|
119
|
+
s = shellReducer(s, { type: 'ENV_SETUP_DONE' })
|
|
120
|
+
assert.equal(s.envSetup, null)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('computing overlays and action state', () => {
|
|
125
|
+
test('a repeated computing message is a no-op with no new object', () => {
|
|
126
|
+
const on = shellReducer(appState, { type: 'WINDOW_COMPUTING', windowId: 1, computing: true })
|
|
127
|
+
const again = shellReducer(on, { type: 'WINDOW_COMPUTING', windowId: 1, computing: true })
|
|
128
|
+
assert.equal(again, on, 're-emit re-rendered every window')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('computing toggles per window independently', () => {
|
|
132
|
+
let s = shellReducer(appState, { type: 'WINDOW_COMPUTING', windowId: 1, computing: true })
|
|
133
|
+
s = shellReducer(s, { type: 'WINDOW_COMPUTING', windowId: 2, computing: true })
|
|
134
|
+
s = shellReducer(s, { type: 'WINDOW_COMPUTING', windowId: 1, computing: false })
|
|
135
|
+
assert.deepEqual([...s.computingWindows], [2])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('active actions add and remove per window', () => {
|
|
139
|
+
let s = shellReducer(appState, { type: 'ACTION_ACTIVE', windowId: 1, name: 'fft', active: true })
|
|
140
|
+
s = shellReducer(s, { type: 'ACTION_ACTIVE', windowId: 1, name: 'vi', active: true })
|
|
141
|
+
s = shellReducer(s, { type: 'ACTION_ACTIVE', windowId: 1, name: 'fft', active: false })
|
|
142
|
+
assert.deepEqual([...s.activeActions.get(1)!], ['vi'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('sub-items replace by name rather than duplicating', () => {
|
|
146
|
+
let s = shellReducer(appState, {
|
|
147
|
+
type: 'SUB_ITEM', windowId: 1, action: 'vi', name: 'r1', color: 'red', active: true,
|
|
148
|
+
})
|
|
149
|
+
s = shellReducer(s, {
|
|
150
|
+
type: 'SUB_ITEM', windowId: 1, action: 'vi', name: 'r1', color: 'blue', active: true,
|
|
151
|
+
})
|
|
152
|
+
const list = s.subItems.get(1)!.get('vi')!
|
|
153
|
+
assert.equal(list.length, 1)
|
|
154
|
+
assert.equal(list[0].color, 'blue')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('an inactive sub-item is removed', () => {
|
|
158
|
+
let s = shellReducer(appState, {
|
|
159
|
+
type: 'SUB_ITEM', windowId: 1, action: 'vi', name: 'r1', color: 'red', active: true,
|
|
160
|
+
})
|
|
161
|
+
s = shellReducer(s, {
|
|
162
|
+
type: 'SUB_ITEM', windowId: 1, action: 'vi', name: 'r1', color: 'red', active: false,
|
|
163
|
+
})
|
|
164
|
+
assert.deepEqual(s.subItems.get(1)!.get('vi'), [])
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('toShellAction', () => {
|
|
169
|
+
test('maps the chrome messages', () => {
|
|
170
|
+
assert.deepEqual(toShellAction({ type: 'status', text: 'hi' }),
|
|
171
|
+
{ type: 'STATUS', text: 'hi' })
|
|
172
|
+
assert.deepEqual(toShellAction({ type: 'backend_exited', code: 2 }),
|
|
173
|
+
{ type: 'BACKEND_EXITED', code: 2, reason: undefined })
|
|
174
|
+
assert.equal(toShellAction({ type: 'env_setup', event: 'start' })!.type, 'ENV_SETUP_START')
|
|
175
|
+
assert.equal(toShellAction({ type: 'env_setup', event: 'done' })!.type, 'ENV_SETUP_DONE')
|
|
176
|
+
assert.equal(
|
|
177
|
+
toShellAction({ type: 'env_setup', event: 'progress', raw: 'x' })!.type,
|
|
178
|
+
'ENV_SETUP_PROGRESS')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('returns null for anything the app owns', () => {
|
|
182
|
+
// The contract that lets an app write `if (a) { dispatch(a); return }` and
|
|
183
|
+
// then handle the rest — a wrong non-null here would SWALLOW a domain
|
|
184
|
+
// message.
|
|
185
|
+
assert.equal(toShellAction({ type: 'frame_stats' }), null)
|
|
186
|
+
assert.equal(toShellAction({ type: 'figure' }), null)
|
|
187
|
+
assert.equal(toShellAction({ type: 'log' }), null)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('log is deliberately NOT mapped, so hosts can batch', () => {
|
|
191
|
+
assert.equal(toShellAction({ type: 'log', msg: 'x' }), null)
|
|
192
|
+
})
|
|
193
|
+
})
|