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.
Files changed (57) hide show
  1. de_shell/__init__.py +25 -0
  2. de_shell/actions/__init__.py +0 -0
  3. de_shell/actions/context.py +62 -0
  4. de_shell/actions/figure_registry.py +53 -0
  5. de_shell/actions/lifecycle.py +295 -0
  6. de_shell/actions/registry.py +141 -0
  7. de_shell/actions/wizard.py +115 -0
  8. de_shell/app.py +170 -0
  9. de_shell/compute.py +103 -0
  10. de_shell/debug_flags.py +69 -0
  11. de_shell/ipc.py +236 -0
  12. de_shell/js/__init__.py +38 -0
  13. de_shell/js/__main__.py +4 -0
  14. de_shell/js/main/backendProcess.test.ts +70 -0
  15. de_shell/js/main/backendProcess.ts +330 -0
  16. de_shell/js/main/config.ts +53 -0
  17. de_shell/js/main/dialogs.ts +62 -0
  18. de_shell/js/main/envProgress.ts +126 -0
  19. de_shell/js/main/errorReport.ts +261 -0
  20. de_shell/js/main/index.ts +57 -0
  21. de_shell/js/main/problemLog.ts +53 -0
  22. de_shell/js/main/pythonEnv.test.ts +125 -0
  23. de_shell/js/main/pythonEnv.ts +442 -0
  24. de_shell/js/main/sentryEnvelope.test.ts +94 -0
  25. de_shell/js/main/sentryEnvelope.ts +100 -0
  26. de_shell/js/main/updater.ts +322 -0
  27. de_shell/js/main/updaterErrors.test.ts +111 -0
  28. de_shell/js/main/updaterErrors.ts +65 -0
  29. de_shell/js/main/window.ts +141 -0
  30. de_shell/js/package.json +5 -0
  31. de_shell/js/preload/index.ts +130 -0
  32. de_shell/js/renderer/FigureFrame.tsx +88 -0
  33. de_shell/js/renderer/figureBridge.react.ts +58 -0
  34. de_shell/js/renderer/figureBridge.test.ts +184 -0
  35. de_shell/js/renderer/figureBridge.ts +169 -0
  36. de_shell/js/renderer/index.ts +34 -0
  37. de_shell/js/renderer/protocol.ts +164 -0
  38. de_shell/js/renderer/shellState.test.ts +193 -0
  39. de_shell/js/renderer/shellState.ts +310 -0
  40. de_shell/js/testing/harness.cjs +244 -0
  41. de_shell/js/testing/harness.test.cjs +73 -0
  42. de_shell/log_stream.py +185 -0
  43. de_shell/plotting/__init__.py +0 -0
  44. de_shell/plotting/colormaps.py +27 -0
  45. de_shell/plotting/figure.py +601 -0
  46. de_shell/plotting/selectors/__init__.py +0 -0
  47. de_shell/plotting/selectors/utils.py +29 -0
  48. de_shell/plotting/stream.py +172 -0
  49. de_shell/process_guard.py +190 -0
  50. de_shell/session.py +211 -0
  51. de_shell/testing/__init__.py +0 -0
  52. de_shell/timing.py +28 -0
  53. de_shell-0.2.0.dist-info/METADATA +196 -0
  54. de_shell-0.2.0.dist-info/RECORD +57 -0
  55. de_shell-0.2.0.dist-info/WHEEL +5 -0
  56. de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
  57. 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
+ })