2026-07-09 11:02:20 +08:00
|
|
|
import { test, mock } from 'node:test';
|
2026-07-08 16:11:12 +08:00
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { createRequire } from 'node:module';
|
2026-07-09 11:02:20 +08:00
|
|
|
import { execFileSync } from 'node:child_process';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
2026-07-08 16:11:12 +08:00
|
|
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
|
2026-07-12 00:28:17 +08:00
|
|
|
import { acquireWriterLease } from '../packages/core/src/writer-lease.ts';
|
2026-07-10 18:10:45 +08:00
|
|
|
|
2026-07-08 16:11:12 +08:00
|
|
|
const require = createRequire(import.meta.url);
|
2026-07-10 18:10:45 +08:00
|
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-08 16:11:12 +08:00
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
// 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.
|
2026-07-09 16:25:59 +08:00
|
|
|
const mainUrl = new URL('../app/src/main/index.ts', import.meta.url);
|
2026-07-09 11:02:20 +08:00
|
|
|
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');
|
2026-07-09 16:25:59 +08:00
|
|
|
const INDEXER_URL = new URL('./indexer.ts', mainUrl).href;
|
|
|
|
|
const INDEXER_SERVICE_URL = new URL('./indexer-service.ts', mainUrl).href;
|
|
|
|
|
const INDEXER_WORKER_URL = new URL('./indexer-worker-client.ts', mainUrl).href;
|
2026-07-09 11:02:20 +08:00
|
|
|
|
|
|
|
|
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() {},
|
|
|
|
|
}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 16:11:12 +08:00
|
|
|
async function loadMainForWindowFlags(flags) {
|
|
|
|
|
const originalArgv = process.argv;
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
const home = join(tmpdir(), `obelisk-window-flags-${Date.now()}-${Math.random()}`);
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
process.argv = [originalArgv[0] || 'node', originalArgv[1] || 'electron', ...flags];
|
|
|
|
|
|
|
|
|
|
const windows = [];
|
|
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec() {}
|
2026-07-08 16:11:12 +08:00
|
|
|
close() {}
|
|
|
|
|
prepare() {
|
|
|
|
|
return { get: () => null, all: () => [], run: () => ({}) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor(options) {
|
|
|
|
|
this.options = options;
|
|
|
|
|
this.loadedURL = null;
|
|
|
|
|
this.loadedFile = null;
|
|
|
|
|
this.devToolsOpened = false;
|
|
|
|
|
this.webContents = {
|
|
|
|
|
on() {},
|
|
|
|
|
setZoomLevel() {},
|
|
|
|
|
openDevTools: () => { this.devToolsOpened = true; },
|
|
|
|
|
send() {},
|
|
|
|
|
};
|
|
|
|
|
windows.push(this);
|
|
|
|
|
}
|
|
|
|
|
loadFile(filePath) { this.loadedFile = filePath; }
|
|
|
|
|
loadURL(url) { this.loadedURL = url; return Promise.resolve(); }
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return windows; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
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() }],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
return windows;
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.argv = originalArgv;
|
|
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('dev mode does not open DevTools unless explicitly requested', async () => {
|
|
|
|
|
const packagedWindows = await loadMainForWindowFlags([]);
|
|
|
|
|
assert.equal(packagedWindows.length, 1);
|
|
|
|
|
assert.equal(packagedWindows[0].loadedURL, null);
|
|
|
|
|
assert.equal(packagedWindows[0].options.webPreferences.devTools, false);
|
|
|
|
|
assert.equal(packagedWindows[0].devToolsOpened, false);
|
|
|
|
|
|
|
|
|
|
const devWindows = await loadMainForWindowFlags(['--dev']);
|
|
|
|
|
assert.equal(devWindows.length, 1);
|
|
|
|
|
assert.equal(devWindows[0].loadedURL, 'http://localhost:5173');
|
|
|
|
|
assert.equal(devWindows[0].options.webPreferences.devTools, true);
|
|
|
|
|
assert.equal(devWindows[0].devToolsOpened, false);
|
|
|
|
|
|
|
|
|
|
const devtoolsWindows = await loadMainForWindowFlags(['--dev', '--devtools']);
|
|
|
|
|
assert.equal(devtoolsWindows.length, 1);
|
|
|
|
|
assert.equal(devtoolsWindows[0].loadedURL, 'http://localhost:5173');
|
|
|
|
|
assert.equal(devtoolsWindows[0].devToolsOpened, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('main process watches Codex sessions directory instead of Codex root', async () => {
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-watch-dirs-${Date.now()}`);
|
|
|
|
|
const claudeDir = join(home, '.claude');
|
|
|
|
|
const codexDir = join(home, '.codex');
|
|
|
|
|
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
|
|
|
|
mkdirSync(join(codexDir, 'sessions'), { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const serviceOptions = [];
|
|
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec() {}
|
2026-07-08 16:11:12 +08:00
|
|
|
close() {}
|
|
|
|
|
prepare() {
|
|
|
|
|
return { get: () => null, all: () => [], run: () => ({}) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
|
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
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: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createIndexerService: (options) => {
|
|
|
|
|
serviceOptions.push(options);
|
|
|
|
|
return {
|
|
|
|
|
start() {},
|
|
|
|
|
stop() {},
|
|
|
|
|
idle: async () => {},
|
|
|
|
|
runBuildNow() { return Promise.resolve(); },
|
|
|
|
|
};
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
assert.equal(serviceOptions.length, 1);
|
|
|
|
|
assert.deepEqual(serviceOptions[0].watchDirs, [
|
|
|
|
|
join(claudeDir, 'projects'),
|
|
|
|
|
join(codexDir, 'sessions'),
|
|
|
|
|
]);
|
|
|
|
|
assert.equal(serviceOptions[0].watchDirs.includes(codexDir), false);
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
test('main process forwards committed IDs without reopening after a deferred build', async () => {
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-deferred-build-${Date.now()}`);
|
|
|
|
|
mkdirSync(join(home, '.claude', 'projects'), { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.codex', 'sessions'), { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
let databaseOpens = 0;
|
|
|
|
|
let serviceOptions;
|
|
|
|
|
let notifications = 0;
|
|
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
constructor() { databaseOpens += 1; }
|
|
|
|
|
pragma() {}
|
|
|
|
|
exec() {}
|
|
|
|
|
close() {}
|
|
|
|
|
prepare() { return { get: () => null, all: () => [], run: () => ({}) }; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() { notifications += 1; } };
|
|
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return [{ webContents: { send() { notifications += 1; } } }]; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 = options;
|
|
|
|
|
return { start() {}, stop() {}, idle: async () => {}, runBuildNow() { return Promise.resolve(); } };
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
[INDEXER_WORKER_URL, {
|
|
|
|
|
namedExports: {
|
|
|
|
|
createWorkerBuildIndex: () => ({
|
|
|
|
|
buildIndex: async () => ({ deferred: true, reason: 'database_busy', affectedSessionIds: ['session-1'] }),
|
|
|
|
|
stop() {},
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await importMain();
|
|
|
|
|
const opensBeforeBuild = databaseOpens;
|
|
|
|
|
const notificationsBeforeBuild = notifications;
|
|
|
|
|
const result = await serviceOptions.buildIndex({ reason: 'writer-lease' });
|
|
|
|
|
|
|
|
|
|
assert.equal(result.deferred, true);
|
|
|
|
|
assert.equal(databaseOpens, opensBeforeBuild);
|
|
|
|
|
assert.equal(notifications, notificationsBeforeBuild + 2);
|
|
|
|
|
} finally {
|
|
|
|
|
restore();
|
|
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-08 16:11:12 +08:00
|
|
|
test('session IPC hides Codex rows by default and supports explicit source opt-in', async () => {
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-source-filter-${Date.now()}`);
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const ipcHandlers = new Map();
|
|
|
|
|
const queries = [];
|
|
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec() {}
|
2026-07-08 16:11:12 +08:00
|
|
|
close() {}
|
|
|
|
|
prepare(sql) {
|
|
|
|
|
return {
|
|
|
|
|
all: (...params) => {
|
|
|
|
|
queries.push({ sql, params });
|
|
|
|
|
return [];
|
|
|
|
|
},
|
|
|
|
|
get: (...params) => {
|
|
|
|
|
queries.push({ sql, params });
|
|
|
|
|
return null;
|
|
|
|
|
},
|
|
|
|
|
run: () => ({}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
|
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
2026-07-08 16:11:12 +08:00
|
|
|
BrowserWindow: FakeBrowserWindow,
|
|
|
|
|
ipcMain: {
|
|
|
|
|
handle(channel, handler) {
|
|
|
|
|
ipcHandlers.set(channel, handler);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[DATABASE_URL, { defaultExport: FakeDatabase }],
|
|
|
|
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
|
|
|
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
|
|
|
|
[INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }],
|
|
|
|
|
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
ipcHandlers.get('db:getSessions')(null, {});
|
|
|
|
|
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
|
|
|
|
|
|
|
|
|
ipcHandlers.get('db:getProjects')(null, {});
|
|
|
|
|
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
|
|
|
|
|
|
|
|
|
ipcHandlers.get('db:getSessions')(null, { includeCodex: true });
|
|
|
|
|
assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
|
|
|
|
|
|
|
|
|
ipcHandlers.get('db:getSessions')(null, { source: 'codex' });
|
|
|
|
|
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = \?/);
|
|
|
|
|
assert.ok(queries.at(-1).params.includes('codex'));
|
|
|
|
|
|
|
|
|
|
await ipcHandlers.get('settings:get')();
|
|
|
|
|
assert.ok(
|
|
|
|
|
queries.some(q => /COUNT\(\*\) as c FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)),
|
|
|
|
|
);
|
|
|
|
|
assert.ok(
|
|
|
|
|
queries.some(q => /MAX\(started_at\) as t FROM sessions WHERE COALESCE\(source, 'claude'\) = 'claude'/.test(q.sql)),
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
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 originalHome = process.env.HOME;
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);
|
|
|
|
|
const obeliskDir = join(home, '.obelisk');
|
|
|
|
|
mkdirSync(obeliskDir, { recursive: true });
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
|
|
|
const dbPath = join(obeliskDir, 'obelisk.sqlite');
|
|
|
|
|
const legacy = new DatabaseSync(dbPath);
|
|
|
|
|
legacy.exec(`
|
|
|
|
|
CREATE TABLE sessions (
|
|
|
|
|
id TEXT PRIMARY KEY, title TEXT, project TEXT, project_path TEXT,
|
|
|
|
|
started_at TEXT, ended_at TEXT, git_branch TEXT, version TEXT,
|
|
|
|
|
message_count INTEGER DEFAULT 0, jsonl_path TEXT
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE messages (
|
|
|
|
|
uuid TEXT PRIMARY KEY, session_id TEXT, type TEXT, parent_uuid TEXT,
|
|
|
|
|
timestamp TEXT, role TEXT, text TEXT, content_type TEXT,
|
|
|
|
|
is_meta INTEGER DEFAULT 0, model TEXT,
|
|
|
|
|
is_sidechain INTEGER DEFAULT 0, agent_id TEXT,
|
|
|
|
|
input_tokens INTEGER, output_tokens INTEGER,
|
|
|
|
|
cwd TEXT, skill TEXT, turn_duration_ms INTEGER
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE memories (
|
|
|
|
|
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
|
|
|
|
|
message_start TEXT, message_end TEXT,
|
|
|
|
|
path TEXT, anchors TEXT, summary TEXT, created_at TEXT,
|
|
|
|
|
deleted_at TEXT, deleted_reason TEXT
|
|
|
|
|
);
|
|
|
|
|
INSERT INTO sessions (id, title, project, started_at, message_count)
|
|
|
|
|
VALUES ('legacy-session', 'Legacy session', 'quiet-zero', '2026-06-10T10:00:00Z', 1);
|
|
|
|
|
`);
|
|
|
|
|
legacy.close();
|
|
|
|
|
|
|
|
|
|
const ipcHandlers = new Map();
|
2026-07-09 11:02:20 +08:00
|
|
|
|
2026-07-08 16:11:12 +08:00
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
|
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
2026-07-08 16:11:12 +08:00
|
|
|
BrowserWindow: FakeBrowserWindow,
|
|
|
|
|
ipcMain: {
|
|
|
|
|
handle(channel, handler) {
|
|
|
|
|
ipcHandlers.set(channel, handler);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[DATABASE_URL, { defaultExport: SqliteCompatDatabase }],
|
|
|
|
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
|
|
|
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
|
|
|
|
[INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }],
|
|
|
|
|
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
const sessions = ipcHandlers.get('db:getSessions')(null, {});
|
|
|
|
|
assert.equal(sessions[0].id, 'legacy-session');
|
|
|
|
|
assert.equal(sessions[0].source, 'claude');
|
|
|
|
|
assert.deepEqual(ipcHandlers.get('db:getStats')(null, {}), {
|
|
|
|
|
sessions: 1,
|
|
|
|
|
memories: 0,
|
|
|
|
|
memoriesArchived: 0,
|
|
|
|
|
});
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
test('main process keeps schema and memory mutations behind the writer lease', async () => {
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-migration-lease-${Date.now()}`);
|
|
|
|
|
const obeliskDir = join(home, '.obelisk');
|
|
|
|
|
const dbPath = join(obeliskDir, 'obelisk.sqlite');
|
|
|
|
|
mkdirSync(obeliskDir, { recursive: true });
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const legacy = new DatabaseSync(dbPath);
|
|
|
|
|
legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)');
|
|
|
|
|
legacy.close();
|
|
|
|
|
|
|
|
|
|
const holder = acquireWriterLease({
|
|
|
|
|
lockPath: join(obeliskDir, 'writer.lock.sqlite'),
|
|
|
|
|
openDb: lockPath => new DatabaseSync(lockPath),
|
|
|
|
|
});
|
|
|
|
|
assert.ok(holder);
|
|
|
|
|
const ipcHandlers = new Map();
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
|
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
|
|
|
|
BrowserWindow: FakeBrowserWindow,
|
|
|
|
|
ipcMain: {
|
|
|
|
|
handle(channel, handler) { ipcHandlers.set(channel, handler); },
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[DATABASE_URL, { defaultExport: SqliteCompatDatabase }],
|
|
|
|
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
|
|
|
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
|
|
|
|
[INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }],
|
|
|
|
|
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await importMain();
|
|
|
|
|
const check = new DatabaseSync(dbPath, { readOnly: true });
|
|
|
|
|
const columns = check.prepare('PRAGMA table_info(sessions)').all().map(column => column.name);
|
|
|
|
|
check.close();
|
|
|
|
|
assert.deepEqual(columns, ['id']);
|
|
|
|
|
assert.throws(
|
|
|
|
|
() => ipcHandlers.get('db:archiveMemory')(null, 'memory-1', 'test'),
|
|
|
|
|
/writer is busy/i,
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
restore();
|
|
|
|
|
holder.release();
|
|
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-08 16:11:12 +08:00
|
|
|
test('closing the last macOS window releases background resources until activation', async () => {
|
|
|
|
|
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'), '');
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
|
|
|
|
|
|
|
|
|
const appHandlers = new Map();
|
|
|
|
|
const serviceEvents = [];
|
|
|
|
|
const workers = [];
|
|
|
|
|
const watchers = [];
|
|
|
|
|
const windows = [];
|
|
|
|
|
let quitCalled = false;
|
|
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec() {}
|
2026-07-08 16:11:12 +08:00
|
|
|
close() { serviceEvents.push('db-close'); }
|
|
|
|
|
prepare() {
|
|
|
|
|
return { get: () => null, all: () => [], run: () => ({}) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
|
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
|
|
|
|
windows.push(this);
|
|
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
|
|
|
|
static getAllWindows() { return windows; }
|
|
|
|
|
static fromWebContents() { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
2026-07-08 16:11:12 +08:00
|
|
|
app: {
|
|
|
|
|
whenReady: () => Promise.resolve(),
|
|
|
|
|
on(event, handler) { appHandlers.set(event, handler); },
|
|
|
|
|
quit() { quitCalled = true; },
|
|
|
|
|
},
|
|
|
|
|
BrowserWindow: FakeBrowserWindow,
|
2026-07-09 11:02:20 +08:00
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[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: {
|
2026-07-08 16:11:12 +08:00
|
|
|
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(); },
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
[INDEXER_WORKER_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createWorkerBuildIndex: () => {
|
|
|
|
|
const worker = { stop() { serviceEvents.push('worker-stop'); } };
|
|
|
|
|
workers.push(worker);
|
|
|
|
|
return worker;
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
assert.equal(windows.length, 1);
|
|
|
|
|
assert.equal(workers.length, 1);
|
|
|
|
|
assert.equal(watchers.length, 1);
|
|
|
|
|
|
|
|
|
|
windows.length = 0;
|
|
|
|
|
appHandlers.get('window-all-closed')();
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
|
|
|
|
|
|
assert.equal(quitCalled, false);
|
|
|
|
|
assert.ok(serviceEvents.includes('service-stop'));
|
|
|
|
|
assert.ok(serviceEvents.includes('worker-stop'));
|
|
|
|
|
assert.ok(serviceEvents.includes('watcher-close'));
|
|
|
|
|
assert.ok(serviceEvents.includes('db-close'));
|
|
|
|
|
|
|
|
|
|
appHandlers.get('activate')();
|
|
|
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
|
|
|
|
|
|
assert.equal(windows.length, 1);
|
|
|
|
|
assert.equal(workers.length, 2);
|
|
|
|
|
assert.equal(watchers.length, 2);
|
|
|
|
|
assert.equal(serviceEvents.filter(e => e === 'service-start').length, 2);
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform);
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('settings rebuild reopens the database from the configured Claude path', async () => {
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-settings-${Date.now()}`);
|
|
|
|
|
const defaultClaudeDir = join(home, '.claude');
|
|
|
|
|
const customClaudeDir = join(home, 'custom-claude');
|
|
|
|
|
const customCodexDir = join(home, 'custom-codex');
|
|
|
|
|
mkdirSync(defaultClaudeDir, { recursive: true });
|
|
|
|
|
mkdirSync(customClaudeDir, { recursive: true });
|
|
|
|
|
mkdirSync(customCodexDir, { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(defaultClaudeDir, 'obelisk.sqlite'), '');
|
|
|
|
|
writeFileSync(join(customClaudeDir, 'obelisk.sqlite'), 'legacy custom db');
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
|
|
|
|
|
claudeDir: customClaudeDir,
|
|
|
|
|
codexDir: customCodexDir,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const ipcHandlers = new Map();
|
|
|
|
|
const openedDbPaths = [];
|
|
|
|
|
const buildCalls = [];
|
|
|
|
|
const serviceEvents = [];
|
2026-07-10 18:10:45 +08:00
|
|
|
let competingLeaseDuringBuild;
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
constructor(dbPath) {
|
2026-07-10 18:10:45 +08:00
|
|
|
this.lockDb = dbPath.endsWith('writer.lock.sqlite') ? new DatabaseSync(dbPath) : null;
|
2026-07-08 16:11:12 +08:00
|
|
|
openedDbPaths.push(dbPath);
|
|
|
|
|
}
|
|
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec(sql) { return this.lockDb?.exec(sql); }
|
|
|
|
|
close() { this.lockDb?.close(); }
|
2026-07-08 16:11:12 +08:00
|
|
|
prepare() {
|
|
|
|
|
return { get: () => null, all: () => [], run: () => ({}) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
2026-07-09 11:02:20 +08:00
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
2026-07-09 11:02:20 +08:00
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
2026-07-08 16:11:12 +08:00
|
|
|
BrowserWindow: FakeBrowserWindow,
|
|
|
|
|
ipcMain: {
|
|
|
|
|
handle(channel, handler) {
|
|
|
|
|
ipcHandlers.set(channel, handler);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[DATABASE_URL, { defaultExport: FakeDatabase }],
|
|
|
|
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
|
|
|
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
|
|
|
|
[INDEXER_SERVICE_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createIndexerService: () => ({
|
|
|
|
|
start() { serviceEvents.push('start'); },
|
|
|
|
|
stop() { serviceEvents.push('stop'); },
|
|
|
|
|
idle: async () => { serviceEvents.push('idle'); },
|
|
|
|
|
runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); },
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
[INDEXER_WORKER_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createWorkerBuildIndex: () => ({
|
|
|
|
|
buildIndex: async (args) => {
|
|
|
|
|
serviceEvents.push('build');
|
|
|
|
|
buildCalls.push(args);
|
2026-07-10 18:10:45 +08:00
|
|
|
const competingLease = acquireWriterLease({
|
|
|
|
|
lockPath: args.writerLeasePath,
|
|
|
|
|
openDb: lockPath => new DatabaseSync(lockPath),
|
|
|
|
|
});
|
|
|
|
|
competingLeaseDuringBuild = Boolean(competingLease);
|
|
|
|
|
competingLease?.release();
|
2026-07-08 16:11:12 +08:00
|
|
|
writeFileSync(args.dbPath, 'rebuilt temp db');
|
|
|
|
|
return { files: 2, affectedSessionIds: ['session-1', 'session-2'] };
|
|
|
|
|
},
|
|
|
|
|
stop() { return Promise.resolve(); },
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
const rebuild = ipcHandlers.get('settings:rebuildIndex');
|
|
|
|
|
assert.equal(typeof rebuild, 'function');
|
|
|
|
|
await rebuild();
|
|
|
|
|
|
|
|
|
|
assert.equal(buildCalls.at(-1).claudeDir, customClaudeDir);
|
|
|
|
|
assert.equal(buildCalls.at(-1).projectsDir, join(customClaudeDir, 'projects'));
|
|
|
|
|
assert.equal(buildCalls.at(-1).codexDir, customCodexDir);
|
|
|
|
|
assert.notEqual(buildCalls.at(-1).dbPath, join(home, '.obelisk', 'obelisk.sqlite'));
|
|
|
|
|
assert.equal(buildCalls.at(-1).preserveDbPath, join(home, '.obelisk', 'obelisk.sqlite'));
|
2026-07-10 18:10:45 +08:00
|
|
|
assert.equal(buildCalls.at(-1).writerLeasePath, join(home, '.obelisk', 'writer.lock.sqlite'));
|
|
|
|
|
assert.equal(buildCalls.at(-1).writerLeaseMode, 'caller-held');
|
|
|
|
|
assert.equal(competingLeaseDuringBuild, false);
|
2026-07-08 16:11:12 +08:00
|
|
|
assert.equal(openedDbPaths.at(-1), join(home, '.obelisk', 'obelisk.sqlite'));
|
|
|
|
|
assert.equal(
|
|
|
|
|
require('node:fs').readFileSync(join(home, '.obelisk', 'obelisk.sqlite'), 'utf8'),
|
|
|
|
|
'rebuilt temp db',
|
|
|
|
|
);
|
|
|
|
|
assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop'));
|
2026-07-10 18:10:45 +08:00
|
|
|
const postRebuildLease = acquireWriterLease({
|
|
|
|
|
lockPath: join(home, '.obelisk', 'writer.lock.sqlite'),
|
|
|
|
|
openDb: lockPath => new DatabaseSync(lockPath),
|
|
|
|
|
});
|
|
|
|
|
assert.ok(postRebuildLease);
|
|
|
|
|
postRebuildLease.release();
|
2026-07-08 16:11:12 +08:00
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('settings rebuild keeps the existing database after a worker failure', async () => {
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-settings-rebuild-failure-${Date.now()}`);
|
|
|
|
|
const customClaudeDir = join(home, 'custom-claude');
|
|
|
|
|
const customCodexDir = join(home, 'custom-codex');
|
|
|
|
|
mkdirSync(customClaudeDir, { recursive: true });
|
|
|
|
|
mkdirSync(customCodexDir, { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(customClaudeDir, 'obelisk.sqlite'), 'legacy custom db');
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
|
|
|
|
|
claudeDir: customClaudeDir,
|
|
|
|
|
codexDir: customCodexDir,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const ipcHandlers = new Map();
|
|
|
|
|
const openedDbPaths = [];
|
|
|
|
|
const closedDbPaths = [];
|
|
|
|
|
const serviceEvents = [];
|
|
|
|
|
|
|
|
|
|
class FakeDatabase {
|
|
|
|
|
constructor(dbPath) {
|
|
|
|
|
this.dbPath = dbPath;
|
2026-07-10 18:10:45 +08:00
|
|
|
this.lockDb = dbPath.endsWith('writer.lock.sqlite') ? new DatabaseSync(dbPath) : null;
|
|
|
|
|
if (!this.lockDb) openedDbPaths.push(dbPath);
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec(sql) { return this.lockDb?.exec(sql); }
|
|
|
|
|
close() {
|
|
|
|
|
if (this.lockDb) this.lockDb.close();
|
|
|
|
|
else closedDbPaths.push(this.dbPath);
|
|
|
|
|
}
|
2026-07-08 16:11:12 +08:00
|
|
|
prepare() {
|
|
|
|
|
return { get: () => null, all: () => [], run: () => ({}) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
2026-07-09 11:02:20 +08:00
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
2026-07-09 11:02:20 +08:00
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
2026-07-08 16:11:12 +08:00
|
|
|
BrowserWindow: FakeBrowserWindow,
|
|
|
|
|
ipcMain: {
|
|
|
|
|
handle(channel, handler) {
|
|
|
|
|
ipcHandlers.set(channel, handler);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[DATABASE_URL, { defaultExport: FakeDatabase }],
|
|
|
|
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
|
|
|
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
|
|
|
|
[INDEXER_SERVICE_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createIndexerService: () => ({
|
|
|
|
|
start() { serviceEvents.push('start'); },
|
|
|
|
|
stop() { serviceEvents.push('stop'); },
|
|
|
|
|
idle: async () => { serviceEvents.push('idle'); },
|
|
|
|
|
runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); },
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
[INDEXER_WORKER_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createWorkerBuildIndex: () => ({
|
|
|
|
|
buildIndex: async () => {
|
|
|
|
|
serviceEvents.push('build');
|
|
|
|
|
throw new Error('worker exploded');
|
|
|
|
|
},
|
|
|
|
|
stop() {},
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
const rebuild = ipcHandlers.get('settings:rebuildIndex');
|
|
|
|
|
const openCountBeforeRebuild = openedDbPaths.length;
|
|
|
|
|
await assert.rejects(() => rebuild(), /worker exploded/);
|
|
|
|
|
|
|
|
|
|
const expectedDbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
|
|
|
|
assert.equal(openedDbPaths.at(-1), expectedDbPath);
|
|
|
|
|
assert.equal(openedDbPaths.length, openCountBeforeRebuild);
|
|
|
|
|
assert.equal(closedDbPaths.includes(expectedDbPath), false);
|
|
|
|
|
assert.equal(
|
|
|
|
|
require('node:fs').readFileSync(expectedDbPath, 'utf8'),
|
|
|
|
|
'legacy custom db',
|
|
|
|
|
);
|
|
|
|
|
assert.ok(serviceEvents.indexOf('build') > serviceEvents.indexOf('stop'));
|
|
|
|
|
assert.ok(serviceEvents.lastIndexOf('start') > serviceEvents.indexOf('build'));
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('settings rebuild cancels an in-flight background build instead of waiting for it', async () => {
|
|
|
|
|
const home = join(tmpdir(), `obelisk-main-settings-rebuild-cancel-${Date.now()}`);
|
|
|
|
|
const customClaudeDir = join(home, 'custom-claude');
|
|
|
|
|
const customCodexDir = join(home, 'custom-codex');
|
|
|
|
|
mkdirSync(customClaudeDir, { recursive: true });
|
|
|
|
|
mkdirSync(customCodexDir, { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
writeFileSync(join(customClaudeDir, 'obelisk.sqlite'), 'legacy custom db');
|
|
|
|
|
writeFileSync(join(home, '.obelisk', 'settings.json'), JSON.stringify({
|
|
|
|
|
claudeDir: customClaudeDir,
|
|
|
|
|
codexDir: customCodexDir,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const originalHome = process.env.HOME;
|
|
|
|
|
process.env.HOME = home;
|
|
|
|
|
|
|
|
|
|
const ipcHandlers = new Map();
|
|
|
|
|
const serviceEvents = [];
|
2026-07-09 11:02:20 +08:00
|
|
|
let buildIndexCalls = 0;
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
class FakeDatabase {
|
2026-07-10 18:10:45 +08:00
|
|
|
constructor(dbPath) {
|
|
|
|
|
this.lockDb = dbPath.endsWith('writer.lock.sqlite') ? new DatabaseSync(dbPath) : null;
|
|
|
|
|
}
|
2026-07-08 16:11:12 +08:00
|
|
|
pragma() {}
|
2026-07-10 18:10:45 +08:00
|
|
|
exec(sql) { return this.lockDb?.exec(sql); }
|
|
|
|
|
close() { this.lockDb?.close(); }
|
2026-07-08 16:11:12 +08:00
|
|
|
prepare() {
|
|
|
|
|
return { get: () => null, all: () => [], run: () => ({}) };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class FakeBrowserWindow {
|
|
|
|
|
constructor() {
|
2026-07-09 11:02:20 +08:00
|
|
|
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
loadFile() {}
|
|
|
|
|
loadURL() {}
|
|
|
|
|
close() {}
|
2026-07-09 11:02:20 +08:00
|
|
|
static getAllWindows() { return []; }
|
|
|
|
|
static fromWebContents() { return null; }
|
2026-07-08 16:11:12 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-09 11:02:20 +08:00
|
|
|
const restore = registerMocks([
|
|
|
|
|
[ELECTRON_URL, {
|
|
|
|
|
namedExports: electronNamespace({
|
2026-07-08 16:11:12 +08:00
|
|
|
BrowserWindow: FakeBrowserWindow,
|
|
|
|
|
ipcMain: {
|
|
|
|
|
handle(channel, handler) {
|
|
|
|
|
ipcHandlers.set(channel, handler);
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-09 11:02:20 +08:00
|
|
|
}),
|
|
|
|
|
}],
|
|
|
|
|
[DATABASE_URL, { defaultExport: FakeDatabase }],
|
|
|
|
|
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
|
|
|
|
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
|
|
|
|
[INDEXER_SERVICE_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createIndexerService: () => ({
|
|
|
|
|
start() { serviceEvents.push('start'); },
|
|
|
|
|
stop() { serviceEvents.push('stop'); },
|
|
|
|
|
idle: async () => new Promise(() => {}),
|
|
|
|
|
runBuildNow() { serviceEvents.push('runBuildNow'); return Promise.resolve(); },
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
[INDEXER_WORKER_URL, {
|
|
|
|
|
namedExports: {
|
2026-07-08 16:11:12 +08:00
|
|
|
createWorkerBuildIndex: () => ({
|
|
|
|
|
buildIndex: async (args) => {
|
|
|
|
|
serviceEvents.push(`build-${++buildIndexCalls}`);
|
|
|
|
|
writeFileSync(args.dbPath, 'rebuilt temp db');
|
|
|
|
|
return { files: 2, affectedSessionIds: [] };
|
|
|
|
|
},
|
|
|
|
|
stop() {
|
|
|
|
|
serviceEvents.push('worker-stop');
|
|
|
|
|
return Promise.resolve();
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-07-09 11:02:20 +08:00
|
|
|
},
|
|
|
|
|
}],
|
|
|
|
|
]);
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
try {
|
2026-07-09 11:02:20 +08:00
|
|
|
await importMain();
|
2026-07-08 16:11:12 +08:00
|
|
|
|
|
|
|
|
const rebuild = ipcHandlers.get('settings:rebuildIndex');
|
|
|
|
|
const outcome = await Promise.race([
|
|
|
|
|
rebuild().then(() => 'done'),
|
|
|
|
|
new Promise(resolve => setTimeout(() => resolve('timeout'), 20)),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
assert.equal(outcome, 'done');
|
|
|
|
|
assert.ok(serviceEvents.indexOf('worker-stop') > serviceEvents.indexOf('stop'));
|
|
|
|
|
assert.ok(serviceEvents.some(event => event.startsWith('build-')));
|
|
|
|
|
} finally {
|
2026-07-09 11:02:20 +08:00
|
|
|
restore();
|
2026-07-08 16:11:12 +08:00
|
|
|
process.env.HOME = originalHome;
|
|
|
|
|
rmSync(home, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|