diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..923f630 --- /dev/null +++ b/CHANGELOG.md @@ -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 `-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 diff --git a/de_shell/js/main/pythonEnv.test.ts b/de_shell/js/main/pythonEnv.test.ts index 4a215ff..bff50f4 100644 --- a/de_shell/js/main/pythonEnv.test.ts +++ b/de_shell/js/main/pythonEnv.test.ts @@ -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' @@ -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. */ diff --git a/de_shell/js/testing/harness.cjs b/de_shell/js/testing/harness.cjs index e3b21b1..01d69ba 100644 --- a/de_shell/js/testing/harness.cjs +++ b/de_shell/js/testing/harness.cjs @@ -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') @@ -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 @@ -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 }, }) @@ -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 }), } } @@ -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 @@ -240,5 +255,5 @@ async function countColorPixels(page, kind) { module.exports = { launchApp, createBackend, countColorPixels, assertNoJsErrors, errorLines, - firstWindowWithLog, closeApp, hardKillTree, + firstWindowWithLog, closeApp, hardKillTree, removeProfileDir, } diff --git a/de_shell/js/testing/harness.test.cjs b/de_shell/js/testing/harness.test.cjs index 91325e9..31b83f0 100644 --- a/de_shell/js/testing/harness.test.cjs +++ b/de_shell/js/testing/harness.test.cjs @@ -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 () => { @@ -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') +})