From 2db7eb7807a880c077bc8a16a80465e2d96d5881 Mon Sep 17 00:00:00 2001 From: Si Hyeong Lee Date: Wed, 21 Oct 2026 15:52:51 -0700 Subject: [PATCH] chore(qa): harnesses and the diagnostics test remove their temp folders Every harness run left its sandbox in the OS temp directory, and the diagnostics unit test left one folder per case: 1,379 folders, about 5 GB, after a few weeks. qa/sandbox.mjs makes them and removes them when the run exits passing; a failing run keeps them and prints where. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NLGwiBY5uEivJqERQzcoY --- qa/audit.mjs | 3 ++- qa/claude-update.mjs | 3 ++- qa/close.mjs | 7 ++++--- qa/cockpit-tools.mjs | 3 ++- qa/image-rows.mjs | 3 ++- qa/images-remote.mjs | 5 +++-- qa/images.mjs | 3 ++- qa/link.mjs | 7 ++++--- qa/perf-gate.mjs | 3 ++- qa/perf.mjs | 3 ++- qa/restore-pacing.mjs | 3 ++- qa/sandbox.mjs | 24 ++++++++++++++++++++++++ src/main/diagnostics.test.ts | 8 +++++--- 13 files changed, 56 insertions(+), 19 deletions(-) create mode 100644 qa/sandbox.mjs diff --git a/qa/audit.mjs b/qa/audit.mjs index aeea692..28d919d 100644 --- a/qa/audit.mjs +++ b/qa/audit.mjs @@ -5,12 +5,13 @@ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { sandboxDir } from './sandbox.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const out = join(root, 'qa', 'shots'); mkdirSync(out, { recursive: true }); -const userData = mkdtempSync(join(tmpdir(), 'devdeck-qa-')); +const userData = sandboxDir('devdeck-qa-'); const app = await electron.launch({ args: ['.', `--user-data-dir=${userData}`, '--no-sandbox', '--disable-gpu'], cwd: root, diff --git a/qa/claude-update.mjs b/qa/claude-update.mjs index 5d66471..8e16cc0 100644 --- a/qa/claude-update.mjs +++ b/qa/claude-update.mjs @@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { sandboxDir } from './sandbox.mjs'; // Windows harness (fixture claude.cmd on PATH). Run with npm run qa:update. const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url)))); @@ -15,7 +16,7 @@ const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'claude-updat mkdirSync(shots, { recursive: true }); const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/')); -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-update-')); +const sandbox = sandboxDir('devdeck-update-'); const repo = join(sandbox, 'update-workspace'); const bin = join(sandbox, 'bin'); const profile = join(sandbox, 'profile'); diff --git a/qa/close.mjs b/qa/close.mjs index b4b429e..ec44bc8 100644 --- a/qa/close.mjs +++ b/qa/close.mjs @@ -17,20 +17,21 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { closeElectron } from './electron-lifecycle.mjs'; +import { sandboxDir } from './sandbox.mjs'; const repo = dirname(dirname(fileURLToPath(import.meta.url))); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const stamp = () => new Date().toISOString().slice(11, 19); -const fixtureRepo = mkdtempSync(join(tmpdir(), 'devdeck-close-fixture-')); +const fixtureRepo = sandboxDir('devdeck-close-fixture-'); writeFileSync(join(fixtureRepo, 'README.md'), '# fixture'); execFileSync('git', ['init', '-q'], { cwd: fixtureRepo }); execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'add', '-A'], { cwd: fixtureRepo }); execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'commit', '-q', '-m', 'fixture'], { cwd: fixtureRepo }); async function launch(tag, registerFolder) { - const userData = mkdtempSync(join(tmpdir(), `devdeck-close-${tag}-`)); - const temp = mkdtempSync(join(tmpdir(), `devdeck-closetmp-${tag}-`)); + const userData = sandboxDir(`devdeck-close-${tag}-`); + const temp = sandboxDir(`devdeck-closetmp-${tag}-`); const app = await electron.launch({ args: ['.', `--user-data-dir=${userData}`, '--no-sandbox', '--disable-gpu'], cwd: repo, diff --git a/qa/cockpit-tools.mjs b/qa/cockpit-tools.mjs index e0e6eb5..1c13f83 100644 --- a/qa/cockpit-tools.mjs +++ b/qa/cockpit-tools.mjs @@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { sandboxDir } from './sandbox.mjs'; const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url)))); if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); } @@ -17,7 +18,7 @@ const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'cockpit-tool mkdirSync(shots, { recursive: true }); const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/')); -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-cockpit-tools-')); +const sandbox = sandboxDir('devdeck-cockpit-tools-'); const repo = join(sandbox, 'tools-workspace'); const bin = join(sandbox, 'bin'); const profile = join(sandbox, 'profile'); diff --git a/qa/image-rows.mjs b/qa/image-rows.mjs index 89e0a38..37ba8ce 100644 --- a/qa/image-rows.mjs +++ b/qa/image-rows.mjs @@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { sandboxDir } from './sandbox.mjs'; const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url)))); if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); } @@ -17,7 +18,7 @@ const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'image-rows') mkdirSync(shots, { recursive: true }); const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/')); -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-image-rows-')); +const sandbox = sandboxDir('devdeck-image-rows-'); const repo = join(sandbox, 'rows-workspace'); const bin = join(sandbox, 'bin'); const profile = join(sandbox, 'profile'); diff --git a/qa/images-remote.mjs b/qa/images-remote.mjs index 02f2bb0..9d10f70 100644 --- a/qa/images-remote.mjs +++ b/qa/images-remote.mjs @@ -11,11 +11,12 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { closeElectron } from './electron-lifecycle.mjs'; +import { sandboxDir } from './sandbox.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const shots = resolve(process.env.DEVDECK_IMAGES_OUT || join(root, 'qa', 'shots', 'images-remote')); mkdirSync(shots, { recursive: true }); -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-images-remote-')); +const sandbox = sandboxDir('devdeck-images-remote-'); const repo = join(sandbox, 'host-workspace'); const bin = join(sandbox, 'bin'); for (const p of [repo, bin]) mkdirSync(p, { recursive: true }); @@ -146,7 +147,7 @@ try { await check('a Claude in Chrome screenshot in the host temp directory travels; other temp files do not', async () => { const shotDir = mkdtempSync(join(tmpdir(), 'claude-chrome-screenshots-')); - const other = mkdtempSync(join(tmpdir(), 'devdeck-qa-other-')); + const other = sandboxDir('devdeck-qa-other-'); copyFileSync(join(root, 'build', 'icon.png'), join(shotDir, 'screenshot-1-1.jpg')); copyFileSync(join(root, 'build', 'icon.png'), join(other, 'shot.png')); try { diff --git a/qa/images.mjs b/qa/images.mjs index b89f7e7..f317820 100644 --- a/qa/images.mjs +++ b/qa/images.mjs @@ -7,6 +7,7 @@ import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { sandboxDir } from './sandbox.mjs'; // Windows harness (fixture claude.cmd on PATH). Run with npm run qa:images. const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url)))); @@ -14,7 +15,7 @@ const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'images')); mkdirSync(shots, { recursive: true }); const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/')); -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-images-')); +const sandbox = sandboxDir('devdeck-images-'); const repo = join(sandbox, 'image-workspace'); const bin = join(sandbox, 'bin'); const profile = join(sandbox, 'profile'); diff --git a/qa/link.mjs b/qa/link.mjs index c298eba..7b6855e 100644 --- a/qa/link.mjs +++ b/qa/link.mjs @@ -16,6 +16,7 @@ import { dirname, join } from 'node:path'; import { connect as netConnect } from 'node:net'; import { fileURLToPath } from 'node:url'; import { closeElectron } from './electron-lifecycle.mjs'; +import { sandboxDir } from './sandbox.mjs'; const repo = dirname(dirname(fileURLToPath(import.meta.url))); @@ -25,7 +26,7 @@ const repo = dirname(dirname(fileURLToPath(import.meta.url))); // child processes down with it (renderer, GPU, network service, exit code -1, no dump). Bisected // across five builds before the pattern showed: every run from a worktree passed, every run from the // checkout crashed. A fresh repo has no conversation to continue. -const fixtureRepo = mkdtempSync(join(tmpdir(), 'devdeck-link-fixture-')); +const fixtureRepo = sandboxDir('devdeck-link-fixture-'); writeFileSync(join(fixtureRepo, 'README.md'), '# fixture'); execFileSync('git', ['init', '-q'], { cwd: fixtureRepo }); execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'add', '-A'], { cwd: fixtureRepo }); @@ -34,12 +35,12 @@ execFileSync('git', ['-c', 'user.email=qa@devdeck', '-c', 'user.name=qa', 'commi async function launch(tag, registerFolder, reuse = null) { // A relaunch keeps its user-data directory: that is where the machine's identity, its pairings and // its port live, and "the host came back" is only meaningful if it comes back as the same machine. - const userData = reuse?.userData ?? mkdtempSync(join(tmpdir(), `devdeck-rc-${tag}-`)); + const userData = reuse?.userData ?? sandboxDir(`devdeck-rc-${tag}-`); // Its own temp directory. Both instances run against one filesystem here, so a pasted image landing // in the HOST's temp dir is the only thing that distinguishes "the bytes crossed the link and the // machine running the agent wrote the file" from "the local paste ran and typed a path that machine // cannot read" — which is the whole failure this feature exists to avoid, and it is silent. - const temp = reuse?.temp ?? mkdtempSync(join(tmpdir(), `devdeck-tmp-${tag}-`)); + const temp = reuse?.temp ?? sandboxDir(`devdeck-tmp-${tag}-`); const app = await electron.launch({ args: ['.', `--user-data-dir=${userData}`, '--no-sandbox', '--disable-gpu'], cwd: repo, diff --git a/qa/perf-gate.mjs b/qa/perf-gate.mjs index 0bc03be..b9a2bae 100644 --- a/qa/perf-gate.mjs +++ b/qa/perf-gate.mjs @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { closeElectron } from './electron-lifecycle.mjs'; +import { sandboxDir } from './sandbox.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const out = resolve(process.env.DEVDECK_PERF_OUT || join(root, 'qa', 'shots', 'perf-gate')); @@ -41,7 +42,7 @@ const LIMITS = { streamSeconds: 45, // the stream must finish drawing at all }; -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-perf-gate-')); +const sandbox = sandboxDir('devdeck-perf-gate-'); const repo = join(sandbox, 'perf-workspace'); const bin = join(sandbox, 'bin'); const profile = join(sandbox, 'profile'); diff --git a/qa/perf.mjs b/qa/perf.mjs index 8b60220..ed18f1f 100644 --- a/qa/perf.mjs +++ b/qa/perf.mjs @@ -19,6 +19,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { sandboxDir } from './sandbox.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); const SESSIONS = Number(process.argv[2] ?? 4); @@ -26,7 +27,7 @@ assert.ok(Number.isInteger(SESSIONS) && SESSIONS >= 1 && SESSIONS <= 16, 'reques if (!process.argv.includes('--allow-live-agents')) throw new Error('Manual live-agent benchmark. Pass --allow-live-agents explicitly; do not run unattended.'); const IDLE_MS = 10_000; -const qaUserData = mkdtempSync(join(tmpdir(), 'devdeck-perf-')); +const qaUserData = sandboxDir('devdeck-perf-'); writeFileSync(join(qaUserData, 'state.json'), JSON.stringify({ projects: {}, settings: { folders: [{ path: root, kind: 'repo' }], viewMode: 'list', cockpitSessions: [] }, }, null, 2)); diff --git a/qa/restore-pacing.mjs b/qa/restore-pacing.mjs index efdbe0c..0cc0e03 100644 --- a/qa/restore-pacing.mjs +++ b/qa/restore-pacing.mjs @@ -11,10 +11,11 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { closeElectron } from './electron-lifecycle.mjs'; +import { sandboxDir } from './sandbox.mjs'; const root = dirname(dirname(fileURLToPath(import.meta.url))); if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); } -const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-restore-pacing-')); +const sandbox = sandboxDir('devdeck-restore-pacing-'); const repo = join(sandbox, 'pacing-workspace'); const bin = join(sandbox, 'bin'); const profile = join(sandbox, 'profile'); diff --git a/qa/sandbox.mjs b/qa/sandbox.mjs new file mode 100644 index 0000000..7850070 --- /dev/null +++ b/qa/sandbox.mjs @@ -0,0 +1,24 @@ +// Temp folders a harness run works in. Every run used to leave its own behind (about 5 GB after a +// few weeks). A passing run removes them on exit; a failing one keeps them and says where. +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const made = []; + +/** A new folder in the OS temp directory, named ``, removed when the run passes. */ +export function sandboxDir(prefix) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + made.push(dir); + return dir; +} + +process.on('exit', (code) => { + if (code) { + if (made.length) console.log('kept for inspection: ' + made.join(', ')); + return; + } + for (const dir of made) { + try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* still held; temp is swept by the OS */ } + } +}); diff --git a/src/main/diagnostics.test.ts b/src/main/diagnostics.test.ts index 3c7f4f7..e039ba3 100644 --- a/src/main/diagnostics.test.ts +++ b/src/main/diagnostics.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect } from 'vitest'; -import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { afterAll, describe, it, expect } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { DiagnosticsLog, adoptLegacyErrorLog, formatDiagLine } from './diagnostics'; -const dir = (): string => mkdtempSync(join(tmpdir(), 'devdeck-diag-')); +const made: string[] = []; +const dir = (): string => { const d = mkdtempSync(join(tmpdir(), 'devdeck-diag-')); made.push(d); return d; }; +afterAll(() => { for (const d of made) rmSync(d, { recursive: true, force: true, maxRetries: 3 }); }); const at = new Date('2026-08-26T01:02:03.000Z'); describe('formatDiagLine', () => {