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,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
|
+
})
|