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,141 @@
1
+ /**
2
+ * window.ts — the app window, and getting backend messages into it.
3
+ *
4
+ * Both live apps had written this identically: create a BrowserWindow with the
5
+ * preload attached, buffer backend messages until the renderer is listening,
6
+ * flush them in order, and tee renderer console output to the terminal.
7
+ *
8
+ * The buffering is the part that is not obvious. Backend messages can arrive
9
+ * before the renderer has finished loading and registered its `ipcRenderer`
10
+ * listener, and `webContents.send()` DROPS anything sent before then — silently.
11
+ * That swallowed the first message after any quiet period: in SpyDE, the
12
+ * nav-shape prompt on opening a file (the dialog only appeared once a later load
13
+ * pushed more messages); in a live app, the very first figure. So messages queue
14
+ * until `did-finish-load` and are then flushed in order.
15
+ */
16
+ import { BrowserWindow } from 'electron'
17
+ import { join } from 'path'
18
+ import { channel, shellConfig } from './config'
19
+
20
+ export interface ShellWindowOptions {
21
+ /** Directory of the running main bundle — normally `__dirname`. Preload and
22
+ * renderer are resolved relative to it (`../preload`, `../renderer`). */
23
+ mainDir: string
24
+ width?: number
25
+ height?: number
26
+ backgroundColor?: string
27
+ /** Extra BrowserWindow options, merged last. */
28
+ browserWindow?: Electron.BrowserWindowConstructorOptions
29
+ /** Tee renderer + figure-iframe console output to this process's stdout.
30
+ * Warnings and errors always; `logFilter` opts extra lines in. */
31
+ teeConsole?: boolean
32
+ /** Return true to tee a console message that is below warning level. */
33
+ logFilter?: (message: string) => boolean
34
+ }
35
+
36
+ export interface ShellWindow {
37
+ /** The window. Null once it has been closed. */
38
+ get(): BrowserWindow | null
39
+ /** Send a backend message to the renderer, buffering until it is listening. */
40
+ sendToRenderer(msg: Record<string, unknown>): void
41
+ /** Raw backend stdout/stderr, on the preload's `onStream` channel. Not
42
+ * buffered: a line that arrives before the renderer listens is a line
43
+ * nobody was going to read. */
44
+ sendStream(text: string, kind: 'stdout' | 'stderr'): void
45
+ /** True when there is a live window whose webContents is not destroyed. */
46
+ alive(): boolean
47
+ }
48
+
49
+ /**
50
+ * Create the app's window and its message pipe.
51
+ *
52
+ * Loads `ELECTRON_RENDERER_URL` when electron-vite's dev server set it, and the
53
+ * built `../renderer/index.html` otherwise.
54
+ */
55
+ export function createShellWindow(opts: ShellWindowOptions): ShellWindow {
56
+ const cfg = shellConfig()
57
+ const messageChannel = channel('message')
58
+ const streamChannel = channel('stream')
59
+
60
+ let win: BrowserWindow | null = null
61
+ let rendererReady = false
62
+ const pending: Array<Record<string, unknown>> = []
63
+
64
+ const alive = () =>
65
+ !!win && !win.isDestroyed() && !win.webContents.isDestroyed()
66
+
67
+ const flush = () => {
68
+ if (!alive()) return
69
+ while (pending.length) win!.webContents.send(messageChannel, pending.shift())
70
+ }
71
+
72
+ const sendToRenderer = (msg: Record<string, unknown>) => {
73
+ if (!rendererReady || !alive()) { pending.push(msg); return }
74
+ win!.webContents.send(messageChannel, msg)
75
+ }
76
+ const sendStream = (text: string, kind: 'stdout' | 'stderr') => {
77
+ if (rendererReady && alive()) win!.webContents.send(streamChannel, text, kind)
78
+ }
79
+
80
+ win = new BrowserWindow({
81
+ width: opts.width ?? 1280,
82
+ height: opts.height ?? 860,
83
+ backgroundColor: opts.backgroundColor ?? '#14161c',
84
+ // Shown on ready-to-show rather than immediately, so the user never sees an
85
+ // empty white frame while the renderer boots.
86
+ show: false,
87
+ webPreferences: {
88
+ preload: join(opts.mainDir, '..', 'preload', 'index.js'),
89
+ sandbox: false,
90
+ },
91
+ ...opts.browserWindow,
92
+ })
93
+
94
+ win.once('ready-to-show', () => win?.show())
95
+ win.webContents.on('did-finish-load', () => { rendererReady = true; flush() })
96
+ // A reload (dev HMR, Ctrl+R) tears the listener down with the page. Close the
97
+ // gate again so messages queue for the NEW page instead of being sent into a
98
+ // frame that is going away — the exact loss the buffering exists to prevent.
99
+ win.webContents.on('did-start-navigation', (_e, _url, isInPlace, isMainFrame) => {
100
+ if (isMainFrame && !isInPlace) rendererReady = false
101
+ })
102
+ win.on('closed', () => { win = null; rendererReady = false })
103
+
104
+ if (opts.teeConsole !== false) {
105
+ // Renderer AND figure-iframe console output, so a JS error inside a figure
106
+ // frame is visible without opening devtools and switching frame context.
107
+ //
108
+ // TWO event signatures, and getting this wrong fails SILENTLY — the tee
109
+ // simply stops teeing, which is the worst way for a diagnostic to break.
110
+ // Electron <35: (event, level: number, message, line, sourceId), where
111
+ // level is 0=log 1=warning 2=error 3=info. Electron >=35: (event, details)
112
+ // with a STRING level. Reading `level >= 1` off the details OBJECT is
113
+ // always false, so every message is dropped.
114
+ win.webContents.on('console-message', (...args: unknown[]) => {
115
+ const [, second, third] = args
116
+ let message: string
117
+ let warnOrWorse: boolean
118
+ if (second !== null && typeof second === 'object') {
119
+ const d = second as { message?: string; level?: string | number }
120
+ message = String(d.message ?? '')
121
+ warnOrWorse = typeof d.level === 'number'
122
+ ? d.level >= 1
123
+ : d.level === 'warning' || d.level === 'error'
124
+ } else {
125
+ message = String(third ?? '')
126
+ warnOrWorse = Number(second) >= 1
127
+ }
128
+ if (warnOrWorse || opts.logFilter?.(message)) {
129
+ console.log(`[${cfg.appId} renderer] ${message}`)
130
+ }
131
+ })
132
+ }
133
+
134
+ if (process.env.ELECTRON_RENDERER_URL) {
135
+ win.loadURL(process.env.ELECTRON_RENDERER_URL)
136
+ } else {
137
+ win.loadFile(join(opts.mainDir, '..', 'renderer', 'index.html'))
138
+ }
139
+
140
+ return { get: () => win, sendToRenderer, sendStream, alive }
141
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "type": "module",
3
+ "private": true,
4
+ "//": "Node loads the .ts files here as ES modules (node --test with native type stripping); without this it guesses per file and warns. harness.cjs says what it is by its extension."
5
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @de/shell-preload — the contextBridge surface every shell app exposes.
3
+ *
4
+ * Both apps had written the same handful of channels by hand: receive backend
5
+ * messages and raw stdio, send an action, forward a figure event, report a
6
+ * figure resize. This builds that core from the app's id and lets the app spread
7
+ * its own extras alongside.
8
+ *
9
+ * ## Disposers are the whole point of the `on*` shape
10
+ *
11
+ * Every listener registration returns an UNSUBSCRIBE function. The renderer
12
+ * registers these in a `useEffect`, and without cleanup React StrictMode's
13
+ * double-invoke — and every HMR remount — stacks duplicate `ipcRenderer`
14
+ * listeners, so each message is dispatched twice, then three times, and the app
15
+ * degrades as you work. Returning a disposer lets the effect remove the exact
16
+ * listener it added. Do not "simplify" these to bare `ipcRenderer.on`.
17
+ */
18
+ import { contextBridge, ipcRenderer, webUtils } from 'electron'
19
+
20
+ /** An Electron dialog file filter, restated so the renderer need not depend on
21
+ * electron's types. */
22
+ export interface FileFilter { name: string; extensions: string[] }
23
+
24
+ export interface ShellBridgeOptions {
25
+ /** Matches @de/shell-main's `appId` — channels are `<appId>:<name>`. */
26
+ appId: string
27
+ /** Env var the main process sets from `app.isPackaged`, if the app gates
28
+ * test-only hooks on it. Defaults to `<APPID>_PACKAGED`. */
29
+ packagedEnvVar?: string
30
+ }
31
+
32
+ /**
33
+ * The channels every shell app shares. Returned rather than exposed, so an app
34
+ * can spread its own on top:
35
+ *
36
+ * contextBridge.exposeInMainWorld('myapp', {
37
+ * ...createShellBridge({ appId: 'myapp' }),
38
+ * myOwnThing: () => ipcRenderer.invoke('myapp:thing'),
39
+ * })
40
+ */
41
+ export function createShellBridge(opts: ShellBridgeOptions) {
42
+ const { appId } = opts
43
+ const packagedVar = opts.packagedEnvVar
44
+ ?? `${appId.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PACKAGED`
45
+ const channel = (name: string) => `${appId}:${name}`
46
+
47
+ /** Register `handler` on `ch`, returning its exact disposer. */
48
+ const on = <A extends unknown[]>(
49
+ ch: string, cb: (...args: A) => void,
50
+ ): (() => void) => {
51
+ const h = (_: unknown, ...args: A) => cb(...args)
52
+ ipcRenderer.on(ch, h as never)
53
+ return () => { ipcRenderer.removeListener(ch, h as never) }
54
+ }
55
+
56
+ return {
57
+ /** 'darwin' | 'win32' | 'linux' — hosts lay out the title bar from it. */
58
+ platform: process.platform,
59
+
60
+ /** True only in a packaged production app. Dev and the Playwright e2e
61
+ * (which launches the BUILT bundle by path, not a packaged app) leave the
62
+ * env var unset, so test-only hooks stay live in both. */
63
+ isPackaged: process.env[packagedVar] === '1',
64
+
65
+ /** Any backend message. Returns an unsubscribe fn. */
66
+ onMessage: (cb: (msg: Record<string, unknown>) => void) =>
67
+ on<[Record<string, unknown>]>(channel('message'), cb),
68
+
69
+ /** Raw stdout/stderr lines from the backend. Returns an unsubscribe fn. */
70
+ onStream: (cb: (text: string, kind: 'stdout' | 'stderr') => void) =>
71
+ on<[string, 'stdout' | 'stderr']>(channel('stream'), cb),
72
+
73
+ /** Send an action to the backend. */
74
+ action: (action: string, payload: Record<string, unknown> = {}, windowId?: number) =>
75
+ ipcRenderer.send(channel('action'), action, payload, windowId),
76
+
77
+ /** Forward an interaction event from an anyplotlib iframe to the backend. */
78
+ figureEvent: (figId: string, eventJson: string) =>
79
+ ipcRenderer.send(channel('figure-event'), figId, eventJson),
80
+
81
+ /** Tell the backend a figure's container resized, so its layout keeps up. */
82
+ resizeFigure: (figId: string, width: number, height: number) =>
83
+ ipcRenderer.send(channel('resize'), figId, width, height),
84
+
85
+ /** Open a URL in the user's browser. Main allowlists the protocol. */
86
+ openExternal: (url: string) => ipcRenderer.send('open-external', url),
87
+
88
+ /** OS path of a dropped File. A sandboxed renderer has no `File.path`, so
89
+ * drag-and-drop of datasets needs this. */
90
+ pathForFile: (file: File): string | null => {
91
+ try {
92
+ return webUtils.getPathForFile(file) || null
93
+ } catch {
94
+ return null
95
+ }
96
+ },
97
+
98
+ /** Native open dialog. Resolves to a path, or null if cancelled.
99
+ *
100
+ * `invoke`, not `send`: the caller needs the answer, and threading a
101
+ * reply back through the message channel would mean correlating requests
102
+ * by hand. Main owns the dialog because a sandboxed renderer cannot make
103
+ * one, and because main is where the parent window is. */
104
+ openFile: (filters?: FileFilter[]): Promise<string | null> =>
105
+ ipcRenderer.invoke(channel('open-file'), filters),
106
+
107
+ /** Native directory picker. Resolves to a path, or null if cancelled. */
108
+ openDirectory: (): Promise<string | null> =>
109
+ ipcRenderer.invoke(channel('open-directory')),
110
+
111
+ /** Native save dialog. Resolves to a path, or null if cancelled. */
112
+ saveFile: (filters?: FileFilter[], defaultPath?: string): Promise<string | null> =>
113
+ ipcRenderer.invoke(channel('save-file'), filters, defaultPath),
114
+
115
+ /** Escape hatch for an app's own channels, so it does not have to
116
+ * re-implement the disposer discipline above. */
117
+ onChannel: on,
118
+ }
119
+ }
120
+
121
+ /** Build the core surface and expose it as `window[appId]`, merged with
122
+ * `extra`. The common case; use `createShellBridge` directly if the app needs
123
+ * to name the global something else. */
124
+ export function exposeShellBridge(
125
+ opts: ShellBridgeOptions, extra: Record<string, unknown> = {},
126
+ ): void {
127
+ contextBridge.exposeInMainWorld(opts.appId, {
128
+ ...createShellBridge(opts), ...extra,
129
+ })
130
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * FigureFrame.tsx — an anyplotlib figure in an iframe, wired to the bridge.
3
+ *
4
+ * Handles the three things every host of a figure has to get right, and which
5
+ * are easy to omit without any error appearing:
6
+ *
7
+ * * **Register with the bridge**, so state can be routed to this frame — and
8
+ * deregister on unmount, so a dead element is not held.
9
+ * * **Replay on load**, passing THIS element. State that arrived before the
10
+ * frame mounted was posted into the void; replay is the only thing that
11
+ * recovers it, and a figure mounted twice must serve itself rather than
12
+ * whichever mount happens to hold the registry slot.
13
+ * * **Report its size**, so the backend can lay the figure out to fit. Without
14
+ * it the figure renders at anyplotlib's default size and overflows its pane.
15
+ *
16
+ * `srcdoc` vs `src`: a backend that inlines the figure's ESM can be mounted
17
+ * directly from `html`. One that swaps the bundle for a shared URL (SpyDE does,
18
+ * so Chromium reuses the V8 code cache across many figure iframes) MUST be
19
+ * served over a real origin and passes `fileUrl` instead — a srcdoc frame
20
+ * cannot load it. Note that a srcdoc frame INHERITS the parent page's CSP, so
21
+ * the host page needs `script-src … blob:` for anyplotlib's ESM boot.
22
+ */
23
+ import React, { useEffect, useRef } from 'react'
24
+ import type { FigureBridge } from './figureBridge'
25
+
26
+ export interface FigureFrameProps {
27
+ bridge: FigureBridge
28
+ figId: string
29
+ /** Inline figure HTML (mounted via srcdoc). Ignored when `fileUrl` is set. */
30
+ html?: string
31
+ /** Figure URL served over the app's own scheme. Takes precedence over `html`. */
32
+ fileUrl?: string | null
33
+ title?: string
34
+ /** Called with the frame's pixel size whenever it changes. */
35
+ onResize?: (width: number, height: number) => void
36
+ className?: string
37
+ style?: React.CSSProperties
38
+ 'data-testid'?: string
39
+ }
40
+
41
+ export function FigureFrame({
42
+ bridge, figId, html, fileUrl, title, onResize, className, style,
43
+ 'data-testid': testId,
44
+ }: FigureFrameProps) {
45
+ const ref = useRef<HTMLIFrameElement | null>(null)
46
+
47
+ // Report size to the backend. Fires once on mount and on every resize; the
48
+ // zero-size guard skips the frame's first layout pass, which would otherwise
49
+ // tell the backend to lay the figure out at 0×0.
50
+ useEffect(() => {
51
+ const el = ref.current
52
+ if (!el || !onResize) return
53
+ const send = () => {
54
+ const r = el.getBoundingClientRect()
55
+ if (r.width > 0 && r.height > 0) {
56
+ onResize(Math.round(r.width), Math.round(r.height))
57
+ }
58
+ }
59
+ send()
60
+ const ro = new ResizeObserver(send)
61
+ ro.observe(el)
62
+ return () => ro.disconnect()
63
+ }, [figId, onResize])
64
+
65
+ // Deregister on unmount so the bridge never holds a detached element.
66
+ useEffect(() => () => bridge.registerIframe(figId, null), [bridge, figId])
67
+
68
+ return (
69
+ <iframe
70
+ ref={(el) => { ref.current = el; bridge.registerIframe(figId, el) }}
71
+ // Keyed by figId so React REUSES the element across repaints. Remounting
72
+ // per frame would tear down the figure's WebGPU context and throw away
73
+ // the user's zoom.
74
+ key={figId}
75
+ title={title ?? figId}
76
+ {...(fileUrl ? { src: fileUrl } : { srcDoc: html })}
77
+ className={className}
78
+ // `display: block` FIRST, so a caller's style can still override it. An
79
+ // iframe is inline by default, which reserves descender space under it:
80
+ // a host sized to `height: 100%` then overflows by ~4-5 px and grows a
81
+ // scrollbar around a figure that looks correctly sized.
82
+ style={{ display: 'block', ...style }}
83
+ data-testid={testId}
84
+ // ITSELF, never whichever mount currently holds the registry slot.
85
+ onLoad={() => bridge.replay(figId, ref.current ?? undefined)}
86
+ />
87
+ )
88
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * figureBridge.react.ts — the React binding for the figure bridge.
3
+ *
4
+ * Split from `figureBridge.ts` so the bridge itself stays plain TypeScript:
5
+ * it is stateful but not reactive, and keeping it free of React means it can be
6
+ * unit-tested without a renderer.
7
+ */
8
+ import { useEffect, useRef } from 'react'
9
+ import { createFigureBridge, type FigureBridge } from './figureBridge'
10
+
11
+ export { createFigureBridge }
12
+ export type { FigureBridge }
13
+
14
+ /**
15
+ * One bridge per component tree, with a STABLE identity for the life of the
16
+ * component.
17
+ *
18
+ * The stability matters: the bridge holds every figure's retained state, so a
19
+ * bridge rebuilt on re-render would drop it, and any figure that had already
20
+ * painted would go blank the next time its iframe reloaded. It is also
21
+ * depended on by effects — a changing identity would re-run them every render.
22
+ */
23
+ export function useFigureBridge(
24
+ log?: (label: string, detail: Record<string, unknown>) => void,
25
+ ): FigureBridge {
26
+ const ref = useRef<FigureBridge | null>(null)
27
+ if (ref.current === null) ref.current = createFigureBridge(log)
28
+ return ref.current
29
+ }
30
+
31
+
32
+ /**
33
+ * Forward anyplotlib interaction events from the figure iframes to the backend.
34
+ *
35
+ * An anyplotlib figure posts `{type: 'awi_event', figId, data}` up to its host
36
+ * window when the user clicks, drags or presses a key inside it. Nothing
37
+ * happens to that message unless someone listens and relays it — so WITHOUT
38
+ * this hook every `plot.add_event_handler(...)` registered in Python is silently
39
+ * dead, which is exactly how it presented: arming a measurement worked, and the
40
+ * drag that followed reached nothing.
41
+ *
42
+ * Call once, high in the app. `send` is normally the preload's `figureEvent`.
43
+ */
44
+ export function useFigureEventForwarding(
45
+ send: (figId: string, eventJson: string) => void,
46
+ ): void {
47
+ useEffect(() => {
48
+ const onMessage = (e: MessageEvent) => {
49
+ const d = e.data
50
+ if (d?.type !== 'awi_event' || !d.figId) return
51
+ // `data` is already a JSON STRING on the wire; stringify an object form
52
+ // rather than sending "[object Object]" down the protocol.
53
+ send(d.figId, typeof d.data === 'string' ? d.data : JSON.stringify(d.data))
54
+ }
55
+ window.addEventListener('message', onMessage)
56
+ return () => window.removeEventListener('message', onMessage)
57
+ }, [send])
58
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * figureBridge.test.ts — the retention rules, pinned.
3
+ *
4
+ * Every case here corresponds to a bug that shipped: a blank presented slide, a
5
+ * multi-panel figure with one panel drawn, a stash emptied by its own replay.
6
+ * They are cheap to assert and expensive to rediscover.
7
+ *
8
+ * Run by `npm run test:unit` (node:test, native TS type-stripping).
9
+ */
10
+ import { test, describe } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+
13
+ import { createFigureBridge } from './figureBridge.ts'
14
+
15
+ /** A stand-in iframe that records what was posted into it. */
16
+ function fakeIframe(testid = 'frame') {
17
+ const posted: Array<{ message: any; transfer: any }> = []
18
+ return {
19
+ posted,
20
+ el: {
21
+ contentWindow: {
22
+ postMessage(message: any, _origin: string, transfer?: any) {
23
+ posted.push({ message, transfer })
24
+ },
25
+ },
26
+ getAttribute: () => testid,
27
+ getBoundingClientRect: () => ({ width: 10, height: 10 }),
28
+ } as unknown as HTMLIFrameElement,
29
+ }
30
+ }
31
+
32
+ describe('state forwarding and retention', () => {
33
+ test('a state posted before any iframe mounts is retained and replayed', () => {
34
+ const bridge = createFigureBridge()
35
+ // Nothing registered yet — this post goes nowhere, silently. The retention
36
+ // is the only thing that saves it.
37
+ bridge.applyState('f1', 'title', 'hello')
38
+
39
+ const frame = fakeIframe()
40
+ bridge.registerIframe('f1', frame.el)
41
+ bridge.replay('f1')
42
+
43
+ assert.deepEqual(frame.posted.map(p => p.message),
44
+ [{ type: 'awi_state', key: 'title', value: 'hello' }])
45
+ })
46
+
47
+ test('only the latest value per key is retained', () => {
48
+ const bridge = createFigureBridge()
49
+ bridge.applyState('f1', 'k', 1)
50
+ bridge.applyState('f1', 'k', 2)
51
+ const frame = fakeIframe()
52
+ bridge.registerIframe('f1', frame.el)
53
+ bridge.replay('f1')
54
+ assert.deepEqual(frame.posted.map(p => p.message.value), [2])
55
+ })
56
+
57
+ test('state is posted live to a mounted frame', () => {
58
+ const bridge = createFigureBridge()
59
+ const frame = fakeIframe()
60
+ bridge.registerIframe('f1', frame.el)
61
+ bridge.applyState('f1', 'k', 'v')
62
+ assert.equal(frame.posted.length, 1)
63
+ })
64
+ })
65
+
66
+ describe('binary frames', () => {
67
+ test('panels are stashed separately, keyed by geom', () => {
68
+ // The multi-panel bug: `key` is the pixel FIELD and is identical across
69
+ // panels, so stashing by key alone left one frame however many panels the
70
+ // figure had — and a presented copy drew one panel and blanks.
71
+ const bridge = createFigureBridge()
72
+ bridge.applyBinary('f1', 'image_b64', { geom: 'panel_a' }, new Uint8Array([1]))
73
+ bridge.applyBinary('f1', 'image_b64', { geom: 'panel_b' }, new Uint8Array([2]))
74
+
75
+ const slot = bridge.binaryStates.current.get('f1')!
76
+ assert.deepEqual([...slot.keys()].sort(),
77
+ ['panel_a::image_b64', 'panel_b::image_b64'])
78
+ })
79
+
80
+ test('a frame with no geom falls back to the pixel key', () => {
81
+ const bridge = createFigureBridge()
82
+ bridge.applyBinary('f1', 'image_b64', {}, new Uint8Array([1]))
83
+ assert.deepEqual([...bridge.binaryStates.current.get('f1')!.keys()], ['image_b64'])
84
+ })
85
+
86
+ test('the live post transfers the buffer', () => {
87
+ const bridge = createFigureBridge()
88
+ const frame = fakeIframe()
89
+ bridge.registerIframe('f1', frame.el)
90
+ const bytes = new Uint8Array([1, 2, 3])
91
+ bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, bytes)
92
+ assert.deepEqual(frame.posted[0].transfer, [bytes.buffer])
93
+ })
94
+
95
+ test('the stash survives its own replay, and every replay sends a fresh copy', () => {
96
+ // Transfer DETACHES the buffer it sends. Replaying the stashed array itself
97
+ // would empty the stash on first use, so the second mount of a figure — the
98
+ // presented copy — would get nothing.
99
+ const bridge = createFigureBridge()
100
+ bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, new Uint8Array([7, 8]))
101
+
102
+ const first = fakeIframe('first')
103
+ bridge.registerIframe('f1', first.el)
104
+ bridge.replay('f1')
105
+
106
+ const second = fakeIframe('second')
107
+ bridge.replay('f1', second.el)
108
+
109
+ for (const frame of [first, second]) {
110
+ const msg = frame.posted.at(-1)!.message
111
+ assert.equal(msg.type, 'awi_state_binary')
112
+ assert.deepEqual([...msg.buffer], [7, 8], 'replayed into a detached buffer')
113
+ }
114
+ })
115
+
116
+ test('replay serves the TARGET, not whichever mount holds the registry slot', () => {
117
+ // The blank-presented-deck bug. Two mounts share a figId; the map holds one.
118
+ // A freshly-loaded frame passes itself and must be the one served.
119
+ const bridge = createFigureBridge()
120
+ bridge.applyState('f1', 'k', 'v')
121
+
122
+ const winner = fakeIframe('winner')
123
+ const loader = fakeIframe('loader')
124
+ bridge.registerIframe('f1', winner.el) // last registration wins the map
125
+ bridge.replay('f1', loader.el) // ...but THIS frame just loaded
126
+
127
+ assert.equal(loader.posted.length, 1)
128
+ assert.equal(winner.posted.length, 0, 'state went to the sibling mount')
129
+ })
130
+ })
131
+
132
+ describe('registry and eviction', () => {
133
+ test('registering null deregisters', () => {
134
+ const bridge = createFigureBridge()
135
+ const frame = fakeIframe()
136
+ bridge.registerIframe('f1', frame.el)
137
+ bridge.registerIframe('f1', null)
138
+ assert.equal(bridge.iframes.current.has('f1'), false)
139
+ })
140
+
141
+ test('evict drops every trace of a figure', () => {
142
+ // Without this, a long report-editing session grows the maps forever: each
143
+ // re-render of a cell mints a new figId and the old one's pixels stay.
144
+ const bridge = createFigureBridge()
145
+ const frame = fakeIframe()
146
+ bridge.registerIframe('f1', frame.el)
147
+ bridge.applyState('f1', 'k', 'v')
148
+ bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, new Uint8Array([1]))
149
+
150
+ bridge.evict('f1')
151
+
152
+ assert.equal(bridge.states.current.has('f1'), false)
153
+ assert.equal(bridge.binaryStates.current.has('f1'), false)
154
+ assert.equal(bridge.iframes.current.has('f1'), false)
155
+ })
156
+
157
+ test('replaying an unmounted figure is a no-op, not a throw', () => {
158
+ const bridge = createFigureBridge()
159
+ bridge.applyState('f1', 'k', 'v')
160
+ bridge.replay('f1')
161
+ })
162
+
163
+ test('dump reports retained counts per figure', () => {
164
+ const bridge = createFigureBridge()
165
+ const frame = fakeIframe()
166
+ bridge.registerIframe('f1', frame.el)
167
+ bridge.applyState('f1', 'k', 'v')
168
+ bridge.applyBinary('f1', 'image_b64', { geom: 'g' }, new Uint8Array([1]))
169
+
170
+ const row = bridge.dump().find(r => r.figId === 'f1')!
171
+ assert.equal(row.jsonKeys, 1)
172
+ assert.equal(row.binaryKeys, 1)
173
+ assert.equal(row.binaryKeyNames, 'g::image_b64')
174
+ })
175
+
176
+ test('dump includes figures with retained state but no mounted frame', () => {
177
+ // The diagnostic's whole job: telling "never arrived" from "arrived, not
178
+ // mounted". A figure missing from the dump would be indistinguishable.
179
+ const bridge = createFigureBridge()
180
+ bridge.applyState('ghost', 'k', 'v')
181
+ const row = bridge.dump().find(r => r.figId === 'ghost')!
182
+ assert.equal(row.registeredIn, 'NONE')
183
+ })
184
+ })