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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions docs/production/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 })\"",
Expand Down
50 changes: 50 additions & 0 deletions scripts/package-smoke-run.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
);
}
22 changes: 8 additions & 14 deletions scripts/package-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
40 changes: 40 additions & 0 deletions tests/integration/fixtures/package-smoke-entry.fixture.mjs
Original file line number Diff line number Diff line change
@@ -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('|')}> <outDir>`
);
process.exit(2);
}

main({
proc: process,
packager: packagers[mode],
createOptions: ({ projectRoot, outDir }) => ({ dir: projectRoot, out: outDir }),
fs,
projectRoot: path.dirname(outDir),
outDir
});
86 changes: 86 additions & 0 deletions tests/integration/package-smoke-exit.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading