Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Changelog

All notable changes to `de-shell`. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), the versioning is
[semver](https://semver.org/) with the 0.x caveat: a breaking change to the
sidecar protocol bumps the minor.

## [Unreleased]

### Fixed
- `closeApp` (testing) removes the launch's temp profile directory on every
path, and `launchApp` reports it as `profileDir`. Each e2e launch used to
leave a `<appId>-e2e-profile-*` directory behind. `pythonEnv.test.ts` now
removes the directories it makes.

## [0.2.1] - 2026-09-02

Ground Crew's shell work from after the merge base, so it can move onto the
package too.

### Added
- `createStdoutDemux` (main): the sidecar's stdout demuxer as a chunk-list
accumulator that copies each byte once. The inline parser re-copied the
whole buffered prefix per chunk, O(N^2/chunkSize) while a large frame
streamed in (11.9 s per 64 MB frame at 64 KiB chunks). A malformed
`PLOTAPP:` line is now reported on stderr rather than swallowed.
- `createSizeReporter` (renderer): `FigureFrame` skips the zero-size first
layout and any resize whose rounded size is unchanged, and holds its
`onResize` in a ref so an inline callback no longer re-runs the effect
(measured at ~1,500 sends/s over constant geometry before).
- `attachFigure` (renderer): figure registration is owned by an effect and
re-registers on every run, so React StrictMode's double-invoke no longer
leaves a figure registered nowhere with its pane black.
- `PIN_SCROLL`: every figure document undoes the focus-scroll that shifted a
fresh pane by half its overflow on first hover.

### Changed
- anyplotlib floor raised to 0.8.0, for its fix to a tiled image born on a
placeholder rendering solid black.

## [0.2.0] — 2026-09-02

The first release as its own package. Until now the shell lived as a vendored
copy inside each of SpyDE, Ground Crew and Autopilot, and the three had
diverged.

### Added
- **One package.** The TypeScript half (`de_shell/js`: the Electron main
process, the preload bridge, the React renderer kernel, the Playwright
harness) ships inside the wheel, so `pip install -U de-shell` moves both
halves of the sidecar protocol together. `python -m de_shell.js` prints where
the tree is; apps link it into their Electron project.
- From SpyDE 0.4.3: the problem reporter (`errorReport`, `problemLog`,
`sentryEnvelope`), `recentBackendOutput`, workspace-member wheels in the
environment setup, an update handoff that tree-kills the sidecar first,
`run_on_worker`'s in-flight count and `ComputeHandle` for cancelling a
superseded compute.
- From Ground Crew: the sidecar spawn-error trap, a 5 s tree-kill grace, a
resolved `uv` path, the open-directory dialog channel, `_pin_tile_band` for
large stills, JSON emit that never writes bare `NaN`, harness hardening, and
the unit tests for all of it.
- From Autopilot: the close handler forgets only its own child process, a
report for malformed protocol messages, `useFigureEventForwarding`, and a
`LOG_CLEAR` action in the renderer state.

### Changed
- License: MIT (the vendored copies were GPL-3.0-or-later inside SpyDE).
- Line endings are LF throughout, enforced by `.gitattributes`.

[Unreleased]: https://github.com/directelectron/de-shell/compare/v0.2.1...HEAD
[0.2.1]: https://github.com/directelectron/de-shell/releases/tag/v0.2.1
[0.2.0]: https://github.com/directelectron/de-shell/releases/tag/v0.2.0
20 changes: 16 additions & 4 deletions de_shell/js/main/pythonEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
* Run: `node --test src/pythonEnv.test.ts` (from packages/shell-main/), or via
* the `test:unit` npm script.
*/
import { test } from 'node:test'
import { test, after } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { configureShell } from './config.ts'
Expand All @@ -24,17 +24,29 @@ configureShell({
pythonModule: 'testapp',
})

/** Every temp directory these tests make, removed when the file is done. */
const tempDirs: string[] = []
after(() => {
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true })
})

function tempDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}

/** A directory containing a uv stub under both spellings, so these tests do not
* fork on the host platform. */
function dirWithUv(): string {
const dir = mkdtempSync(join(tmpdir(), 'uv-stub-'))
const dir = tempDir('uv-stub-')
writeFileSync(join(dir, 'uv'), '')
writeFileSync(join(dir, 'uv.exe'), '')
return dir
}

function emptyDir(): string {
return mkdtempSync(join(tmpdir(), 'uv-none-'))
return tempDir('uv-none-')
}

/** An env whose PATH and every fallback root point somewhere we control. */
Expand Down
55 changes: 35 additions & 20 deletions de_shell/js/testing/harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function appRequire(name) {
}
const { _electron: electron } = appRequire('@playwright/test')
const { spawnSync } = require('child_process')
const { mkdtempSync } = require('fs')
const { mkdtempSync, rmSync } = require('fs')
const { join } = require('path')
const { tmpdir } = require('os')

Expand All @@ -51,6 +51,7 @@ async function launchApp(opts) {
env = {}, timeout = 60_000,
} = opts
if (!appDir || !appId) throw new Error('launchApp needs { appDir, appId }')
const profileDir = mkdtempSync(join(tmpdir(), `${appId}-e2e-profile-`))

