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,100 @@
1
+ /**
2
+ * sentryEnvelope.ts — the wire format for a problem report, with no I/O.
3
+ *
4
+ * Sentry's ingest API is a documented HTTP endpoint, and a report is a small
5
+ * JSON document posted to it. That is the whole integration, so this is written
6
+ * against the protocol rather than against `@sentry/electron`: the SDK's value
7
+ * is automatic crash capture, and reports here are only ever sent because
8
+ * somebody clicked a button. Skipping it keeps a native crash handler out of a
9
+ * hardened-runtime, notarized macOS build and keeps the payload something a
10
+ * maintainer can read.
11
+ *
12
+ * Everything here is pure, so the parts that are easy to get wrong — the DSN
13
+ * split, the auth header, the envelope's length-prefixed framing — are unit
14
+ * tested on every OS without a network.
15
+ *
16
+ * Protocol reference: https://develop.sentry.dev/sdk/envelopes/
17
+ */
18
+
19
+ /** The pieces of a DSN that the ingest request actually needs. */
20
+ export interface SentryTarget {
21
+ /** Full URL to POST an envelope to. */
22
+ endpoint: string
23
+ /** The DSN's public key, for the X-Sentry-Auth header. */
24
+ publicKey: string
25
+ /** The DSN with any secret stripped — envelope headers carry it verbatim. */
26
+ dsn: string
27
+ }
28
+
29
+ /**
30
+ * Split a Sentry DSN into what an ingest POST needs, or null if it isn't one.
31
+ *
32
+ * A DSN looks like `https://<publicKey>@<host>/<projectId>`; older ones carry
33
+ * `<publicKey>:<secret>@`, and the secret is not used by the envelope endpoint.
34
+ * Returning null (rather than throwing) is what lets a build ship with no DSN
35
+ * configured and fall back to writing the report to disk.
36
+ */
37
+ export function parseSentryDsn(dsn: string | undefined | null): SentryTarget | null {
38
+ if (!dsn) return null
39
+ let url: URL
40
+ try {
41
+ url = new URL(dsn.trim())
42
+ } catch {
43
+ return null
44
+ }
45
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
46
+ const publicKey = url.username
47
+ const projectId = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '')
48
+ if (!publicKey || !projectId || !/^\d+$/.test(projectId)) return null
49
+ const base = `${url.protocol}//${url.host}`
50
+ return {
51
+ endpoint: `${base}/api/${projectId}/envelope/`,
52
+ publicKey,
53
+ dsn: `${url.protocol}//${publicKey}@${url.host}/${projectId}`,
54
+ }
55
+ }
56
+
57
+ /** The `X-Sentry-Auth` header value for a target. */
58
+ export function sentryAuthHeader(target: SentryTarget, client: string): string {
59
+ return [
60
+ 'Sentry sentry_version=7',
61
+ `sentry_client=${client}`,
62
+ `sentry_key=${target.publicKey}`,
63
+ ].join(', ')
64
+ }
65
+
66
+ /**
67
+ * A 32-character lowercase hex id, the shape Sentry requires for `event_id`.
68
+ * Takes its randomness from the caller so this file stays pure and testable.
69
+ */
70
+ export function formatEventId(bytes: Uint8Array): string {
71
+ let hex = ''
72
+ for (const b of bytes) hex += b.toString(16).padStart(2, '0')
73
+ return hex.slice(0, 32).padEnd(32, '0')
74
+ }
75
+
76
+ /**
77
+ * Frame an event as a Sentry envelope: newline-delimited JSON, where each item
78
+ * is preceded by a header naming its type and byte length. The length is in
79
+ * BYTES, not characters — a report containing a non-ASCII path or a stack trace
80
+ * with a smart quote would be rejected if this counted characters.
81
+ */
82
+ export function buildEnvelope(
83
+ target: SentryTarget,
84
+ event: Record<string, unknown>,
85
+ sentAt: string,
86
+ ): string {
87
+ const body = JSON.stringify(event)
88
+ const length = Buffer.byteLength(body, 'utf8')
89
+ const envelopeHeader = JSON.stringify({
90
+ event_id: event.event_id,
91
+ sent_at: sentAt,
92
+ dsn: target.dsn,
93
+ })
94
+ const itemHeader = JSON.stringify({
95
+ type: 'event',
96
+ content_type: 'application/json',
97
+ length,
98
+ })
99
+ return `${envelopeHeader}\n${itemHeader}\n${body}\n`
100
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * updater.ts — electron-updater wiring (check / download / install, stable vs
3
+ * beta channel).
4
+ *
5
+ * The GitHub `publish` provider (electron-builder.yml) is what release.yml
6
+ * populates: each tagged release gets latest.yml / latest-mac.yml /
7
+ * latest-linux.yml alongside the installers, which is what autoUpdater reads
8
+ * to detect a new version per platform. Plain vX.Y.Z tags are regular GitHub
9
+ * releases; vX.Y.Z-rc.N/-beta.N tags are marked `prerelease` (release.yml's
10
+ * `channel` job) — `allowPrerelease` is what gates whether autoUpdater will
11
+ * offer those to a given install.
12
+ *
13
+ * CHANNEL DEFAULT FOLLOWS THE RUNNING BUILD. The default channel is derived from
14
+ * THIS build's own version: a prerelease build (…-rc.N) tracks beta, a plain
15
+ * X.Y.Z tracks stable (defaultChannelForVersion in updaterErrors.ts). So an rc
16
+ * install checks the beta feed (where its updates actually are) instead of the
17
+ * stable feed — which, with no stable release published yet, would 404 and error.
18
+ * An explicit user choice (the dialog radio, persisted) always overrides the
19
+ * default. And a "no release for this channel" result is reported as up-to-date,
20
+ * not an error (isNoReleaseForChannel).
21
+ *
22
+ * autoDownload is OFF: check -> tell the renderer -> user clicks "Download" ->
23
+ * we call downloadUpdate() -> "Restart to install" -> quitAndInstall(). This
24
+ * matches the "click here to update" ask (not a silent background install).
25
+ *
26
+ * HARDENING (flaky GitHub can HANG, not just crash): every network step is
27
+ * bounded by a timeout so a half-open connection can't wedge the UI in
28
+ * 'checking'/'downloading' forever (with the "Check Now" button disabled in
29
+ * exactly those states → unrecoverable). A stall detector watches the download
30
+ * for silence. Any timeout/error leaves the updater RECHECKABLE (the in-flight
31
+ * guard clears), raw electron-updater strings are mapped to friendly text, and
32
+ * quitAndInstall() can no longer throw the process down.
33
+ */
34
+ import { app, BrowserWindow } from 'electron'
35
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
36
+ import { join } from 'path'
37
+ import { autoUpdater } from 'electron-updater'
38
+ import { friendlyError, defaultChannelForVersion } from './updaterErrors'
39
+ import { channel } from './config'
40
+ import { stopBackend } from './backendProcess'
41
+ import { recordProblem } from './problemLog'
42
+
43
+ export type UpdateChannel = 'stable' | 'beta'
44
+
45
+ export type UpdateStatus =
46
+ | { state: 'idle' }
47
+ | { state: 'checking' }
48
+ | { state: 'available'; version: string; releaseNotes?: string }
49
+ | { state: 'not-available' }
50
+ | { state: 'downloading'; percent: number }
51
+ | { state: 'downloaded'; version: string }
52
+ | { state: 'error'; message: string }
53
+
54
+ // Bound the two network steps. A flaky/half-open GitHub connection otherwise
55
+ // leaves autoUpdater's promise pending forever (the 'error' event never fires),
56
+ // wedging the UI in 'checking'/'downloading'. Mirrors the PDF_EXPORT_TIMEOUT_MS
57
+ // race idiom in index.ts.
58
+ const CHECK_TIMEOUT_MS = 30_000
59
+ // The download reports periodic 'download-progress' events; if none arrives for
60
+ // this long while 'downloading', the transfer has stalled (a half-open socket
61
+ // mid-stream doesn't reject, it just goes quiet).
62
+ const DOWNLOAD_STALL_MS = 60_000
63
+
64
+ let win: BrowserWindow | null = null
65
+ let channelFilePath = ''
66
+ let userDataDir = ''
67
+ let lastStatus: UpdateStatus = { state: 'idle' }
68
+
69
+ // In-flight guards. `checkInFlight` prevents overlapping checks AND is what a
70
+ // timeout/error must CLEAR so a subsequent checkForUpdates() isn't blocked by a
71
+ // stale belief that a check is still running (the "guaranteed recovery" rule).
72
+ let checkInFlight = false
73
+ let checkTimer: ReturnType<typeof setTimeout> | null = null
74
+ let downloadStallTimer: ReturnType<typeof setTimeout> | null = null
75
+
76
+ function clearCheckTimer(): void {
77
+ if (checkTimer) { clearTimeout(checkTimer); checkTimer = null }
78
+ }
79
+ function clearDownloadStallTimer(): void {
80
+ if (downloadStallTimer) { clearTimeout(downloadStallTimer); downloadStallTimer = null }
81
+ }
82
+
83
+ // friendlyError lives in updaterErrors.ts (pure, dependency-free) so it's unit-
84
+ // testable with node:test on every OS. Imported above; re-exported to keep the
85
+ // existing public API (some callers/tests import it from updater.ts).
86
+ export { friendlyError } from './updaterErrors'
87
+
88
+ /** Every error/timeout path funnels through here so state stays consistent:
89
+ * friendly text out, in-flight guard + timers cleared so a retry works. */
90
+ function reportError(rawMessage: string): void {
91
+ recordProblem('updater', rawMessage)
92
+ checkInFlight = false
93
+ clearCheckTimer()
94
+ clearDownloadStallTimer()
95
+ sendStatus({ state: 'error', message: friendlyError(rawMessage) })
96
+ }
97
+
98
+ function sendStatus(status: UpdateStatus): void {
99
+ lastStatus = status
100
+ if (win && !win.isDestroyed() && !win.webContents.isDestroyed()) {
101
+ win.webContents.send(channel('update-status'), status)
102
+ }
103
+ }
104
+
105
+ export function getLastUpdateStatus(): UpdateStatus {
106
+ return lastStatus
107
+ }
108
+
109
+ /** Read the effective update channel. An EXPLICIT persisted choice (the user
110
+ * flipped the radio) always wins; otherwise the default follows the RUNNING
111
+ * build's own version — a prerelease build (…-rc.N) tracks beta, a plain X.Y.Z
112
+ * tracks stable. This is what stops an rc install from fruitlessly checking the
113
+ * (non-existent) stable channel and erroring. Electron-side storage, separate
114
+ * from the app's own settings file — readable before the Python sidecar is up. */
115
+ export function readUpdateChannel(): UpdateChannel {
116
+ try {
117
+ const raw = readFileSync(channelFilePath, 'utf8').trim()
118
+ if (raw === 'beta' || raw === 'stable') return raw
119
+ // Any other/empty content → fall through to the version-derived default.
120
+ } catch {
121
+ // No persisted choice yet → derive from this build's version.
122
+ }
123
+ return defaultChannelForVersion(appVersion())
124
+ }
125
+
126
+ /** The running app's version. Wrapped so it's overridable/testable and safe if
127
+ * called before `app` is ready (falls back to a stable-looking version). */
128
+ function appVersion(): string {
129
+ try {
130
+ return app.getVersion()
131
+ } catch {
132
+ return '0.0.0'
133
+ }
134
+ }
135
+
136
+ export function setUpdateChannel(channel: UpdateChannel): void {
137
+ autoUpdater.allowPrerelease = channel === 'beta'
138
+ try {
139
+ mkdirSync(userDataDir, { recursive: true })
140
+ writeFileSync(channelFilePath, channel, 'utf8')
141
+ } catch (err) {
142
+ console.error('[updater] failed to persist update channel:', err)
143
+ }
144
+ }
145
+
146
+ /** Wire autoUpdater events + do the initial channel read. Call once from
147
+ * app.whenReady() after the window exists (there's somewhere to show a
148
+ * result). Does NOT check for updates itself — see checkForUpdates(). */
149
+ export function initUpdater(mainWindow: BrowserWindow, userData: string): void {
150
+ win = mainWindow
151
+ userDataDir = userData
152
+ channelFilePath = join(userData, 'update-channel.json')
153
+
154
+ autoUpdater.autoDownload = false
155
+ autoUpdater.autoInstallOnAppQuit = false
156
+ autoUpdater.allowPrerelease = readUpdateChannel() === 'beta'
157
+
158
+ autoUpdater.on('checking-for-update', () => sendStatus({ state: 'checking' }))
159
+
160
+ autoUpdater.on('update-available', (info) => {
161
+ // A definite result arrived → the check is no longer in flight and its
162
+ // timeout must not later fire a spurious "timed out" over this state.
163
+ checkInFlight = false
164
+ clearCheckTimer()
165
+ sendStatus({ state: 'available', version: info.version, releaseNotes: releaseNotesText(info) })
166
+ })
167
+
168
+ autoUpdater.on('update-not-available', () => {
169
+ checkInFlight = false
170
+ clearCheckTimer()
171
+ sendStatus({ state: 'not-available' })
172
+ })
173
+
174
+ autoUpdater.on('download-progress', (progress) => {
175
+ // Each progress tick proves the transfer is alive — re-arm the stall watch.
176
+ armDownloadStall()
177
+ sendStatus({ state: 'downloading', percent: Math.round(progress.percent) })
178
+ })
179
+
180
+ autoUpdater.on('update-downloaded', (info) => {
181
+ clearDownloadStallTimer()
182
+ sendStatus({ state: 'downloaded', version: info.version })
183
+ })
184
+
185
+ // electron-updater surfaces network + verification failures here. But a
186
+ // "no release found for this channel" error (e.g. a STABLE build/channel when
187
+ // only prereleases exist yet — no stable release published) is NOT a failure —
188
+ // it means "you're up to date on your channel". Report THAT as not-available so
189
+ // the user sees a calm "up to date", not a scary error. Everything else routes
190
+ // through reportError (friendly + recheckable).
191
+ autoUpdater.on('error', (err) => {
192
+ const msg = err?.message ?? String(err)
193
+ if (isNoReleaseForChannel(msg)) {
194
+ checkInFlight = false
195
+ clearCheckTimer()
196
+ sendStatus({ state: 'not-available' })
197
+ return
198
+ }
199
+ reportError(msg)
200
+ })
201
+ }
202
+
203
+ /** Recognise electron-updater's "there is no release matching this channel" error
204
+ * — the feed / release simply doesn't exist for the channel we asked for (a
205
+ * stable build when only prereleases have shipped). That's "up to date", not a
206
+ * fault. Kept conservative: only the shapes electron-updater/GitHubProvider emit
207
+ * for a genuinely-absent release, so a real 404-from-network still surfaces. */
208
+ function isNoReleaseForChannel(msg: string): boolean {
209
+ const s = String(msg || '')
210
+ return /Unable to find latest version on GitHub|No published versions on GitHub|latest-mac\.yml.*not found|Cannot find channel|No version found/i.test(s)
211
+ }
212
+
213
+ /** (Re)arm the download stall watchdog: if no progress event lands within
214
+ * DOWNLOAD_STALL_MS, treat the transfer as failed (recheckable). */
215
+ function armDownloadStall(): void {
216
+ clearDownloadStallTimer()
217
+ downloadStallTimer = setTimeout(() => {
218
+ downloadStallTimer = null
219
+ // Only fires while we still believe we're downloading — a completed/errored
220
+ // download clears the timer, so reaching here means genuine silence.
221
+ if (lastStatus.state === 'downloading') {
222
+ reportError('The download stalled — check your connection and try again.')
223
+ }
224
+ }, DOWNLOAD_STALL_MS)
225
+ }
226
+
227
+ function releaseNotesText(info: { releaseNotes?: string | Array<{ note?: string | null }> | null }): string | undefined {
228
+ if (typeof info.releaseNotes === 'string') return info.releaseNotes
229
+ if (Array.isArray(info.releaseNotes)) {
230
+ return info.releaseNotes.map((n) => n.note ?? '').filter(Boolean).join('\n\n')
231
+ }
232
+ return undefined
233
+ }
234
+
235
+ /** Force the updater back to a neutral, checkable state. Exposed so any external
236
+ * recovery (e.g. a renderer "Retry" that wants a clean slate) can reset it; the
237
+ * error path already leaves it recheckable, so this is belt-and-braces. */
238
+ export function resetToIdle(): void {
239
+ checkInFlight = false
240
+ clearCheckTimer()
241
+ clearDownloadStallTimer()
242
+ sendStatus({ state: 'idle' })
243
+ }
244
+
245
+ /** Manual or startup check. Safe to call repeatedly — an in-flight check is a
246
+ * no-op (guarded), and a prior timed-out/errored check has already cleared the
247
+ * guard so a retry proceeds. Not packaged (dev/e2e) -> no-op, since there's no
248
+ * installed app for electron-updater to reason about updating. */
249
+ export function checkForUpdates(): void {
250
+ if (!app.isPackaged) {
251
+ sendStatus({ state: 'not-available' })
252
+ return
253
+ }
254
+ if (checkInFlight) return
255
+ checkInFlight = true
256
+
257
+ // Bound the check: a half-open GitHub connection never rejects the promise nor
258
+ // fires 'error', so without this the UI wedges in 'checking' forever with the
259
+ // "Check Now" button disabled. On timeout report + clear the guard so a retry
260
+ // works (mirrors index.ts's PDF_EXPORT_TIMEOUT_MS race).
261
+ clearCheckTimer()
262
+ checkTimer = setTimeout(() => {
263
+ checkTimer = null
264
+ if (checkInFlight) {
265
+ reportError('Update check timed out — check your connection and try again.')
266
+ }
267
+ }, CHECK_TIMEOUT_MS)
268
+
269
+ autoUpdater.checkForUpdates().catch((err) => reportError(err?.message ?? String(err)))
270
+ }
271
+
272
+ export function downloadUpdate(): void {
273
+ // A download that never starts producing progress (immediate half-open) would
274
+ // otherwise sit forever — arm the stall watch up front; each progress event
275
+ // re-arms it, downloaded/error clears it.
276
+ armDownloadStall()
277
+ autoUpdater.downloadUpdate().catch((err) => reportError(err?.message ?? String(err)))
278
+ }
279
+
280
+ /**
281
+ * Hand off to the downloaded installer.
282
+ *
283
+ * The handoff is a RACE, and losing it is what produced the Windows
284
+ * "SpyDE cannot be closed. Please close it manually and click Retry" dead end:
285
+ * electron-updater spawns the installer FIRST and only then asks the app to
286
+ * quit, so for a second or two both are alive. The installer opens by refusing
287
+ * to touch a directory anything is still running out of — and it counts the
288
+ * Python sidecar and its Dask workers, not just the app window. Electron's own
289
+ * quit is too slow and too conditional to win that race on its own, and the
290
+ * sidecar's tree-kill backstop is on a timer that an exiting process never
291
+ * lives long enough to fire.
292
+ *
293
+ * So: kill the sidecar tree synchronously, hand off, then guarantee this
294
+ * process is gone whether or not the graceful quit completes. There is nothing
295
+ * left to save past this point — the user asked to restart into an installer.
296
+ */
297
+ const HANDOFF_EXIT_MS = 1500
298
+
299
+ export function quitAndInstall(): void {
300
+ // The one call that used to be able to throw the process down. If the installer
301
+ // handoff fails (missing/locked installer, permissions), surface a friendly
302
+ // recoverable error instead of an uncaught exception crashing the app.
303
+ try {
304
+ clearDownloadStallTimer()
305
+ // Order matters: quitAndInstall() spawns the installer synchronously, so a
306
+ // throw here means the handoff never started and the app has to keep
307
+ // running — killing the sidecar first would leave it alive but useless.
308
+ // Everything after this line happens within microseconds of the spawn,
309
+ // whole seconds before the installer gets as far as looking for us.
310
+ autoUpdater.quitAndInstall()
311
+ stopBackend({ immediate: true })
312
+ setTimeout(() => { try { app.exit(0) } catch { /* already gone */ } }, HANDOFF_EXIT_MS)
313
+ } catch (err) {
314
+ reportError(`Couldn't start the installer — please download manually. (${(err as Error)?.message ?? String(err)})`)
315
+ }
316
+ }
317
+
318
+ /** Whether this is a build electron-builder/electron-updater can actually
319
+ * act on (a real installed app, not `electron .` dev / the e2e harness). */
320
+ export function updatesSupported(): boolean {
321
+ return app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml'))
322
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * updater_errors.test.ts — node:test unit tests for the pure updater error mapper.
3
+ *
4
+ * Runs on every CI push, every OS (`node --test`, native TS type-stripping on
5
+ * Node 24+). Guards the "mac auto-update failed spectacularly" regression: a raw
6
+ * releases.atom XML body must be collapsed to a short line, never passed through.
7
+ *
8
+ * Run: `node --test src/main/updater_errors.test.ts` (from electron/), or via the
9
+ * `test:unit` npm script.
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import {
14
+ friendlyError, truncateMessage, isPrereleaseVersion, defaultChannelForVersion,
15
+ } from './updaterErrors.ts'
16
+
17
+ test('offline / DNS errors → offline message', () => {
18
+ for (const raw of ['net::ERR_INTERNET_DISCONNECTED', 'getaddrinfo ENOTFOUND github.com', 'EAI_AGAIN']) {
19
+ assert.match(friendlyError(raw), /offline/i)
20
+ }
21
+ })
22
+
23
+ test('timeout errors → try again', () => {
24
+ for (const raw of ['ETIMEDOUT', 'net::ERR_TIMED_OUT', 'request timed out']) {
25
+ assert.match(friendlyError(raw), /too long|try again/i)
26
+ }
27
+ })
28
+
29
+ test('connection reset/refused → could not reach', () => {
30
+ for (const raw of ['ECONNRESET', 'net::ERR_CONNECTION_REFUSED', 'socket hang up']) {
31
+ assert.match(friendlyError(raw), /reach the update server/i)
32
+ }
33
+ })
34
+
35
+ test('missing feed / 404 → no update info', () => {
36
+ for (const raw of ['Cannot find latest-mac.yml', 'HttpError: 404', 'status code 404']) {
37
+ assert.match(friendlyError(raw), /no update information/i)
38
+ }
39
+ })
40
+
41
+ test('RAW ATOM/HTML FEED is collapsed, never passed through (the regression)', () => {
42
+ const atom = '<?xml version="1.0" encoding="UTF-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom">'
43
+ + '<entry><id>tag:github.com,2008:Repository/1/v0.2.0-rc.6</id>'.repeat(300) + '</feed>'
44
+ const out = friendlyError(atom)
45
+ assert.doesNotMatch(out, /<entry|<feed|<\?xml/i, 'markup leaked into the user message')
46
+ assert.ok(out.length < 200, `message not collapsed (len ${out.length})`)
47
+ assert.match(out, /unexpected response|manually/i)
48
+ })
49
+
50
+ test('a raw <html> error page is collapsed too', () => {
51
+ const html = '<!DOCTYPE html><html><body>' + '<div>error</div>'.repeat(500) + '</body></html>'
52
+ const out = friendlyError(html)
53
+ assert.doesNotMatch(out, /<html|<div|<!DOCTYPE/i)
54
+ assert.ok(out.length < 200)
55
+ })
56
+
57
+ test('an unrecognised long single-line blob is truncated, not verbatim', () => {
58
+ const blob = 'SomeUpstreamError: ' + 'x'.repeat(5000)
59
+ const out = friendlyError(blob)
60
+ assert.ok(out.length <= 300, `not truncated (len ${out.length})`)
61
+ assert.ok(out.endsWith('…'))
62
+ })
63
+
64
+ test('a short recognisable-enough message passes through (collapsed whitespace)', () => {
65
+ assert.equal(friendlyError('Update failed\n for reason X'), 'Update failed for reason X')
66
+ })
67
+
68
+ test('truncateMessage collapses whitespace and caps length', () => {
69
+ assert.equal(truncateMessage('a\n\n b c'), 'a b c')
70
+ const long = 'y'.repeat(400)
71
+ const t = truncateMessage(long, 100)
72
+ assert.equal(t.length, 100)
73
+ assert.ok(t.endsWith('…'))
74
+ })
75
+
76
+ test('empty / nullish input → empty string, never throws', () => {
77
+ assert.equal(friendlyError(''), '')
78
+ // @ts-expect-error deliberately exercising a nullish raw
79
+ assert.equal(friendlyError(null), '')
80
+ // @ts-expect-error deliberately exercising an undefined raw
81
+ assert.equal(friendlyError(undefined), '')
82
+ })
83
+
84
+ // ── channel detection (rc build → beta, stable build → stable) ────────────────
85
+
86
+ test('prerelease versions are detected', () => {
87
+ for (const v of ['0.2.0-rc.8', '0.2.0-rc.1', '1.0.0-beta.2', '2.3.4-alpha.1',
88
+ 'v0.2.0-rc.8', '0.2.0-rc.8+build.5']) {
89
+ assert.equal(isPrereleaseVersion(v), true, `${v} should be prerelease`)
90
+ }
91
+ })
92
+
93
+ test('plain releases are NOT prerelease', () => {
94
+ for (const v of ['0.2.0', '1.0.0', 'v2.3.4', '0.2.0+build.5', '10.20.30']) {
95
+ assert.equal(isPrereleaseVersion(v), false, `${v} should be stable`)
96
+ }
97
+ })
98
+
99
+ test('defaultChannelForVersion: rc build → beta, stable build → stable', () => {
100
+ assert.equal(defaultChannelForVersion('0.2.0-rc.8'), 'beta')
101
+ assert.equal(defaultChannelForVersion('v1.0.0-beta.1'), 'beta')
102
+ assert.equal(defaultChannelForVersion('0.2.0'), 'stable')
103
+ assert.equal(defaultChannelForVersion('1.2.3'), 'stable')
104
+ })
105
+
106
+ test('channel detection tolerates empty/garbage version', () => {
107
+ assert.equal(isPrereleaseVersion(''), false)
108
+ // @ts-expect-error nullish
109
+ assert.equal(isPrereleaseVersion(null), false)
110
+ assert.equal(defaultChannelForVersion(''), 'stable')
111
+ })
@@ -0,0 +1,65 @@
1
+ /**
2
+ * updater_errors.ts — PURE, dependency-free updater helpers (error mapping +
3
+ * channel detection).
4
+ *
5
+ * Split out of updater.ts (which imports `electron`, so it can't be loaded by a
6
+ * plain node unit test) so these can be unit-tested with `node:test` on every CI
7
+ * push, on every OS. The whole point of friendlyError: an updater error must NEVER
8
+ * reach the UI as a raw blob — the "mac auto-update failed spectacularly"
9
+ * screenshot was ~10 KB of raw releases.atom XML rendered full-screen because the
10
+ * fallback returned the provider's message verbatim. See updater_errors.test.ts.
11
+ */
12
+
13
+ /** True when a semver string has a prerelease component (-rc.N / -beta.N /
14
+ * -alpha.N / any `-suffix`). Used to pick the DEFAULT update channel from the
15
+ * running build's OWN version: a prerelease build follows the beta channel, a
16
+ * plain X.Y.Z follows stable — so an rc build never fruitlessly looks for a
17
+ * (non-existent) stable release. Tolerant of a leading `v` and build metadata
18
+ * (`+…`). */
19
+ export function isPrereleaseVersion(version: string): boolean {
20
+ const v = String(version ?? '').trim().replace(/^v/i, '')
21
+ // Strip build metadata, then a prerelease is anything after the first '-'.
22
+ const core = v.split('+')[0]
23
+ return core.includes('-')
24
+ }
25
+
26
+ /** The channel a build should follow BY DEFAULT (before any explicit user
27
+ * choice): beta if the running build is itself a prerelease, else stable. */
28
+ export function defaultChannelForVersion(version: string): 'stable' | 'beta' {
29
+ return isPrereleaseVersion(version) ? 'beta' : 'stable'
30
+ }
31
+
32
+ /** Bound an unrecognised error message so a huge single-line payload can't blow
33
+ * out the error box. Collapse whitespace, cap length, keep it one readable line. */
34
+ export function truncateMessage(s: string, max = 300): string {
35
+ const flat = String(s ?? '').replace(/\s+/g, ' ').trim()
36
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
37
+ }
38
+
39
+ /** Map a raw electron-updater / Chromium-net error to something a user can act
40
+ * on. Falls back to a BOUNDED, sanitised version of the raw message when we
41
+ * don't recognise it — never the raw blob verbatim. */
42
+ export function friendlyError(raw: string): string {
43
+ const s = String(raw || '')
44
+ if (/ERR_INTERNET_DISCONNECTED|ENOTFOUND|EAI_AGAIN|ERR_NAME_NOT_RESOLVED|getaddrinfo/i.test(s)) {
45
+ return 'You appear to be offline — check your connection and try again.'
46
+ }
47
+ if (/ETIMEDOUT|ERR_TIMED_OUT|ERR_CONNECTION_TIMED_OUT|timed out/i.test(s)) {
48
+ return 'The update server took too long to respond — please try again.'
49
+ }
50
+ if (/ERR_CONNECTION_(REFUSED|RESET|CLOSED)|ECONNRESET|ECONNREFUSED|socket hang up/i.test(s)) {
51
+ return 'Could not reach the update server — please try again.'
52
+ }
53
+ if (/latest.*\.yml|Cannot find .*\.yml|status code 404|HttpError: 404|ERR_HTTP_RESPONSE_CODE_FAILURE/i.test(s)) {
54
+ return 'No update information available right now — please try again later.'
55
+ }
56
+ // A provider that can't resolve the platform feed can surface the GitHub
57
+ // releases.atom body (or an HTML error page) as the error message. That raw
58
+ // XML/HTML must NEVER reach the UI verbatim — it rendered as a full-screen wall
59
+ // of markup ("mac auto-update failed spectacularly"). Detect markup / an
60
+ // oversized blob and collapse it to a short, actionable line.
61
+ if (/<\?xml|<!DOCTYPE|<feed\b|<entry\b|<html\b|<rss\b/i.test(s)) {
62
+ return 'The update server returned an unexpected response — please try again later or update manually from GitHub.'
63
+ }
64
+ return truncateMessage(s)
65
+ }