diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdabe4f..9fac1e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: '22' + node-version-file: '.nvmrc' cache: 'pnpm' - name: Install dependencies diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md index 6bd34bc..8fd6f6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,6 +134,7 @@ If `pnpm run check` is too slow during iteration, run a narrower loop while deve ## Build And Runtime Notes +- Use the Node version from `.nvmrc` (Node 22). Node >= 26 is unsupported: `@electron/packager`/`extract-zip` zip extraction stalls, breaking `pnpm run package:smoke` and the `electron` package's install script. - Start the app with `pnpm dev` or `pnpm start`, not raw `electron .` - this ensures renderer styles are rebuilt first - Tailwind output is generated into `src/renderer/styles/main.css` diff --git a/docs/production/runbook.md b/docs/production/runbook.md index 22ab737..547cb9f 100644 --- a/docs/production/runbook.md +++ b/docs/production/runbook.md @@ -2,6 +2,7 @@ ## Environment +- Use the Node version from `.nvmrc` (Node 22). Node >= 26 is unsupported: `@electron/packager` / `extract-zip` zip extraction stalls there, which breaks `pnpm run package:smoke` and the `electron` package's install script (`node_modules/electron/path.txt` is never written). The packaging smoke detects the stall and fails loudly instead of reporting a false success. - Copy `.env.example` to `.env`. - Set `ELEVENLABS_API_KEY` for post-recording batch transcription (used only in the main process; never exposed to the renderer). diff --git a/package.json b/package.json index 6905726..dd54318 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "loop", "version": "1.0.0", "packageManager": "pnpm@10.28.0", + "engines": { + "node": ">=20.19 <26" + }, "main": "dist/main.js", "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", diff --git a/scripts/package-smoke-run.mjs b/scripts/package-smoke-run.mjs new file mode 100644 index 0000000..6d6a6eb --- /dev/null +++ b/scripts/package-smoke-run.mjs @@ -0,0 +1,50 @@ +export async function runPackageSmoke({ packager, createOptions, fs, projectRoot, outDir }) { + await fs.rm(outDir, { recursive: true, force: true }); + + await packager(createOptions({ projectRoot, outDir })); + + // A resolved packager promise is not proof of success: require real output. + let entries; + try { + entries = await fs.readdir(outDir); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`Packaging smoke produced no output directory at ${outDir} (${reason})`, { + cause: error + }); + } + if (entries.length === 0) { + throw new Error(`Packaging smoke produced an empty output directory at ${outDir}`); + } + + await fs.rm(outDir, { recursive: true, force: true }); +} + +export function main({ proc, log = console.log, logError = console.error, ...smokeDeps }) { + // Fail-safe preset: @electron/packager's zip extraction can stall without + // rejecting (observed on Node >= 26 via extract-zip), draining the event + // loop. A drained process must exit 1, so only verified success resets this. + proc.exitCode = 1; + + const onBeforeExit = () => { + logError( + 'Packaging smoke did not complete: the event loop drained before packaging finished. ' + + 'Known cause: @electron/packager zip extraction stalls on Node >= 26. ' + + 'Use the Node version from .nvmrc.' + ); + }; + proc.once('beforeExit', onBeforeExit); + + return runPackageSmoke(smokeDeps).then( + () => { + proc.removeListener('beforeExit', onBeforeExit); + log('Packaging smoke succeeded'); + proc.exitCode = 0; + }, + (error) => { + proc.removeListener('beforeExit', onBeforeExit); + logError(error instanceof Error ? error.message : error); + proc.exitCode = 1; + } + ); +} diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index 44b122e..1d972a2 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -5,24 +5,18 @@ import { fileURLToPath } from 'node:url'; import * as packagerModule from '@electron/packager'; import { createPackagerOptions } from './package-smoke-options.mjs'; +import { main } from './package-smoke-run.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..'); const outDir = path.join(projectRoot, 'dist-smoke'); -async function run() { - const packager = packagerModule.default || packagerModule.packager || packagerModule; - - await fs.rm(outDir, { recursive: true, force: true }); - - await packager(createPackagerOptions({ projectRoot, outDir })); - - await fs.rm(outDir, { recursive: true, force: true }); - console.log('Packaging smoke succeeded'); -} - -run().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; +main({ + proc: process, + packager: packagerModule.default || packagerModule.packager || packagerModule, + createOptions: createPackagerOptions, + fs, + projectRoot, + outDir }); diff --git a/tests/integration/fixtures/package-smoke-entry.fixture.mjs b/tests/integration/fixtures/package-smoke-entry.fixture.mjs new file mode 100644 index 0000000..cf95cc8 --- /dev/null +++ b/tests/integration/fixtures/package-smoke-entry.fixture.mjs @@ -0,0 +1,40 @@ +// Test fixture: wires scripts/package-smoke-run.mjs `main` against the real +// process and filesystem, with the packager behavior selected by argv so the +// integration test can observe true process exit codes. +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { main } from '../../../scripts/package-smoke-run.mjs'; + +const [, , mode, outDir] = process.argv; + +const packagers = { + // Never settles and holds no handles, so the event loop drains — the + // exact failure mode of the extract-zip stall inside @electron/packager. + hang: () => new Promise(() => {}), + success: async (options) => { + const appDir = path.join(options.out, 'Loop-fake-arm64'); + await fs.mkdir(appDir, { recursive: true }); + await fs.writeFile(path.join(appDir, 'app.txt'), 'packaged'); + }, + empty: async () => {}, + reject: async () => { + throw new Error('packager exploded'); + } +}; + +if (!packagers[mode] || !outDir) { + console.error( + `usage: node package-smoke-entry.fixture.mjs <${Object.keys(packagers).join('|')}> ` + ); + process.exit(2); +} + +main({ + proc: process, + packager: packagers[mode], + createOptions: ({ projectRoot, outDir }) => ({ dir: projectRoot, out: outDir }), + fs, + projectRoot: path.dirname(outDir), + outDir +}); diff --git a/tests/integration/package-smoke-exit.test.mjs b/tests/integration/package-smoke-exit.test.mjs new file mode 100644 index 0000000..62543ae --- /dev/null +++ b/tests/integration/package-smoke-exit.test.mjs @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, test } from 'vitest'; + +const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'fixtures/package-smoke-entry.fixture.mjs' +); + +const tempDirs = []; + +async function createTempOutDir() { + const base = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-package-smoke-')); + tempDirs.push(base); + return path.join(base, 'dist-smoke'); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function runFixture(mode, outDir) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fixturePath, mode, outDir], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + }); +} + +describe('package smoke process exit behavior', () => { + test('exits 1 with a drain diagnostic when the packager promise never settles', async () => { + const outDir = await createTempOutDir(); + + const result = await runFixture('hang', outDir); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('did not complete'); + expect(result.stdout).not.toContain('Packaging smoke succeeded'); + }); + + test('exits 0 and logs success when packaging produces real output', async () => { + const outDir = await createTempOutDir(); + + const result = await runFixture('success', outDir); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('Packaging smoke succeeded'); + expect(result.stderr).toBe(''); + // The smoke output directory is cleaned up after verification. + await expect(fs.access(outDir)).rejects.toThrow(); + }); + + test('exits 1 when the packager resolves without producing output', async () => { + const outDir = await createTempOutDir(); + + const result = await runFixture('empty', outDir); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('no output directory'); + expect(result.stdout).not.toContain('Packaging smoke succeeded'); + }); + + test('exits 1 with the error message when the packager rejects', async () => { + const outDir = await createTempOutDir(); + + const result = await runFixture('reject', outDir); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('packager exploded'); + expect(result.stdout).not.toContain('Packaging smoke succeeded'); + }); +}); diff --git a/tests/unit/package-smoke-run.test.mjs b/tests/unit/package-smoke-run.test.mjs new file mode 100644 index 0000000..f1acc6c --- /dev/null +++ b/tests/unit/package-smoke-run.test.mjs @@ -0,0 +1,183 @@ +import { EventEmitter } from 'node:events'; + +import { describe, expect, test, vi } from 'vitest'; + +import { runPackageSmoke, main } from '../../scripts/package-smoke-run.mjs'; + +function createFsFake({ readdirEntries = ['Loop-darwin-arm64'], readdirError = null } = {}) { + const calls = []; + return { + calls, + rm: vi.fn(async (target, options) => { + calls.push(['rm', target, options]); + }), + readdir: vi.fn(async (target) => { + calls.push(['readdir', target]); + if (readdirError) { + throw readdirError; + } + return readdirEntries; + }) + }; +} + +class FakeProcess extends EventEmitter { + exitCode = undefined; +} + +describe('scripts/package-smoke-run runPackageSmoke', () => { + test('cleans, packages, verifies non-empty output, then cleans up', async () => { + const fs = createFsFake(); + const packagerCalls = []; + const packager = vi.fn(async (options) => { + packagerCalls.push(options); + fs.calls.push(['packager']); + }); + const createOptions = vi.fn(({ projectRoot, outDir }) => ({ dir: projectRoot, out: outDir })); + + await runPackageSmoke({ + packager, + createOptions, + fs, + projectRoot: '/tmp/loop', + outDir: '/tmp/loop/dist-smoke' + }); + + expect(createOptions).toHaveBeenCalledWith({ + projectRoot: '/tmp/loop', + outDir: '/tmp/loop/dist-smoke' + }); + expect(packagerCalls).toEqual([{ dir: '/tmp/loop', out: '/tmp/loop/dist-smoke' }]); + expect(fs.calls).toEqual([ + ['rm', '/tmp/loop/dist-smoke', { recursive: true, force: true }], + ['packager'], + ['readdir', '/tmp/loop/dist-smoke'], + ['rm', '/tmp/loop/dist-smoke', { recursive: true, force: true }] + ]); + }); + + test('rejects when the packager resolves but the output directory is missing', async () => { + const fs = createFsFake({ readdirError: new Error('ENOENT: no such file or directory') }); + + await expect( + runPackageSmoke({ + packager: async () => {}, + createOptions: (options) => options, + fs, + projectRoot: '/tmp/loop', + outDir: '/tmp/loop/dist-smoke' + }) + ).rejects.toThrow(/no output directory at \/tmp\/loop\/dist-smoke/); + + // The cleanup rm must not run: only the initial pre-package rm happened. + expect(fs.rm).toHaveBeenCalledTimes(1); + }); + + test('rejects when the packager resolves but the output directory is empty', async () => { + const fs = createFsFake({ readdirEntries: [] }); + + await expect( + runPackageSmoke({ + packager: async () => {}, + createOptions: (options) => options, + fs, + projectRoot: '/tmp/loop', + outDir: '/tmp/loop/dist-smoke' + }) + ).rejects.toThrow(/empty output directory at \/tmp\/loop\/dist-smoke/); + }); + + test('propagates packager rejection', async () => { + const fs = createFsFake(); + + await expect( + runPackageSmoke({ + packager: async () => { + throw new Error('packager exploded'); + }, + createOptions: (options) => options, + fs, + projectRoot: '/tmp/loop', + outDir: '/tmp/loop/dist-smoke' + }) + ).rejects.toThrow('packager exploded'); + }); +}); + +describe('scripts/package-smoke-run main', () => { + function createMainDeps({ packager, fs = createFsFake() } = {}) { + const proc = new FakeProcess(); + const log = vi.fn(); + const logError = vi.fn(); + return { + proc, + log, + logError, + packager: packager ?? (async () => {}), + createOptions: (options) => options, + fs, + projectRoot: '/tmp/loop', + outDir: '/tmp/loop/dist-smoke' + }; + } + + test('presets a failing exit code synchronously before packaging settles', () => { + const deps = createMainDeps({ packager: () => new Promise(() => {}) }); + + main(deps); + + expect(deps.proc.exitCode).toBe(1); + }); + + test('reports the drain diagnostic if the event loop drains while packaging is pending', () => { + const deps = createMainDeps({ packager: () => new Promise(() => {}) }); + + main(deps); + deps.proc.emit('beforeExit'); + + expect(deps.proc.exitCode).toBe(1); + expect(deps.logError).toHaveBeenCalledWith(expect.stringContaining('did not complete')); + expect(deps.log).not.toHaveBeenCalled(); + }); + + test('resets the exit code to 0 and logs success only after verification passes', async () => { + const deps = createMainDeps(); + + await main(deps); + + expect(deps.proc.exitCode).toBe(0); + expect(deps.log).toHaveBeenCalledWith('Packaging smoke succeeded'); + expect(deps.logError).not.toHaveBeenCalled(); + + // A later beforeExit must not report a stale drain diagnostic. + deps.proc.emit('beforeExit'); + expect(deps.logError).not.toHaveBeenCalled(); + }); + + test('keeps a failing exit code and logs the error when packaging rejects', async () => { + const deps = createMainDeps({ + packager: async () => { + throw new Error('packager exploded'); + } + }); + + await main(deps); + + expect(deps.proc.exitCode).toBe(1); + expect(deps.logError).toHaveBeenCalledWith('packager exploded'); + expect(deps.log).not.toHaveBeenCalled(); + + deps.proc.emit('beforeExit'); + expect(deps.logError).toHaveBeenCalledTimes(1); + }); + + test('keeps a failing exit code when packaging succeeds but produces no output', async () => { + const deps = createMainDeps({ fs: createFsFake({ readdirEntries: [] }) }); + + await main(deps); + + expect(deps.proc.exitCode).toBe(1); + expect(deps.logError).toHaveBeenCalledWith(expect.stringContaining('empty output directory')); + expect(deps.log).not.toHaveBeenCalled(); + }); +});