From 905c10789a57f0039a685bcb3a6d0c3d37891087 Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Thu, 9 Jul 2026 11:02:20 +0800 Subject: [PATCH] refactor(app): migrate to ESM; preload emitted as CJS for sandbox (Phase 5d-3b) App source (main/preload/worker/renderer) -> ESM; app is now type: module; __dirname via import.meta.url; worker spawned with type module. Preload is built as CJS (electron-vite output format) because the sandboxed renderer does not support ESM preload; main loads ../preload/index.js. Removed dead imports (nativeImage, readline) and the obsolete scripts/dev.js. Tests: 4 app tests require->import; app-main-settings rewritten with node:test mock.module + dynamic import (replacing CJS Module._load mocking); test script adds --experimental-test-module-mocks. electron-vite build clean, 119/119, and npm run dev verified: app launches, preload bridges IPC, data loads. --- app/electron.vite.config.ts | 7 + app/package.json | 8 +- app/scripts/dev.js | 138 ----- app/src/main/index.js | 25 +- app/src/main/indexer-service.js | 11 +- app/src/main/indexer-worker-client.js | 11 +- app/src/main/indexer-worker.js | 4 +- app/src/main/indexer.js | 13 +- app/src/main/recap-capture-query.js | 4 +- app/src/preload/index.js | 2 +- package.json | 2 +- tests/app-indexer-service.test.mjs | 2 +- tests/app-indexer-worker-client.test.mjs | 2 +- tests/app-indexer.test.mjs | 2 +- tests/app-main-settings.test.mjs | 635 +++++++++-------------- tests/recap-capture-query.test.mjs | 2 +- 16 files changed, 313 insertions(+), 555 deletions(-) delete mode 100644 app/scripts/dev.js diff --git a/app/electron.vite.config.ts b/app/electron.vite.config.ts index 3de8ba4..59c8e42 100644 --- a/app/electron.vite.config.ts +++ b/app/electron.vite.config.ts @@ -24,6 +24,13 @@ export default defineConfig({ }, preload: { plugins: [externalizeDepsPlugin()], + build: { + rollupOptions: { + // Electron sandbox does not support ESM preload — emit CJS index.js + // (main loads ../preload/index.js) even though the project is ESM. + output: { format: 'cjs', entryFileNames: '[name].js' }, + }, + }, }, renderer: { plugins: [vue()], diff --git a/app/package.json b/app/package.json index c80b054..790c354 100644 --- a/app/package.json +++ b/app/package.json @@ -46,16 +46,13 @@ "out/**" ], "asarUnpack": [ - "**/node_modules/better-sqlite3/**" + "node_modules/better-sqlite3/**/*" ], "extraResources": [ { "from": "../scripts/schema.sql", "to": "scripts/schema.sql" } - ], - "asarUnpack": [ - "node_modules/better-sqlite3/**/*" ] }, "dependencies": { @@ -70,5 +67,6 @@ "vite": "^6.0.0", "vue": "^3.4.0", "vue-router": "^4.3.0" - } + }, + "type": "module" } diff --git a/app/scripts/dev.js b/app/scripts/dev.js deleted file mode 100644 index 5f4097e..0000000 --- a/app/scripts/dev.js +++ /dev/null @@ -1,138 +0,0 @@ -const { spawn } = require('child_process'); -const http = require('http'); -const net = require('net'); -const path = require('path'); - -const appDir = path.join(__dirname, '..'); -const binExt = process.platform === 'win32' ? '.cmd' : ''; -const viteBin = path.join(appDir, 'node_modules', '.bin', `vite${binExt}`); -const electronBin = path.join(appDir, 'node_modules', '.bin', `electron${binExt}`); -const DEFAULT_DEV_PORT = Number(process.env.OBELISK_DEV_SERVER_PORT || 5173); - -let viteProcess = null; -let electronProcess = null; -let shuttingDown = false; - -function spawnLocal(command, args, extraEnv = {}) { - return spawn(command, args, { - cwd: appDir, - stdio: 'inherit', - env: { ...process.env, ...extraEnv }, - shell: process.platform === 'win32', - }); -} - -function waitForDevServer(url, timeoutMs = 20000) { - const started = Date.now(); - return new Promise((resolve, reject) => { - const poll = () => { - const req = http.get(url, (res) => { - res.resume(); - if (res.statusCode >= 200 && res.statusCode < 400) { - resolve(); - } else { - if (Date.now() - started >= timeoutMs) { - reject(new Error(`Timed out waiting for ${url}`)); - } else { - setTimeout(poll, 250); - } - } - }); - req.on('error', () => { - if (Date.now() - started >= timeoutMs) { - reject(new Error(`Timed out waiting for ${url}`)); - return; - } - setTimeout(poll, 250); - }); - req.setTimeout(1000, () => { - req.destroy(); - }); - }; - poll(); - }); -} - -function isDevServerRunning(url) { - return new Promise((resolve) => { - const req = http.get(url, (res) => { - res.resume(); - resolve(res.statusCode >= 200 && res.statusCode < 400); - }); - req.on('error', () => resolve(false)); - req.setTimeout(1000, () => { - req.destroy(); - resolve(false); - }); - }); -} - -function isPortAvailable(port) { - return new Promise((resolve) => { - const server = net.createServer(); - server.once('error', () => resolve(false)); - server.once('listening', () => { - server.close(() => resolve(true)); - }); - server.listen(port, '127.0.0.1'); - }); -} - -async function findAvailablePort(startPort) { - for (let port = startPort; port < startPort + 20; port++) { - if (await isPortAvailable(port)) return port; - } - throw new Error(`No available port found from ${startPort} to ${startPort + 19}`); -} - -function stopChild(child) { - if (!child || child.killed) return; - child.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); -} - -function shutdown(exitCode = 0) { - if (shuttingDown) return; - shuttingDown = true; - stopChild(electronProcess); - stopChild(viteProcess); - process.exit(exitCode); -} - -async function main() { - let devPort = DEFAULT_DEV_PORT; - let devUrl = `http://127.0.0.1:${devPort}`; - const existingServer = await isDevServerRunning(devUrl); - if (existingServer) { - console.log(`Using existing Vite dev server at ${devUrl}`); - } else { - devPort = await findAvailablePort(DEFAULT_DEV_PORT); - devUrl = `http://127.0.0.1:${devPort}`; - viteProcess = spawnLocal(viteBin, ['renderer', '--host', '127.0.0.1', '--port', String(devPort), '--strictPort']); - viteProcess.on('exit', (code, signal) => { - if (!shuttingDown && !electronProcess) shutdown(code || (signal ? 1 : 0)); - }); - - try { - await waitForDevServer(devUrl); - } catch (error) { - console.error(error.message); - shutdown(1); - return; - } - } - - electronProcess = spawnLocal(electronBin, ['.', '--dev', ...process.argv.slice(2)], { - OBELISK_DEV_SERVER_URL: devUrl, - }); - electronProcess.on('exit', (code, signal) => { - shutdown(code || (signal ? 1 : 0)); - }); -} - -process.on('SIGINT', () => shutdown(0)); -process.on('SIGTERM', () => shutdown(0)); - -main().catch((error) => { - console.error(error); - shutdown(1); -}); diff --git a/app/src/main/index.js b/app/src/main/index.js index 55cc1c2..88f1700 100644 --- a/app/src/main/index.js +++ b/app/src/main/index.js @@ -1,12 +1,16 @@ -const { app, BrowserWindow, ipcMain, clipboard, dialog, nativeImage } = require('electron'); -const path = require('path'); -const os = require('os'); -const fs = require('fs'); -const Database = require('better-sqlite3'); -const { writeHeartbeat } = require('./indexer'); -const { createIndexerService } = require('./indexer-service'); -const { createWorkerBuildIndex } = require('./indexer-worker-client'); -const { buildRecapExportQuery } = require('./recap-capture-query'); +import { app, BrowserWindow, ipcMain, clipboard, dialog, shell } from 'electron'; +import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; +import chokidar from 'chokidar'; +import { writeHeartbeat } from './indexer.js'; +import { createIndexerService } from './indexer-service.js'; +import { createWorkerBuildIndex } from './indexer-worker-client.js'; +import { buildRecapExportQuery } from './recap-capture-query.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); function detectClaudeDir() { // macOS / Linux: ~/.claude @@ -283,7 +287,6 @@ let obeliskWatcher = null; function startObeliskWatcher() { if (obeliskWatcher) return obeliskWatcher; - const chokidar = require('chokidar'); if (!fs.existsSync(OBELISK_DIR)) { fs.mkdirSync(OBELISK_DIR, { recursive: true }); } @@ -488,7 +491,6 @@ ipcMain.handle('db:getMessageFullText', (_, uuid) => { if (!jsonlPath || !fs.existsSync(jsonlPath)) return null; // Scan JSONL for the message UUID and extract full text - const readline = require('readline'); const data = fs.readFileSync(jsonlPath, 'utf-8'); const lines = data.split('\n'); for (const line of lines) { @@ -809,7 +811,6 @@ ipcMain.handle('settings:browseFolder', async (event) => { }); ipcMain.handle('settings:revealPath', (_, p) => { - const { shell } = require('electron'); if (fs.existsSync(p)) shell.showItemInFolder(p); }); diff --git a/app/src/main/indexer-service.js b/app/src/main/indexer-service.js index 93203e7..2532ce7 100644 --- a/app/src/main/indexer-service.js +++ b/app/src/main/indexer-service.js @@ -1,6 +1,7 @@ -const fs = require('fs'); -const os = require('os'); -const path = require('path'); +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import chokidarModule from 'chokidar'; const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects'); const DEFAULT_DEBOUNCE_MS = 2000; @@ -38,7 +39,7 @@ function createIndexerService({ if (!name || name.endsWith('.jsonl') || name.endsWith('.json')) onChange(name); }; for (const root of existingRoots) { - const watcher = (chokidar || require('chokidar')).watch(root, { + const watcher = (chokidar || chokidarModule).watch(root, { cwd: root, ignoreInitial: true, awaitWriteFinish: { @@ -192,4 +193,4 @@ function createIndexerService({ }; } -module.exports = { createIndexerService }; +export { createIndexerService }; diff --git a/app/src/main/indexer-worker-client.js b/app/src/main/indexer-worker-client.js index 9065a63..989312f 100644 --- a/app/src/main/indexer-worker-client.js +++ b/app/src/main/indexer-worker-client.js @@ -1,5 +1,8 @@ -const path = require('path'); -const { Worker } = require('worker_threads'); +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); function createWorkerBuildIndex({ workerPath = path.join(__dirname, 'indexer-worker.js'), @@ -16,7 +19,7 @@ function createWorkerBuildIndex({ const ensureWorker = () => { if (worker) return worker; - worker = new WorkerImpl(workerPath); + worker = new WorkerImpl(workerPath, { type: 'module' }); worker.on('message', (message) => { const current = pending.get(message.id); if (!current) return; @@ -57,4 +60,4 @@ function createWorkerBuildIndex({ return { buildIndex, stop }; } -module.exports = { createWorkerBuildIndex }; +export { createWorkerBuildIndex }; diff --git a/app/src/main/indexer-worker.js b/app/src/main/indexer-worker.js index a1241d6..2650dc0 100644 --- a/app/src/main/indexer-worker.js +++ b/app/src/main/indexer-worker.js @@ -1,5 +1,5 @@ -const { parentPort } = require('worker_threads'); -const { buildIndex } = require('./indexer'); +import { parentPort } from 'node:worker_threads'; +import { buildIndex } from './indexer.js'; parentPort.on('message', ({ id, args }) => { try { diff --git a/app/src/main/indexer.js b/app/src/main/indexer.js index 51f25f0..2eaee65 100644 --- a/app/src/main/indexer.js +++ b/app/src/main/indexer.js @@ -1,7 +1,10 @@ -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const Database = require('better-sqlite3'); +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Database from 'better-sqlite3'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const TEXT_LIMIT = 10000; const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude'); @@ -1161,7 +1164,7 @@ function buildIndex({ } } -module.exports = { +export { buildIndex, writeHeartbeat, openIndexDb, diff --git a/app/src/main/recap-capture-query.js b/app/src/main/recap-capture-query.js index ea39c3b..4e037c4 100644 --- a/app/src/main/recap-capture-query.js +++ b/app/src/main/recap-capture-query.js @@ -1,4 +1,4 @@ -const path = require('path'); +import path from 'node:path'; function cleanRecapFilename(filename) { if (!filename) return ''; @@ -15,4 +15,4 @@ function buildRecapExportQuery({ cardIdx = 0, archetype = '', filename = '' } = return params.toString(); } -module.exports = { buildRecapExportQuery, cleanRecapFilename }; +export { buildRecapExportQuery, cleanRecapFilename }; diff --git a/app/src/preload/index.js b/app/src/preload/index.js index cc9e3a3..c03fcd5 100644 --- a/app/src/preload/index.js +++ b/app/src/preload/index.js @@ -1,4 +1,4 @@ -const { contextBridge, ipcRenderer } = require('electron'); +import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('obelisk', { getSessions: (opts) => ipcRenderer.invoke('db:getSessions', opts), diff --git a/package.json b/package.json index 8550393..21af195 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "description": "Explicit memory infrastructure for coding agents — a queryable SQLite evidence layer over local Claude Code and Codex history, plus human-approved durable memory.", "license": "AGPL-3.0", "scripts": { - "test": "node --test tests/*.test.mjs", + "test": "node --experimental-test-module-mocks --test tests/*.test.mjs", "typecheck": "tsc --noEmit", "lint": "eslint .", "build:core": "rm -rf dist && tsc -p tsconfig.build.json" diff --git a/tests/app-indexer-service.test.mjs b/tests/app-indexer-service.test.mjs index 1acde12..113adbb 100644 --- a/tests/app-indexer-service.test.mjs +++ b/tests/app-indexer-service.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const require = createRequire(import.meta.url); -const { createIndexerService } = require('../app/src/main/indexer-service.js'); +import { createIndexerService } from '../app/src/main/indexer-service.js'; function manualTimers() { const timers = new Set(); diff --git a/tests/app-indexer-worker-client.test.mjs b/tests/app-indexer-worker-client.test.mjs index 1c559c5..83233e1 100644 --- a/tests/app-indexer-worker-client.test.mjs +++ b/tests/app-indexer-worker-client.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const { createWorkerBuildIndex } = require('../app/src/main/indexer-worker-client.js'); +import { createWorkerBuildIndex } from '../app/src/main/indexer-worker-client.js'; test('worker build client resolves build results from a worker thread', async () => { const instances = []; diff --git a/tests/app-indexer.test.mjs b/tests/app-indexer.test.mjs index f17e82c..1095c85 100644 --- a/tests/app-indexer.test.mjs +++ b/tests/app-indexer.test.mjs @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; const require = createRequire(import.meta.url); -const { buildIndex } = require('../app/src/main/indexer.js'); +import { buildIndex } from '../app/src/main/indexer.js'; const { DatabaseSync } = require('node:sqlite'); class TestDatabase { diff --git a/tests/app-main-settings.test.mjs b/tests/app-main-settings.test.mjs index 72f681d..fb99aa0 100644 --- a/tests/app-main-settings.test.mjs +++ b/tests/app-main-settings.test.mjs @@ -1,22 +1,108 @@ -import { test } from 'node:test'; +import { test, mock } from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import Module from 'node:module'; const require = createRequire(import.meta.url); +// The app main process is now an ES module. It runs side-effectfully on import +// (registers ipcMain handlers, opens windows, etc.) and has no exports, so we +// mock its ESM dependencies with node:test's `mock.module` and load it via a +// cache-busted dynamic import. +// +// `mock.module` keys mocks by the *resolved* module URL. The app's dependencies +// live in `app/node_modules`, so they are NOT resolvable from this test file's +// directory, and bare specifiers ('electron', ...) would either fail to resolve +// here or resolve to the wrong ESM entry (e.g. chokidar exposes esm/index.js via +// its "exports" map, which differs from require.resolve's CJS entry). We instead +// resolve each bare specifier exactly as the main module sees it (ESM resolution +// relative to the main module's directory) and mock that URL. Relative deps are +// resolved against the main module URL directly. +const mainUrl = new URL('../app/src/main/index.js', import.meta.url); +const mainPath = fileURLToPath(mainUrl); +const mainDir = fileURLToPath(new URL('.', mainUrl)); + +function esmResolve(specifier) { + return execFileSync( + process.execPath, + ['--input-type=module', '-e', `process.stdout.write(import.meta.resolve(${JSON.stringify(specifier)}))`], + { cwd: mainDir, encoding: 'utf8' }, + ).trim(); +} + +const ELECTRON_URL = esmResolve('electron'); +const DATABASE_URL = esmResolve('better-sqlite3'); +const CHOKIDAR_URL = esmResolve('chokidar'); +const INDEXER_URL = new URL('./indexer.js', mainUrl).href; +const INDEXER_SERVICE_URL = new URL('./indexer-service.js', mainUrl).href; +const INDEXER_WORKER_URL = new URL('./indexer-worker-client.js', mainUrl).href; + +let importCounter = 0; + +// Registers the given [specifier, options] mocks and returns a restore fn. +function registerMocks(defs) { + const contexts = defs.map(([spec, opts]) => mock.module(spec, opts)); + return () => { + for (const ctx of contexts) ctx.restore(); + mock.reset(); + }; +} + +// Fresh evaluation of the main module every call (cache-busted query string). +async function importMain() { + await import(`${mainUrl.href}?t=${++importCounter}-${Date.now()}`); + await new Promise(resolve => setImmediate(resolve)); +} + +// Electron named-export namespace. ESM named imports are validated at load time, +// so every export the app imports ('app', 'BrowserWindow', 'ipcMain', 'clipboard', +// 'dialog', 'nativeImage', 'shell') must be present, even if unused by a test. +function electronNamespace({ app, BrowserWindow, ipcMain }) { + return { + app: app ?? { whenReady: () => Promise.resolve(), on() {}, quit() {} }, + BrowserWindow, + ipcMain: ipcMain ?? { handle() {} }, + clipboard: {}, + dialog: {}, + nativeImage: {}, + shell: {}, + }; +} + +function noopChokidar() { + return { watch: () => ({ on() { return this; }, close() {} }) }; +} + +function defaultIndexerService() { + return { + createIndexerService: () => ({ + start() {}, + stop() {}, + idle: async () => {}, + runBuildNow() { return Promise.resolve(); }, + }), + }; +} + +function defaultIndexerWorkerClient() { + return { + createWorkerBuildIndex: () => ({ + buildIndex: async () => ({ files: 0, affectedSessionIds: [] }), + stop() {}, + }), + }; +} + async function loadMainForWindowFlags(flags) { const originalArgv = process.argv; const originalHome = process.env.HOME; - const originalLoad = Module._load; - const mainPath = require.resolve('../app/src/main/index.js'); const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`); mkdirSync(join(home, '.obelisk'), { recursive: true }); writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), ''); - delete require.cache[mainPath]; process.env.HOME = home; process.argv = [originalArgv[0] || 'node', originalArgv[1] || 'electron', ...flags]; @@ -51,54 +137,20 @@ async function loadMainForWindowFlags(flags) { static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, - BrowserWindow: FakeBrowserWindow, - ipcMain: { handle() {} }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { - createIndexerService: () => ({ - start() {}, - stop() {}, - idle: async () => {}, - runBuildNow() { return Promise.resolve(); }, - }), - }; - } - if (request === './indexer-worker-client') { - return { - createWorkerBuildIndex: () => ({ - buildIndex: async () => ({ files: 0, affectedSessionIds: [] }), - stop() {}, - }), - }; - } - if (request === 'chokidar') { - return { watch: () => ({ on() { return this; }, close() {} }) }; - } - return originalLoad.call(this, request, parent, isMain); - }; + const restore = registerMocks([ + [ELECTRON_URL, { namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow }) }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }], + [INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); return windows; } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.argv = originalArgv; process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); @@ -125,7 +177,6 @@ test('dev mode does not open DevTools unless explicitly requested', async () => }); test('main process watches Codex sessions directory instead of Codex root', async () => { - const originalLoad = Module._load; const originalHome = process.env.HOME; const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`); const claudeDir = join(home, '.claude'); @@ -137,8 +188,6 @@ test('main process watches Codex sessions directory instead of Codex root', asyn process.env.HOME = home; const serviceOptions = []; - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; class FakeDatabase { pragma() {} @@ -159,25 +208,13 @@ test('main process watches Codex sessions directory instead of Codex root', asyn static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, - BrowserWindow: FakeBrowserWindow, - ipcMain: { handle() {} }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { + const restore = registerMocks([ + [ELECTRON_URL, { namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow }) }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { + namedExports: { createIndexerService: (options) => { serviceOptions.push(options); return { @@ -187,23 +224,13 @@ test('main process watches Codex sessions directory instead of Codex root', asyn runBuildNow() { return Promise.resolve(); }, }; }, - }; - } - if (request === './indexer-worker-client') { - return { - createWorkerBuildIndex: () => ({ - buildIndex: async () => ({ files: 0, affectedSessionIds: [] }), - stop() {}, - }), - }; - } - if (request === 'chokidar') return { watch: () => ({ on() { return this; }, close() {} }) }; - return originalLoad.call(this, request, parent, isMain); - }; + }, + }], + [INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); assert.equal(serviceOptions.length, 1); assert.deepEqual(serviceOptions[0].watchDirs, [ @@ -212,15 +239,13 @@ test('main process watches Codex sessions directory instead of Codex root', asyn ]); assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); } }); test('session IPC hides Codex rows by default and supports explicit source opt-in', async () => { - const originalLoad = Module._load; const originalHome = process.env.HOME; const home = join(tmpdir(), `obelisk-main-source-filter-${Date.now()}`); mkdirSync(join(home, '.obelisk'), { recursive: true }); @@ -229,8 +254,6 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i const ipcHandlers = new Map(); const queries = []; - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; class FakeDatabase { pragma() {} @@ -261,52 +284,26 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, + const restore = registerMocks([ + [ELECTRON_URL, { + namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow, ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); }, }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { - createIndexerService: () => ({ - start() {}, - stop() {}, - idle: async () => {}, - runBuildNow() { return Promise.resolve(); }, - }), - }; - } - if (request === './indexer-worker-client') { - return { - createWorkerBuildIndex: () => ({ - buildIndex: async () => ({ files: 0, affectedSessionIds: [] }), - stop() {}, - }), - }; - } - if (request === 'chokidar') return { watch: () => ({ on() { return this; }, close() {} }) }; - return originalLoad.call(this, request, parent, isMain); - }; + }), + }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }], + [INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); ipcHandlers.get('db:getSessions')(null, {}); assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/); @@ -329,15 +326,13 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i queries.some(q => /MAX\(started_at\) as t FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)), ); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); } }); test('main process migrates an existing app database before source-filtered IPC queries', async () => { - const originalLoad = Module._load; const originalHome = process.env.HOME; const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`); const obeliskDir = join(home, '.obelisk'); @@ -373,8 +368,31 @@ test('main process migrates an existing app database before source-filtered IPC legacy.close(); const ipcHandlers = new Map(); - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; + + // better-sqlite3-compatible adapter over node:sqlite so the real migration + // logic (ALTER TABLE ADD COLUMN source, etc.) runs against a real database. + class SqliteCompatDatabase { + constructor(dbFile) { + this.db = new DatabaseSync(dbFile); + } + pragma(statement) { + this.db.exec(`PRAGMA ${statement}`); + } + exec(sql) { + return this.db.exec(sql); + } + close() { + return this.db.close(); + } + prepare(sql) { + const stmt = this.db.prepare(sql); + return { + all: (...params) => stmt.all(...params), + get: (...params) => stmt.get(...params), + run: (...params) => stmt.run(...params), + }; + } + } class FakeBrowserWindow { constructor() { @@ -387,75 +405,26 @@ test('main process migrates an existing app database before source-filtered IPC static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, + const restore = registerMocks([ + [ELECTRON_URL, { + namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow, ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); }, }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') { - return class SqliteCompatDatabase { - constructor(dbFile) { - this.db = new DatabaseSync(dbFile); - } - pragma(statement) { - this.db.exec(`PRAGMA ${statement}`); - } - exec(sql) { - return this.db.exec(sql); - } - close() { - return this.db.close(); - } - prepare(sql) { - const stmt = this.db.prepare(sql); - return { - all: (...params) => stmt.all(...params), - get: (...params) => stmt.get(...params), - run: (...params) => stmt.run(...params), - }; - } - }; - } - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { - createIndexerService: () => ({ - start() {}, - stop() {}, - idle: async () => {}, - runBuildNow() { return Promise.resolve(); }, - }), - }; - } - if (request === './indexer-worker-client') { - return { - createWorkerBuildIndex: () => ({ - buildIndex: async () => ({ files: 0, affectedSessionIds: [] }), - stop() {}, - }), - }; - } - if (request === 'chokidar') return { watch: () => ({ on() { return this; }, close() {} }) }; - return originalLoad.call(this, request, parent, isMain); - }; + }), + }], + [DATABASE_URL, { defaultExport: SqliteCompatDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }], + [INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); const sessions = ipcHandlers.get('db:getSessions')(null, {}); assert.equal(sessions[0].id, 'legacy-session'); @@ -466,22 +435,18 @@ test('main process migrates an existing app database before source-filtered IPC memoriesArchived: 0, }); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); } }); test('closing the last macOS window releases background resources until activation', async () => { - const originalLoad = Module._load; const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); const originalHome = process.env.HOME; const home = join(tmpdir(), `obelisk-main-window-${Date.now()}`); mkdirSync(join(home, '.obelisk'), { recursive: true }); writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), ''); - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; process.env.HOME = home; Object.defineProperty(process, 'platform', { value: 'darwin' }); @@ -512,57 +477,51 @@ test('closing the last macOS window releases background resources until activati static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { + const restore = registerMocks([ + [ELECTRON_URL, { + namedExports: electronNamespace({ app: { whenReady: () => Promise.resolve(), on(event, handler) { appHandlers.set(event, handler); }, quit() { quitCalled = true; }, }, BrowserWindow: FakeBrowserWindow, - ipcMain: { handle() {} }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { + }), + }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { + defaultExport: { + watch: () => { + const watcher = { on() { return this; }, close() { serviceEvents.push('watcher-close'); } }; + watchers.push(watcher); + return watcher; + }, + }, + }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { + namedExports: { createIndexerService: () => ({ start() { serviceEvents.push('service-start'); }, stop() { serviceEvents.push('service-stop'); }, idle: async () => { serviceEvents.push('service-idle'); }, runBuildNow() { serviceEvents.push('service-build'); return Promise.resolve(); }, }), - }; - } - if (request === './indexer-worker-client') { - return { + }, + }], + [INDEXER_WORKER_URL, { + namedExports: { createWorkerBuildIndex: () => { const worker = { stop() { serviceEvents.push('worker-stop'); } }; workers.push(worker); return worker; }, - }; - } - if (request === 'chokidar') { - return { - watch: () => { - const watcher = { on() { return this; }, close() { serviceEvents.push('watcher-close'); } }; - watchers.push(watcher); - return watcher; - }, - }; - } - return originalLoad.call(this, request, parent, isMain); - }; + }, + }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); assert.equal(windows.length, 1); assert.equal(workers.length, 1); @@ -586,8 +545,7 @@ test('closing the last macOS window releases background resources until activati assert.equal(watchers.length, 2); assert.equal(serviceEvents.filter(e => e === 'service-start').length, 2); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform); rmSync(home, { recursive: true, force: true }); @@ -617,9 +575,6 @@ test('settings rebuild reopens the database from the configured Claude path', as const openedDbPaths = []; const buildCalls = []; const serviceEvents = []; - const originalLoad = Module._load; - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; class FakeDatabase { constructor(dbPath) { @@ -634,57 +589,41 @@ test('settings rebuild reopens the database from the configured Claude path', as class FakeBrowserWindow { constructor() { - this.webContents = { - on() {}, - setZoomLevel() {}, - openDevTools() {}, - send() {}, - }; + this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} }; } loadFile() {} loadURL() {} close() {} - static getAllWindows() { - return []; - } - static fromWebContents() { - return null; - } + static getAllWindows() { return []; } + static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, + const restore = registerMocks([ + [ELECTRON_URL, { + namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow, ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); }, }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { + }), + }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { + namedExports: { createIndexerService: () => ({ start() { serviceEvents.push('start'); }, stop() { serviceEvents.push('stop'); }, idle: async () => { serviceEvents.push('idle'); }, runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); }, }), - }; - } - if (request === './indexer-worker-client') { - return { + }, + }], + [INDEXER_WORKER_URL, { + namedExports: { createWorkerBuildIndex: () => ({ buildIndex: async (args) => { serviceEvents.push('build'); @@ -694,17 +633,12 @@ test('settings rebuild reopens the database from the configured Claude path', as }, stop() { return Promise.resolve(); }, }), - }; - } - if (request === 'chokidar') { - return { watch: () => ({ on() { return this; }, close() {} }) }; - } - return originalLoad.call(this, request, parent, isMain); - }; + }, + }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); const rebuild = ipcHandlers.get('settings:rebuildIndex'); assert.equal(typeof rebuild, 'function'); @@ -722,8 +656,7 @@ test('settings rebuild reopens the database from the configured Claude path', as ); assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop')); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); } @@ -749,9 +682,6 @@ test('settings rebuild keeps the existing database after a worker failure', asyn const openedDbPaths = []; const closedDbPaths = []; const serviceEvents = []; - const originalLoad = Module._load; - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; class FakeDatabase { constructor(dbPath) { @@ -767,57 +697,41 @@ test('settings rebuild keeps the existing database after a worker failure', asyn class FakeBrowserWindow { constructor() { - this.webContents = { - on() {}, - setZoomLevel() {}, - openDevTools() {}, - send() {}, - }; + this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} }; } loadFile() {} loadURL() {} close() {} - static getAllWindows() { - return []; - } - static fromWebContents() { - return null; - } + static getAllWindows() { return []; } + static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, + const restore = registerMocks([ + [ELECTRON_URL, { + namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow, ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); }, }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { + }), + }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { + namedExports: { createIndexerService: () => ({ start() { serviceEvents.push('start'); }, stop() { serviceEvents.push('stop'); }, idle: async () => { serviceEvents.push('idle'); }, runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); }, }), - }; - } - if (request === './indexer-worker-client') { - return { + }, + }], + [INDEXER_WORKER_URL, { + namedExports: { createWorkerBuildIndex: () => ({ buildIndex: async () => { serviceEvents.push('build'); @@ -825,17 +739,12 @@ test('settings rebuild keeps the existing database after a worker failure', asyn }, stop() {}, }), - }; - } - if (request === 'chokidar') { - return { watch: () => ({ on() { return this; }, close() {} }) }; - } - return originalLoad.call(this, request, parent, isMain); - }; + }, + }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); const rebuild = ipcHandlers.get('settings:rebuildIndex'); const openCountBeforeRebuild = openedDbPaths.length; @@ -852,8 +761,7 @@ test('settings rebuild keeps the existing database after a worker failure', asyn assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop')); assert.ok(serviceEvents.lastIndexOf('start') > serviceEvents.indexOf('build')); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); } @@ -877,9 +785,7 @@ test('settings rebuild cancels an in-flight background build instead of waiting const ipcHandlers = new Map(); const serviceEvents = []; - const originalLoad = Module._load; - const mainPath = require.resolve('../app/src/main/index.js'); - delete require.cache[mainPath]; + let buildIndexCalls = 0; class FakeDatabase { pragma() {} @@ -891,58 +797,41 @@ test('settings rebuild cancels an in-flight background build instead of waiting class FakeBrowserWindow { constructor() { - this.webContents = { - on() {}, - setZoomLevel() {}, - openDevTools() {}, - send() {}, - }; + this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} }; } loadFile() {} loadURL() {} close() {} - static getAllWindows() { - return []; - } - static fromWebContents() { - return null; - } + static getAllWindows() { return []; } + static fromWebContents() { return null; } } - Module._load = function patchedLoad(request, parent, isMain) { - if (request === 'electron') { - return { - app: { - whenReady: () => Promise.resolve(), - on() {}, - quit() {}, - }, + const restore = registerMocks([ + [ELECTRON_URL, { + namedExports: electronNamespace({ BrowserWindow: FakeBrowserWindow, ipcMain: { handle(channel, handler) { ipcHandlers.set(channel, handler); }, }, - clipboard: {}, - dialog: {}, - nativeImage: {}, - }; - } - if (request === 'better-sqlite3') return FakeDatabase; - if (request === './indexer') return { writeHeartbeat() {} }; - if (request === './indexer-service') { - return { + }), + }], + [DATABASE_URL, { defaultExport: FakeDatabase }], + [CHOKIDAR_URL, { defaultExport: noopChokidar() }], + [INDEXER_URL, { namedExports: { writeHeartbeat() {} } }], + [INDEXER_SERVICE_URL, { + namedExports: { createIndexerService: () => ({ start() { serviceEvents.push('start'); }, stop() { serviceEvents.push('stop'); }, idle: async () => new Promise(() => {}), runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); }, }), - }; - } - if (request === './indexer-worker-client') { - let buildIndexCalls = 0; - return { + }, + }], + [INDEXER_WORKER_URL, { + namedExports: { createWorkerBuildIndex: () => ({ buildIndex: async (args) => { serviceEvents.push(`build-${++buildIndexCalls}`); @@ -954,17 +843,12 @@ test('settings rebuild cancels an in-flight background build instead of waiting return Promise.resolve(); }, }), - }; - } - if (request === 'chokidar') { - return { watch: () => ({ on() { return this; }, close() {} }) }; - } - return originalLoad.call(this, request, parent, isMain); - }; + }, + }], + ]); try { - require('../app/src/main/index.js'); - await new Promise(resolve => setImmediate(resolve)); + await importMain(); const rebuild = ipcHandlers.get('settings:rebuildIndex'); const outcome = await Promise.race([ @@ -976,8 +860,7 @@ test('settings rebuild cancels an in-flight background build instead of waiting assert.ok(serviceEvents.indexOf('worker-stop') > serviceEvents.indexOf('stop')); assert.ok(serviceEvents.some(event => event.startsWith('build-'))); } finally { - Module._load = originalLoad; - delete require.cache[mainPath]; + restore(); process.env.HOME = originalHome; rmSync(home, { recursive: true, force: true }); } diff --git a/tests/recap-capture-query.test.mjs b/tests/recap-capture-query.test.mjs index af0c750..1d7d4dd 100644 --- a/tests/recap-capture-query.test.mjs +++ b/tests/recap-capture-query.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const { buildRecapExportQuery, cleanRecapFilename } = require('../app/src/main/recap-capture-query.js'); +import { buildRecapExportQuery, cleanRecapFilename } from '../app/src/main/recap-capture-query.js'; test('recap export query includes the selected recap filename', () => { const query = buildRecapExportQuery({