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,310 @@
1
+ /**
2
+ * shellState.ts — the app-chrome state every shell app keeps.
3
+ *
4
+ * Status line, busy indicator, the log ring, first-run environment setup, the
5
+ * "backend died" latch, per-window computing overlays, and toolbar action
6
+ * state. None of it knows what the data is; all of it was written twice (SpyDE
7
+ * in a reducer, Ground Crew in ad-hoc `useState`) before landing here.
8
+ *
9
+ * **The window/figure registry is deliberately NOT here.** SpyDE's is entangled
10
+ * with its named-view/chip system (`view`, `view_label`, strain components) and
11
+ * Ground Crew has no window registry at all — one fixed pane. Extracting it now
12
+ * would be generalising from a single consumer, so it stays in SpyDE until a
13
+ * second app actually needs it.
14
+ *
15
+ * ## Composing
16
+ *
17
+ * An app's state EXTENDS `ShellState` and its reducer delegates:
18
+ *
19
+ * function appReducer(state: AppState, action: AppAction): AppState {
20
+ * switch (action.type) {
21
+ * case 'MY_THING': return …
22
+ * default: return shellReducer(state, action as ShellAction)
23
+ * }
24
+ * }
25
+ *
26
+ * `shellReducer` is generic over the state type and returns it unchanged for an
27
+ * action it does not own, so the delegation is safe in either order.
28
+ */
29
+
30
+ /**
31
+ * One application-log record streamed from the backend.
32
+ *
33
+ * `seq` is a renderer-assigned monotonic id, stamped once as the record enters
34
+ * the buffer. It is the STABLE React key + height-cache key for a virtualised
35
+ * log list: the buffer is a ring (old records drop off the front), so an array
36
+ * INDEX identifies a different record after every shift, which would defeat
37
+ * both row memoisation and a measured-height cache.
38
+ *
39
+ * `area` is the subsystem tag the app registered (see de_shell.log_stream), and
40
+ * is optional because a record can predate registration.
41
+ */
42
+ export interface LogEntry {
43
+ level: string
44
+ name: string
45
+ area?: string
46
+ msg: string
47
+ time: number
48
+ seq?: number
49
+ }
50
+
51
+ export interface SubItem {
52
+ name: string
53
+ color: string
54
+ vtype?: string
55
+ calculation?: string
56
+ }
57
+
58
+ export type EnvPhase =
59
+ | 'resolving' | 'downloading' | 'installing' | 'building' | 'torch' | 'working'
60
+
61
+ export interface EnvSetupState {
62
+ phase: EnvPhase
63
+ /** Friendly current-step headline. */
64
+ step: string
65
+ /** 0–100 for a download we can measure, else null. */
66
+ percent: number | null
67
+ /** Rolling raw output tail (bounded). */
68
+ lines: string[]
69
+ }
70
+
71
+ export interface ShellState {
72
+ status: string
73
+ ready: boolean
74
+ /** Long file-read / open busy indicator. */
75
+ loading: { busy: boolean; text: string }
76
+ /** Raw stdout/stderr from the backend (bounded). */
77
+ streamLines: Array<{ text: string; kind: 'stdout' | 'stderr' }>
78
+ /** Application-log records (the log panel). */
79
+ logEntries: LogEntry[]
80
+ /** Current backend verbosity (DEBUG…CRITICAL). */
81
+ logLevel: string
82
+ /** Set when the Python sidecar dies; surfaces a blocking banner. */
83
+ backendExited: { code: number | null; reason?: string } | null
84
+ /** First-run `uv sync` progress; drives the floating setup overlay. */
85
+ envSetup: EnvSetupState | null
86
+ /** windowIds with a long compute in flight (→ floating overlay). */
87
+ computingWindows: Set<number>
88
+ /** windowId → action names with live output. */
89
+ activeActions: Map<number, Set<string>>
90
+ /** windowId → action → dynamic chips. */
91
+ subItems: Map<number, Map<string, SubItem[]>>
92
+ }
93
+
94
+ /** Max buffered log records (the renderer-side ring buffer). */
95
+ export const LOG_MAX = 1000
96
+
97
+ /** Matching bound for raw stdout/stderr, which is noisier and less useful. */
98
+ export const STREAM_MAX = 500
99
+
100
+ export const shellInitialState: ShellState = {
101
+ status: 'Starting…',
102
+ ready: false,
103
+ loading: { busy: false, text: '' },
104
+ streamLines: [],
105
+ logEntries: [],
106
+ logLevel: 'INFO',
107
+ backendExited: null,
108
+ envSetup: null,
109
+ computingWindows: new Set(),
110
+ activeActions: new Map(),
111
+ subItems: new Map(),
112
+ }
113
+
114
+ export type ShellAction =
115
+ | { type: 'STATUS'; text: string }
116
+ | { type: 'LOADING'; busy: boolean; text: string }
117
+ | { type: 'STREAM'; text: string; kind: 'stdout' | 'stderr' }
118
+ | { type: 'LOG'; entries: LogEntry[] }
119
+ | { type: 'LOG_BACKFILL'; entries: LogEntry[] }
120
+ | { type: 'LOG_CLEAR' }
121
+ | { type: 'LOG_LEVEL'; level: string }
122
+ | { type: 'BACKEND_EXITED'; code: number | null; reason?: string }
123
+ | { type: 'ENV_SETUP_START' }
124
+ | { type: 'ENV_SETUP_PROGRESS'; phase?: EnvPhase; step?: string; percent: number | null; raw: string }
125
+ | { type: 'ENV_SETUP_DONE' }
126
+ | { type: 'WINDOW_COMPUTING'; windowId: number; computing: boolean }
127
+ | { type: 'ACTION_ACTIVE'; windowId: number; name: string; active: boolean }
128
+ | { type: 'SUB_ITEM'; windowId: number; action: string; name: string; color: string; vtype?: string; calculation?: string; active: boolean }
129
+
130
+ /**
131
+ * Reduce the shell's slice. Generic over the app's state so it composes as a
132
+ * `default:` branch; returns *state* untouched for anything it does not own.
133
+ */
134
+ export function shellReducer<S extends ShellState>(state: S, action: ShellAction): S {
135
+ switch (action.type) {
136
+ case 'STATUS':
137
+ return { ...state, status: action.text }
138
+
139
+ case 'LOADING':
140
+ return { ...state, loading: { busy: action.busy, text: action.text } }
141
+
142
+ case 'STREAM':
143
+ return {
144
+ ...state,
145
+ streamLines: [...state.streamLines.slice(-STREAM_MAX),
146
+ { text: action.text, kind: action.kind }],
147
+ }
148
+
149
+ // A BATCH of records (hosts coalesce per animation frame) — one array copy
150
+ // and one render for a whole burst, instead of one per line.
151
+ case 'LOG': {
152
+ if (action.entries.length === 0) return state
153
+ const merged = state.logEntries.concat(action.entries)
154
+ return {
155
+ ...state,
156
+ logEntries: merged.length > LOG_MAX ? merged.slice(-LOG_MAX) : merged,
157
+ }
158
+ }
159
+
160
+ case 'LOG_BACKFILL':
161
+ return { ...state, logEntries: action.entries.slice(-LOG_MAX) }
162
+
163
+ // Empty the panel. A renderer-side clear ONLY — the backend's own buffer is
164
+ // untouched, so a later `log_backfill` legitimately brings the history
165
+ // back. Belongs here rather than in an app: the buffer it empties is this
166
+ // reducer's, and an app-level high-water mark cannot survive the ring
167
+ // dropping records off the front (the records carry no id of their own
168
+ // until a host stamps `seq`).
169
+ case 'LOG_CLEAR':
170
+ return state.logEntries.length === 0 ? state : { ...state, logEntries: [] }
171
+
172
+ case 'LOG_LEVEL':
173
+ return { ...state, logLevel: action.level }
174
+
175
+ case 'BACKEND_EXITED':
176
+ return {
177
+ ...state,
178
+ backendExited: { code: action.code, reason: action.reason },
179
+ // A setup failure surfaces via BACKEND_EXITED — drop the setup overlay
180
+ // so the two don't stack.
181
+ envSetup: null,
182
+ ready: false,
183
+ status: 'Backend stopped',
184
+ }
185
+
186
+ case 'ENV_SETUP_START':
187
+ return {
188
+ ...state,
189
+ envSetup: {
190
+ phase: 'resolving',
191
+ step: 'Preparing the analysis environment',
192
+ percent: null,
193
+ lines: [],
194
+ },
195
+ status: 'Setting up the analysis environment…',
196
+ }
197
+
198
+ case 'ENV_SETUP_PROGRESS': {
199
+ const prev = state.envSetup ?? {
200
+ phase: 'resolving' as EnvPhase,
201
+ step: 'Preparing the analysis environment',
202
+ percent: null,
203
+ lines: [],
204
+ }
205
+ // Keep the last meaningful step/phase when a noisy line parses to nothing;
206
+ // always append the raw line to the bounded tail so it visibly moves.
207
+ const lines = [...prev.lines, action.raw].slice(-200)
208
+ return {
209
+ ...state,
210
+ envSetup: {
211
+ phase: action.phase ?? prev.phase,
212
+ step: action.step ?? prev.step,
213
+ percent: action.percent,
214
+ lines,
215
+ },
216
+ }
217
+ }
218
+
219
+ case 'ENV_SETUP_DONE':
220
+ return { ...state, envSetup: null }
221
+
222
+ case 'WINDOW_COMPUTING': {
223
+ const has = state.computingWindows.has(action.windowId)
224
+ if (action.computing === has) return state // no-op re-emit
225
+ const computingWindows = new Set(state.computingWindows)
226
+ if (action.computing) computingWindows.add(action.windowId)
227
+ else computingWindows.delete(action.windowId)
228
+ return { ...state, computingWindows }
229
+ }
230
+
231
+ case 'ACTION_ACTIVE': {
232
+ const activeActions = new Map(state.activeActions)
233
+ const set = new Set(activeActions.get(action.windowId) ?? [])
234
+ if (action.active) set.add(action.name)
235
+ else set.delete(action.name)
236
+ activeActions.set(action.windowId, set)
237
+ return { ...state, activeActions }
238
+ }
239
+
240
+ case 'SUB_ITEM': {
241
+ const subItems = new Map(state.subItems)
242
+ const byAction = new Map(subItems.get(action.windowId) ?? new Map<string, SubItem[]>())
243
+ const list = (byAction.get(action.action) ?? []).filter(i => i.name !== action.name)
244
+ if (action.active) list.push({
245
+ name: action.name, color: action.color,
246
+ vtype: action.vtype, calculation: action.calculation,
247
+ })
248
+ byAction.set(action.action, list)
249
+ subItems.set(action.windowId, byAction)
250
+ return { ...state, subItems }
251
+ }
252
+
253
+ default:
254
+ return state
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Map a backend message to a shell action, or null if the shell does not own it.
260
+ *
261
+ * Lets an app route the chrome messages without restating them:
262
+ *
263
+ * const shellAction = toShellAction(msg)
264
+ * if (shellAction) { dispatch(shellAction); return }
265
+ *
266
+ * `log` is deliberately NOT handled here. Hosts coalesce log records per frame
267
+ * before dispatching a batched `LOG` — mapping one message to one action would
268
+ * undo that and re-render per line.
269
+ */
270
+ export function toShellAction(msg: Record<string, unknown>): ShellAction | null {
271
+ switch (msg.type) {
272
+ case 'status':
273
+ return { type: 'STATUS', text: String(msg.text ?? '') }
274
+ case 'loading':
275
+ return { type: 'LOADING', busy: Boolean(msg.busy), text: String(msg.text ?? '') }
276
+ case 'stream':
277
+ return {
278
+ type: 'STREAM', text: String(msg.text ?? ''),
279
+ kind: msg.kind === 'stderr' ? 'stderr' : 'stdout',
280
+ }
281
+ case 'log_backfill':
282
+ return { type: 'LOG_BACKFILL', entries: (msg.entries ?? []) as LogEntry[] }
283
+ case 'log_level':
284
+ return { type: 'LOG_LEVEL', level: String(msg.level ?? 'INFO') }
285
+ case 'backend_exited':
286
+ return {
287
+ type: 'BACKEND_EXITED',
288
+ code: (msg.code ?? null) as number | null,
289
+ reason: msg.reason as string | undefined,
290
+ }
291
+ case 'window_computing':
292
+ return {
293
+ type: 'WINDOW_COMPUTING',
294
+ windowId: Number(msg.window_id),
295
+ computing: Boolean(msg.computing),
296
+ }
297
+ case 'env_setup':
298
+ if (msg.event === 'start') return { type: 'ENV_SETUP_START' }
299
+ if (msg.event === 'done') return { type: 'ENV_SETUP_DONE' }
300
+ return {
301
+ type: 'ENV_SETUP_PROGRESS',
302
+ phase: msg.phase as EnvPhase | undefined,
303
+ step: msg.step as string | undefined,
304
+ percent: (msg.percent ?? null) as number | null,
305
+ raw: String(msg.raw ?? ''),
306
+ }
307
+ default:
308
+ return null
309
+ }
310
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * harness.cjs — launching a shell app under Playwright, for any of the three.
3
+ *
4
+ * Generalised from SpyDE's electron/tests/_harness.cjs. Everything here is
5
+ * app-agnostic; domain helpers (SpyDE's loadTestVectors, navWindow,
6
+ * dragCrosshair) stay in the app's own test directory.
7
+ *
8
+ * The two things this exists to get right, both learned the hard way in SpyDE:
9
+ *
10
+ * 1. **Profile isolation.** Electron defaults to a per-app profile directory
11
+ * that a developer's `npm run dev` instance may already hold a Chromium
12
+ * singleton lock on. A test launched against it does not fail with a clear
13
+ * error — it HANGS, and Playwright reports a bare launch timeout with no
14
+ * hint that another process owns the directory. Every launch gets a fresh
15
+ * temp profile.
16
+ *
17
+ * 2. **Backend errors are invisible by default.** The Python side talks over
18
+ * the PLOTAPP stdout protocol, which the Electron main process consumes —
19
+ * so a backend that dies mid-test dies SILENTLY. The app's log-level env
20
+ * var makes it tee logging to stderr, which this captures into `logBuffer`.
21
+ */
22
+ // The APP's Playwright, never one found beside this file. This package reaches
23
+ // an app through a symlink into a checkout that may carry its own node_modules
24
+ // (for the shell's typecheck), and Playwright refuses to be loaded twice
25
+ // ("Requiring @playwright/test second time"). Resolving from the working
26
+ // directory — the app's, when its `playwright test` runs — picks the copy the
27
+ // runner already loaded; the fallback is for a bare `node --test` here.
28
+ function appRequire(name) {
29
+ try { return require(require.resolve(name, { paths: [process.cwd()] })) } catch { return require(name) }
30
+ }
31
+ const { _electron: electron } = appRequire('@playwright/test')
32
+ const { spawnSync } = require('child_process')
33
+ const { mkdtempSync } = require('fs')
34
+ const { join } = require('path')
35
+ const { tmpdir } = require('os')
36
+
37
+ /**
38
+ * Launch a shell app.
39
+ *
40
+ * @param {object} opts
41
+ * @param {string} opts.appDir App root (the directory holding out/main/index.js).
42
+ * @param {string} opts.appId Shell appId — namespaces the temp profile dir.
43
+ * @param {string} [opts.readyLog] Stderr/stdout substring meaning "backend up".
44
+ * @param {string[]} [opts.readyMessages] PLOTAPP message types to wait for.
45
+ * @param {object} [opts.env] Extra environment for the app process.
46
+ * @param {number} [opts.timeout] Milliseconds to wait for the ready signals.
47
+ */
48
+ async function launchApp(opts) {
49
+ const {
50
+ appDir, appId, readyLog = null, readyMessages = ['ready'],
51
+ env = {}, timeout = 60_000,
52
+ } = opts
53
+ if (!appDir || !appId) throw new Error('launchApp needs { appDir, appId }')
54
+
55
+ const app = await electron.launch({
56
+ // Resolve Electron from the APP's tree, not Playwright's. Without an
57
+ // explicit path, playwright-core does a bare require('electron/index.js')
58
+ // from its own (usually root-hoisted) location — in a workspace layout
59
+ // that can find a DIFFERENT Electron than the one the app declares (an
60
+ // auto-installed peer once put 43 at the root while the app shipped 34,
61
+ // and every e2e silently tested the wrong Chromium). appDir is the
62
+ // directory holding the app's package.json, so its node_modules wins.
63
+ executablePath: require(require.resolve('electron/index.js', { paths: [appDir] })),
64
+ args: [
65
+ join(appDir, 'out', 'main', 'index.js'),
66
+ `--user-data-dir=${mkdtempSync(join(tmpdir(), `${appId}-e2e-profile-`))}`,
67
+ ],
68
+ env: { ...process.env, ...env },
69
+ })
70
+
71
+ const backend = createBackend(app)
72
+ const page = await firstWindowWithLog(app, backend.logBuffer, timeout)
73
+
74
+ // Surface renderer exceptions rather than letting a blank page look like a
75
+ // slow one. Collected, not thrown, so a spec decides what is fatal.
76
+ const jsErrors = []
77
+ page.on('pageerror', (e) => jsErrors.push(String(e)))
78
+
79
+ if (readyLog) await backend.waitForLog(readyLog, timeout)
80
+ for (const type of readyMessages) await backend.waitForMessage(type, timeout)
81
+
82
+ return {
83
+ app, page, backend, jsErrors,
84
+ assertNoJsErrors: () => assertNoJsErrors(jsErrors),
85
+ close: (opts) => closeApp(app, opts),
86
+ }
87
+ }
88
+
89
+ /**
90
+ * `app.firstWindow()` with an EXPLICIT timeout and the backend log attached to
91
+ * the failure. Playwright's bare "firstWindow timeout" hides the actual cause,
92
+ * which is almost always in the app's stderr (uv missing, env sync failed,
93
+ * Python died on import) — the harness buffered it; say it.
94
+ */
95
+ async function firstWindowWithLog(app, logBuffer, timeout = 60_000) {
96
+ try {
97
+ return await app.firstWindow({ timeout })
98
+ } catch (e) {
99
+ throw new Error(
100
+ `no app window appeared within ${timeout}ms (${e.message}) — ` +
101
+ 'the main process likely failed before creating it.\n' +
102
+ `last 40 log lines:\n${logBuffer.slice(-40).join('\n')}`)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Close the app with a DEADLINE, then hard-kill the whole process tree if the
108
+ * close wedged. A backend stuck mid-compute can hold `app.close()` up past the
109
+ * runner's timeout, leaving an Electron tree alive that (for a DE app) still
110
+ * owns the server's single connection — every later launch then hangs on a
111
+ * connection that cannot be made. Returns 'closed' | 'killed' | 'noop' so a
112
+ * caller can log what teardown actually did.
113
+ */
114
+ async function closeApp(app, opts = {}) {
115
+ const { timeout = 15_000, killTree = hardKillTree } = opts
116
+ if (!app) return 'noop'
117
+ const pid = app.process()?.pid
118
+ const closed = await new Promise((resolve) => {
119
+ const timer = setTimeout(() => resolve(false), timeout)
120
+ timer.unref?.()
121
+ Promise.resolve()
122
+ .then(() => app.close())
123
+ .then(() => { clearTimeout(timer); resolve(true) },
124
+ () => { clearTimeout(timer); resolve(false) })
125
+ })
126
+ if (closed) return 'closed'
127
+ if (pid) killTree(pid)
128
+ return pid ? 'killed' : 'noop'
129
+ }
130
+
131
+ /** Force-kill `pid` AND everything under it (the Python sidecar and its
132
+ * workers ride below the Electron root). */
133
+ function hardKillTree(pid) {
134
+ try {
135
+ if (process.platform === 'win32') {
136
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
137
+ } else {
138
+ process.kill(pid, 'SIGKILL')
139
+ }
140
+ } catch { /* already gone */ }
141
+ }
142
+
143
+ /** Buffer the app process's stdio and expose waiters over it. */
144
+ function createBackend(app) {
145
+ const logBuffer = []
146
+ const waiters = []
147
+
148
+ const push = (text) => {
149
+ for (const line of String(text).split('\n')) {
150
+ if (!line.trim()) continue
151
+ logBuffer.push(line)
152
+ for (const w of waiters.slice()) {
153
+ if (w.test(line)) {
154
+ waiters.splice(waiters.indexOf(w), 1)
155
+ w.resolve(line)
156
+ }
157
+ }
158
+ }
159
+ }
160
+
161
+ app.process().stdout?.on('data', (d) => push(d.toString()))
162
+ app.process().stderr?.on('data', (d) => push(d.toString()))
163
+
164
+ /** Resolve when a line contains `substr` — including lines already buffered,
165
+ * so a caller that starts waiting late does not miss it. */
166
+ function waitForLog(substr, timeout = 60_000) {
167
+ const hit = logBuffer.find((l) => l.includes(substr))
168
+ if (hit) return Promise.resolve(hit)
169
+ return new Promise((resolve, reject) => {
170
+ const w = { test: (l) => l.includes(substr), resolve }
171
+ waiters.push(w)
172
+ setTimeout(() => {
173
+ const i = waiters.indexOf(w)
174
+ if (i >= 0) {
175
+ waiters.splice(i, 1)
176
+ reject(new Error(
177
+ `timed out waiting for log ${JSON.stringify(substr)}\n` +
178
+ `last 40 lines:\n${logBuffer.slice(-40).join('\n')}`))
179
+ }
180
+ }, timeout)
181
+ })
182
+ }
183
+
184
+ /** Resolve when a PLOTAPP message of `type` is emitted. */
185
+ function waitForMessage(type, timeout = 60_000) {
186
+ return waitForLog(`"type": "${type}"`, timeout).catch(() =>
187
+ waitForLog(`"type":"${type}"`, timeout))
188
+ }
189
+
190
+ return { logBuffer, waitForLog, waitForMessage, errorLines: () => errorLines(logBuffer) }
191
+ }
192
+
193
+ /** Lines that look like a Python failure. Used to fail a spec loudly rather than
194
+ * letting a broken backend read as a slow one. */
195
+ function errorLines(logBuffer) {
196
+ return logBuffer.filter((l) =>
197
+ /Traceback|ModuleNotFoundError|ImportError|Fatal Python error|ERROR/.test(l))
198
+ }
199
+
200
+ function assertNoJsErrors(jsErrors) {
201
+ if (jsErrors.length) {
202
+ throw new Error('renderer JS errors:\n' + jsErrors.join('\n'))
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Count pixels in every <canvas> on the page (figures live in iframes, so this
208
+ * walks all frames).
209
+ *
210
+ * `kind` is 'bright' | 'red' | 'green'. Anything else counts NOTHING and would
211
+ * make an assertion pass vacuously — so unknown kinds throw instead.
212
+ */
213
+ async function countColorPixels(page, kind) {
214
+ const KINDS = ['bright', 'red', 'green']
215
+ if (!KINDS.includes(kind)) {
216
+ throw new Error(`countColorPixels: unknown kind ${JSON.stringify(kind)} (use ${KINDS.join('|')})`)
217
+ }
218
+ let total = 0
219
+ for (const frame of page.frames()) {
220
+ try {
221
+ total += await frame.evaluate((k) => {
222
+ let n = 0
223
+ for (const c of Array.from(document.querySelectorAll('canvas'))) {
224
+ const ctx = c.getContext('2d')
225
+ if (!ctx || !c.width || !c.height) continue
226
+ const d = ctx.getImageData(0, 0, c.width, c.height).data
227
+ for (let p = 0; p < d.length; p += 4) {
228
+ const r = d[p], g = d[p + 1], b = d[p + 2]
229
+ if (k === 'bright' && (r > 30 || g > 30 || b > 30)) n++
230
+ if (k === 'red' && r > 120 && g < 90 && b < 90) n++
231
+ if (k === 'green' && g > 150 && r < 130 && b > 50 && b < 170) n++
232
+ }
233
+ }
234
+ return n
235
+ }, kind)
236
+ } catch { /* frame detached mid-evaluate */ }
237
+ }
238
+ return total
239
+ }
240
+
241
+ module.exports = {
242
+ launchApp, createBackend, countColorPixels, assertNoJsErrors, errorLines,
243
+ firstWindowWithLog, closeApp, hardKillTree,
244
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * harness.test.cjs — node:test unit tests for the harness's failure paths.
3
+ *
4
+ * Two teardown/startup behaviours learned the hard way:
5
+ *
6
+ * 1. A window that never appears must fail with the BACKEND LOG attached —
7
+ * Playwright's bare "firstWindow timeout" hides the uv/env error that
8
+ * caused it.
9
+ * 2. `app.close()` can wedge (a backend stuck mid-compute holds quit up), and
10
+ * a wedged close leaves the Electron tree alive holding the DE Server's
11
+ * single connection — so teardown needs a deadline and a hard tree-kill.
12
+ *
13
+ * Run: `node --test src/harness.test.cjs` (from packages/shell-testing/), or
14
+ * via the `test:unit` npm script.
15
+ */
16
+ const { test } = require('node:test')
17
+ const assert = require('node:assert/strict')
18
+ const { firstWindowWithLog, closeApp } = require('./harness.cjs')
19
+
20
+ test('a missing first window reports the backend log, not a bare timeout', async () => {
21
+ const app = {
22
+ firstWindow: async () => { throw new Error('Timeout 60000ms exceeded.') },
23
+ }
24
+ const logBuffer = ['[env-setup] running full locked uv sync',
25
+ 'error: uv exploded for reasons']
26
+ await assert.rejects(
27
+ () => firstWindowWithLog(app, logBuffer, 60_000),
28
+ (err) => {
29
+ assert.match(err.message, /window/i)
30
+ assert.match(err.message, /uv exploded for reasons/,
31
+ 'the backend log tail is missing from the failure')
32
+ return true
33
+ },
34
+ )
35
+ })
36
+
37
+ test('a clean close does not kill anything', async () => {
38
+ const killed = []
39
+ const app = {
40
+ process: () => ({ pid: 4242 }),
41
+ close: async () => {},
42
+ }
43
+ const outcome = await closeApp(app, { timeout: 1000, killTree: (pid) => killed.push(pid) })
44
+ assert.equal(outcome, 'closed')
45
+ assert.deepEqual(killed, [])
46
+ })
47
+
48
+ test('a wedged close is hard-killed at the deadline', async () => {
49
+ const killed = []
50
+ const app = {
51
+ process: () => ({ pid: 4242 }),
52
+ close: () => new Promise(() => {}), // never resolves — the wedge
53
+ }
54
+ const outcome = await closeApp(app, { timeout: 100, killTree: (pid) => killed.push(pid) })
55
+ assert.equal(outcome, 'killed')
56
+ assert.deepEqual(killed, [4242])
57
+ })
58
+
59
+ test('a close that rejects still hard-kills the tree', async () => {
60
+ const killed = []
61
+ const app = {
62
+ process: () => ({ pid: 4242 }),
63
+ close: async () => { throw new Error('target closed') },
64
+ }
65
+ const outcome = await closeApp(app, { timeout: 1000, killTree: (pid) => killed.push(pid) })
66
+ assert.equal(outcome, 'killed')
67
+ assert.deepEqual(killed, [4242])
68
+ })
69
+
70
+ test('closeApp tolerates a missing app', async () => {
71
+ assert.equal(await closeApp(null), 'noop')
72
+ assert.equal(await closeApp(undefined), 'noop')
73
+ })