const app = await electron.launch({
// Resolve Electron from the APP's tree, not Playwright's. Without an
Expand All @@ -63,7 +64,7 @@ async function launchApp(opts) {
executablePath: require(require.resolve('electron/index.js', { paths: [appDir] })),
args: [
join(appDir, 'out', 'main', 'index.js'),
`--user-data-dir=${mkdtempSync(join(tmpdir(), `${appId}-e2e-profile-`))}`,
`--user-data-dir=${profileDir}`,
],
env: { ...process.env, ...env },
})
Expand All @@ -80,9 +81,9 @@ async function launchApp(opts) {
for (const type of readyMessages) await backend.waitForMessage(type, timeout)

return {
app, page, backend, jsErrors,
app, page, backend, jsErrors, profileDir,
assertNoJsErrors: () => assertNoJsErrors(jsErrors),
close: (opts) => closeApp(app, opts),
close: (opts) => closeApp(app, { profileDir, ...opts }),
}
}

Expand All @@ -109,23 +110,37 @@ async function firstWindowWithLog(app, logBuffer, timeout = 60_000) {
* runner's timeout, leaving an Electron tree alive that (for a DE app) still
* owns the server's single connection — every later launch then hangs on a
* connection that cannot be made. Returns 'closed' | 'killed' | 'noop' so a
* caller can log what teardown actually did.
* caller can log what teardown actually did. `opts.profileDir` (the launch's
* temp profile) is removed once the process is down, on every path.
*/
async function closeApp(app, opts = {}) {
const { timeout = 15_000, killTree = hardKillTree } = opts
if (!app) return 'noop'
const pid = app.process()?.pid
const closed = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeout)
timer.unref?.()
Promise.resolve()
.then(() => app.close())
.then(() => { clearTimeout(timer); resolve(true) },
() => { clearTimeout(timer); resolve(false) })
})
if (closed) return 'closed'
if (pid) killTree(pid)
return pid ? 'killed' : 'noop'
const { timeout = 15_000, killTree = hardKillTree, profileDir = null } = opts
try {
if (!app) return 'noop'
const pid = app.process()?.pid
const closed = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeout)
timer.unref?.()
Promise.resolve()
.then(() => app.close())
.then(() => { clearTimeout(timer); resolve(true) },
() => { clearTimeout(timer); resolve(false) })
})
if (closed) return 'closed'
if (pid) killTree(pid)
return pid ? 'killed' : 'noop'
} finally {
if (profileDir) removeProfileDir(profileDir)
}
}

/** Remove a launch's temp profile. Retries cover a handle Chromium is still
* letting go of on Windows; a directory that still will not go is left for the
* OS rather than failing the spec's teardown. */
function removeProfileDir(dir) {
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
} catch { /* left behind; not the spec's failure */ }
}

/** Force-kill `pid` AND everything under it (the Python sidecar and its
Expand Down Expand Up @@ -240,5 +255,5 @@ async function countColorPixels(page, kind) {

module.exports = {
launchApp, createBackend, countColorPixels, assertNoJsErrors, errorLines,
firstWindowWithLog, closeApp, hardKillTree,
firstWindowWithLog, closeApp, hardKillTree, removeProfileDir,
}
30 changes: 30 additions & 0 deletions de_shell/js/testing/harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
*/
const { test } = require('node:test')
const assert = require('node:assert/strict')
const { mkdtempSync, existsSync, writeFileSync } = require('fs')
const { join } = require('path')
const { tmpdir } = require('os')
const { firstWindowWithLog, closeApp } = require('./harness.cjs')

test('a missing first window reports the backend log, not a bare timeout', async () => {
Expand Down Expand Up @@ -71,3 +74,30 @@ test('closeApp tolerates a missing app', async () => {
assert.equal(await closeApp(null), 'noop')
assert.equal(await closeApp(undefined), 'noop')
})

/** A stand-in for the launch's --user-data-dir, with something inside it. */
function fakeProfileDir() {
const dir = mkdtempSync(join(tmpdir(), 'harness-test-profile-'))
writeFileSync(join(dir, 'Preferences'), '{}')
return dir
}

test('a clean close removes the profile dir', async () => {
const profileDir = fakeProfileDir()
const app = { process: () => ({ pid: 4242 }), close: async () => {} }
assert.equal(await closeApp(app, { timeout: 1000, killTree: () => {}, profileDir }), 'closed')
assert.equal(existsSync(profileDir), false, 'profile dir left behind')
})

test('a hard-killed close removes the profile dir too', async () => {
const profileDir = fakeProfileDir()
const app = { process: () => ({ pid: 4242 }), close: () => new Promise(() => {}) }
assert.equal(await closeApp(app, { timeout: 100, killTree: () => {}, profileDir }), 'killed')
assert.equal(existsSync(profileDir), false, 'profile dir left behind')
})

test('closeApp with no app still removes the profile dir', async () => {
const profileDir = fakeProfileDir()
assert.equal(await closeApp(null, { profileDir }), 'noop')
assert.equal(existsSync(profileDir), false, 'profile dir left behind')
})
Loading