chore: sanitize tests, add CONTEXT.md + ADRs, track tests/docs
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createIndexerService } = require('../app/indexer-service.js');
|
||||
|
||||
function manualTimers() {
|
||||
const timers = new Set();
|
||||
return {
|
||||
setTimeout(fn) {
|
||||
timers.add(fn);
|
||||
return fn;
|
||||
},
|
||||
clearTimeout(fn) {
|
||||
timers.delete(fn);
|
||||
},
|
||||
flush() {
|
||||
const pending = [...timers];
|
||||
timers.clear();
|
||||
for (const fn of pending) fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('indexer service debounces repeated build requests', async () => {
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
const service = createIndexerService({
|
||||
buildIndex: async ({ reason }) => calls.push(reason),
|
||||
watchProjects: () => null,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
});
|
||||
|
||||
service.scheduleBuild('first');
|
||||
service.scheduleBuild('second');
|
||||
service.scheduleBuild('third');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
|
||||
assert.deepEqual(calls, ['third']);
|
||||
});
|
||||
|
||||
test('indexer service runs one pending build after an in-flight build finishes', async () => {
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
let finishFirst;
|
||||
const service = createIndexerService({
|
||||
buildIndex: async ({ reason }) => {
|
||||
calls.push(reason);
|
||||
if (reason === 'first') await new Promise(resolve => { finishFirst = resolve; });
|
||||
},
|
||||
watchProjects: () => null,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
});
|
||||
|
||||
const first = service.runBuildNow('first');
|
||||
service.scheduleBuild('second');
|
||||
timers.flush();
|
||||
|
||||
assert.deepEqual(calls, ['first']);
|
||||
finishFirst();
|
||||
await first;
|
||||
await service.idle();
|
||||
|
||||
assert.deepEqual(calls, ['first', 'pending']);
|
||||
});
|
||||
|
||||
test('indexer service waits for a stability window before building', async () => {
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
const service = createIndexerService({
|
||||
buildIndex: async ({ reason }) => calls.push(reason),
|
||||
watchProjects: () => null,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 500,
|
||||
});
|
||||
|
||||
service.scheduleBuild('jsonl-change');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
assert.deepEqual(calls, ['jsonl-change']);
|
||||
});
|
||||
|
||||
test('indexer service retries watcher setup when the projects directory is missing', () => {
|
||||
const timers = manualTimers();
|
||||
let attempts = 0;
|
||||
const service = createIndexerService({
|
||||
buildIndex: async () => {},
|
||||
watchProjects: () => {
|
||||
attempts++;
|
||||
return attempts === 1 ? null : { close() {} };
|
||||
},
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
});
|
||||
|
||||
service.start({ buildOnStart: false });
|
||||
assert.equal(attempts, 1);
|
||||
|
||||
timers.flush();
|
||||
assert.equal(attempts, 2);
|
||||
|
||||
timers.flush();
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
test('indexer service watches Claude JSON files through chokidar', async () => {
|
||||
const projectsDir = mkdtempSync(join(tmpdir(), 'obelisk-chokidar-projects-'));
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
let watchArgs = null;
|
||||
const handlers = {};
|
||||
const watcher = {
|
||||
on(event, handler) {
|
||||
handlers[event] = handler;
|
||||
return watcher;
|
||||
},
|
||||
closeCalled: false,
|
||||
close() {
|
||||
watcher.closeCalled = true;
|
||||
},
|
||||
};
|
||||
const chokidar = {
|
||||
watch(paths, options) {
|
||||
watchArgs = { paths, options };
|
||||
return watcher;
|
||||
},
|
||||
};
|
||||
|
||||
const service = createIndexerService({
|
||||
projectsDir,
|
||||
buildIndex: async ({ reason }) => calls.push(reason),
|
||||
chokidar,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
debounceMs: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
service.start({ buildOnStart: false });
|
||||
assert.equal(watchArgs.paths, projectsDir);
|
||||
assert.equal(watchArgs.options.cwd, projectsDir);
|
||||
assert.equal(watchArgs.options.ignoreInitial, true);
|
||||
assert.ok(watchArgs.options.awaitWriteFinish);
|
||||
|
||||
handlers.change('session.jsonl');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
assert.deepEqual(calls, ['watch']);
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
|
||||
assert.equal(watcher.closeCalled, true);
|
||||
});
|
||||
|
||||
test('indexer service passes changed JSONL paths to the build worker', async () => {
|
||||
const projectsDir = mkdtempSync(join(tmpdir(), 'obelisk-changed-paths-'));
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
const handlers = {};
|
||||
const watcher = {
|
||||
on(event, handler) {
|
||||
handlers[event] = handler;
|
||||
return watcher;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
const chokidar = {
|
||||
watch() {
|
||||
return watcher;
|
||||
},
|
||||
};
|
||||
|
||||
const service = createIndexerService({
|
||||
projectsDir,
|
||||
buildIndex: async (args) => calls.push(args),
|
||||
chokidar,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
debounceMs: 0,
|
||||
});
|
||||
|
||||
service.start({ buildOnStart: false });
|
||||
handlers.change('project-a/session-1.jsonl');
|
||||
handlers.add('project-a/session-2.json');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].reason, 'watch');
|
||||
assert.deepEqual(calls[0].changedPaths, [
|
||||
'project-a/session-1.jsonl',
|
||||
'project-a/session-2.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('indexer service watches Claude projects and Codex sessions for app-side indexing', async () => {
|
||||
const claudeProjectsDir = mkdtempSync(join(tmpdir(), 'obelisk-watch-claude-'));
|
||||
const codexSessionsDir = mkdtempSync(join(tmpdir(), 'obelisk-watch-codex-sessions-'));
|
||||
const timers = manualTimers();
|
||||
const calls = [];
|
||||
const watchers = [];
|
||||
const watchArgs = [];
|
||||
const chokidar = {
|
||||
watch(paths, options) {
|
||||
const handlers = {};
|
||||
const watcher = {
|
||||
handlers,
|
||||
on(event, handler) {
|
||||
handlers[event] = handler;
|
||||
return watcher;
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
watchers.push(watcher);
|
||||
watchArgs.push({ paths, options });
|
||||
return watcher;
|
||||
},
|
||||
};
|
||||
|
||||
const service = createIndexerService({
|
||||
projectsDir: claudeProjectsDir,
|
||||
watchDirs: [claudeProjectsDir, codexSessionsDir],
|
||||
buildIndex: async (args) => calls.push(args),
|
||||
chokidar,
|
||||
writeHeartbeat: () => {},
|
||||
timers,
|
||||
stabilityMs: 0,
|
||||
debounceMs: 0,
|
||||
});
|
||||
|
||||
service.start({ buildOnStart: false });
|
||||
assert.deepEqual(watchArgs.map(arg => arg.paths), [claudeProjectsDir, codexSessionsDir]);
|
||||
assert.deepEqual(watchArgs.map(arg => arg.options.cwd), [claudeProjectsDir, codexSessionsDir]);
|
||||
|
||||
watchers[1].handlers.change('2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl');
|
||||
timers.flush();
|
||||
await service.idle();
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0].changedPaths, [
|
||||
'2026/06/15/rollout-2026-06-15T00-00-00-codex.jsonl',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createWorkerBuildIndex } = require('../app/indexer-worker-client.js');
|
||||
|
||||
test('worker build client resolves build results from a worker thread', async () => {
|
||||
const instances = [];
|
||||
class MockWorker {
|
||||
constructor(workerPath) {
|
||||
this.workerPath = workerPath;
|
||||
this.handlers = {};
|
||||
this.messages = [];
|
||||
this.terminated = false;
|
||||
instances.push(this);
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
this.handlers[event] = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
postMessage(message) {
|
||||
this.messages.push(message);
|
||||
queueMicrotask(() => {
|
||||
this.handlers.message({
|
||||
id: message.id,
|
||||
result: { files: 1, reason: message.args.reason },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const client = createWorkerBuildIndex({ WorkerImpl: MockWorker, workerPath: '/tmp/indexer-worker.js' });
|
||||
const result = await client.buildIndex({ reason: 'watch' });
|
||||
|
||||
assert.equal(instances.length, 1);
|
||||
assert.equal(instances[0].workerPath, '/tmp/indexer-worker.js');
|
||||
assert.deepEqual(result, { files: 1, reason: 'watch' });
|
||||
|
||||
client.stop();
|
||||
assert.equal(instances[0].terminated, true);
|
||||
});
|
||||
|
||||
test('worker build client rejects pending builds when worker exits cleanly', async () => {
|
||||
class MockWorker {
|
||||
constructor() {
|
||||
this.handlers = {};
|
||||
}
|
||||
|
||||
on(event, handler) {
|
||||
this.handlers[event] = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
postMessage() {
|
||||
queueMicrotask(() => {
|
||||
this.handlers.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const client = createWorkerBuildIndex({ WorkerImpl: MockWorker, workerPath: '/tmp/indexer-worker.js' });
|
||||
|
||||
await assert.rejects(
|
||||
client.buildIndex({ reason: 'startup' }),
|
||||
/Indexer worker exited with code 0/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,741 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { appendFileSync, mkdtempSync, mkdirSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildIndex } = require('../app/indexer.js');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
class TestDatabase {
|
||||
constructor(dbPath) {
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
}
|
||||
|
||||
pragma(statement) {
|
||||
this.db.exec(`PRAGMA ${statement}`);
|
||||
}
|
||||
|
||||
exec(sql) {
|
||||
return this.db.exec(sql);
|
||||
}
|
||||
|
||||
prepare(sql) {
|
||||
return this.db.prepare(sql);
|
||||
}
|
||||
|
||||
close() {
|
||||
return this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('app indexer builds the Obelisk database from Claude JSONL and records app heartbeat', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = 'session-app-1';
|
||||
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-app-1',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:00:00Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello from app indexer' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(claudeDir, 'obelisk.sqlite');
|
||||
const firstBuild = buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-app-1').text, 'hello from app indexer');
|
||||
assert.deepEqual(firstBuild.affectedSessionIds, [sessionId]);
|
||||
assert.equal(firstBuild.ftsRebuilt, true);
|
||||
assert.equal(db.prepare("SELECT uuid FROM messages_fts WHERE messages_fts MATCH 'hello'").get().uuid, 'msg-app-1');
|
||||
assert.equal(db.prepare("SELECT jsonl_path FROM index_state WHERE jsonl_path='__app_heartbeat__'").get().jsonl_path, '__app_heartbeat__');
|
||||
assert.equal(db.prepare("SELECT jsonl_path FROM index_state WHERE jsonl_path='__app_last_successful_build__'").get().jsonl_path, '__app_last_successful_build__');
|
||||
assert.equal(db.prepare('SELECT project_path FROM sessions WHERE id=?').get(sessionId).project_path, '/tmp/obelisk-app');
|
||||
db.close();
|
||||
|
||||
appendFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-app-2',
|
||||
type: 'assistant',
|
||||
timestamp: '2026-06-13T10:01:00Z',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'companion view updated quickly' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
const nextMtime = new Date(Date.now() + 2000);
|
||||
utimesSync(jsonlPath, nextMtime, nextMtime);
|
||||
|
||||
const secondBuild = buildIndex({
|
||||
claudeDir,
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
changedPaths: [`-tmp-obelisk-app/${sessionId}.jsonl`],
|
||||
});
|
||||
assert.deepEqual(secondBuild.affectedSessionIds, [sessionId]);
|
||||
assert.equal(secondBuild.ftsRebuilt, false);
|
||||
|
||||
const db2 = new TestDatabase(dbPath);
|
||||
assert.equal(db2.prepare("SELECT uuid FROM messages_fts WHERE messages_fts MATCH 'companion'").get().uuid, 'msg-app-2');
|
||||
assert.equal(db2.prepare('SELECT message_count FROM sessions WHERE id=?').get(sessionId).message_count, 2);
|
||||
db2.close();
|
||||
});
|
||||
|
||||
test('force rebuild ignores stale JSONL index_state rows after session tables were cleared', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = 'session-force-1';
|
||||
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-force-1',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:00:00Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'force rebuild should not trust stale state' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(claudeDir, 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const broken = new TestDatabase(dbPath);
|
||||
assert.equal(broken.prepare('SELECT COUNT(*) AS c FROM index_state WHERE jsonl_path = ?').get(jsonlPath).c, 1);
|
||||
broken.prepare('DELETE FROM messages').run();
|
||||
broken.prepare('DELETE FROM sessions').run();
|
||||
broken.close();
|
||||
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase, force: true });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions').get().c, 1);
|
||||
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-force-1').text, 'force rebuild should not trust stale state');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('force rebuild bypasses stale message FTS delete triggers', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-fts-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = 'session-force-fts-1';
|
||||
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-force-fts-1',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:00:00Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'force rebuild should bulk clear without old fts deletes' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(claudeDir, 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const trapped = new TestDatabase(dbPath);
|
||||
trapped.exec('DROP TRIGGER messages_fts_ad');
|
||||
trapped.exec(`
|
||||
CREATE TRIGGER messages_fts_ad AFTER DELETE ON messages BEGIN
|
||||
SELECT RAISE(FAIL, 'message FTS delete trigger fired during force rebuild');
|
||||
END;
|
||||
`);
|
||||
trapped.close();
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase, force: true });
|
||||
});
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-force-fts-1').text, 'force rebuild should bulk clear without old fts deletes');
|
||||
assert.equal(db.prepare("SELECT uuid FROM messages_fts WHERE messages_fts MATCH 'bulk'").get().uuid, 'msg-force-fts-1');
|
||||
const trigger = db.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name='messages_fts_ad'").get();
|
||||
assert.match(trigger.sql, /INSERT INTO messages_fts/);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('force rebuild into a new database preserves existing memories', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-force-preserve-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = 'session-force-preserve-1';
|
||||
writeFileSync(join(projectDir, `${sessionId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-force-preserve-1',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:00:00Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'force rebuild should preserve memories' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const tempDbPath = join(home, '.obelisk', 'obelisk.rebuild.tmp');
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const sourceDb = new TestDatabase(dbPath);
|
||||
sourceDb.prepare(`
|
||||
INSERT INTO memories (id,session_id,project,message_start,message_end,path,anchors,summary,created_at,deleted_at,deleted_reason)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
||||
`).run(
|
||||
'mem-preserve-1',
|
||||
sessionId,
|
||||
'-tmp-obelisk-app',
|
||||
'msg-force-preserve-1',
|
||||
'msg-force-preserve-1',
|
||||
'/tmp/obelisk-app/notes.md',
|
||||
'[]',
|
||||
'Decision: preserve memories when rebuilding the session index.',
|
||||
'2026-06-13T10:05:00Z',
|
||||
null,
|
||||
null,
|
||||
);
|
||||
sourceDb.close();
|
||||
|
||||
buildIndex({
|
||||
claudeDir,
|
||||
dbPath: tempDbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
force: true,
|
||||
preserveDbPath: dbPath,
|
||||
});
|
||||
|
||||
const db = new TestDatabase(tempDbPath);
|
||||
const memory = db.prepare('SELECT id, summary FROM memories WHERE id=?').get('mem-preserve-1');
|
||||
assert.equal(memory.summary, 'Decision: preserve memories when rebuilding the session index.');
|
||||
assert.equal(db.prepare("SELECT id FROM memories_fts WHERE memories_fts MATCH 'preserve'").get().id, 'mem-preserve-1');
|
||||
assert.equal(db.prepare('SELECT text FROM messages WHERE uuid=?').get('msg-force-preserve-1').text, 'force rebuild should preserve memories');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app indexer reports changed workflow JSON as an affected session', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-workflow-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const project = '-tmp-obelisk-app';
|
||||
const sessionId = 'session-workflow-1';
|
||||
const projectDir = join(claudeDir, 'projects', project);
|
||||
const workflowDir = join(projectDir, sessionId, 'workflows');
|
||||
mkdirSync(workflowDir, { recursive: true });
|
||||
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-workflow-1',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:00:00Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'start workflow' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(claudeDir, 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const workflowPath = join(workflowDir, 'run-1.json');
|
||||
writeFileSync(workflowPath, JSON.stringify({
|
||||
runId: 'run-1',
|
||||
timestamp: '2026-06-13T10:01:00Z',
|
||||
workflowName: 'review',
|
||||
}));
|
||||
|
||||
const result = buildIndex({
|
||||
claudeDir,
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
changedPaths: [`${project}/${sessionId}/workflows/run-1.json`],
|
||||
});
|
||||
|
||||
assert.deepEqual(result.affectedSessionIds, [sessionId]);
|
||||
});
|
||||
|
||||
test('app indexer marks UI-fallback control messages as meta at ingest time', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-meta-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const projectDir = join(claudeDir, 'projects', '-tmp-obelisk-app');
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const sessionId = 'session-meta-1';
|
||||
const jsonlPath = join(projectDir, `${sessionId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
uuid: 'msg-meta-system-reminder',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:00:00Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>Keep answers concise</system-reminder>' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
uuid: 'msg-meta-local-command',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:01:00Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: '<local-command>git status</local-command>' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
uuid: 'msg-meta-normal',
|
||||
type: 'user',
|
||||
timestamp: '2026-06-13T10:02:00Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'normal user request' }] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(claudeDir, 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT is_meta FROM messages WHERE uuid=?').get('msg-meta-system-reminder').is_meta, 1);
|
||||
assert.equal(db.prepare('SELECT is_meta FROM messages WHERE uuid=?').get('msg-meta-local-command').is_meta, 1);
|
||||
assert.equal(db.prepare('SELECT is_meta FROM messages WHERE uuid=?').get('msg-meta-normal').is_meta, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app indexer loads Codex root sessions into the shared schema', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
const jsonlPath = join(codexSessionDir, `rollout-2026-06-15T00-19-59-${codexId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: codexId,
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
originator: 'Codex Desktop',
|
||||
source: 'vscode',
|
||||
thread_source: 'user',
|
||||
git: { branch: 'feat/codex' },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'codex user asks for indexing', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:01.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: 'codex assistant replies' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:02.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', call_id: 'call_codex_1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:03.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call_output', call_id: 'call_codex_1', output: '/tmp/obelisk-app' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:04.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'task_complete',
|
||||
completed_at: 1781454004,
|
||||
duration_ms: 4321,
|
||||
turn_id: 'turn-1',
|
||||
last_agent_message: 'codex assistant replies',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const result = buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
const session = db.prepare('SELECT * FROM sessions WHERE id=?').get(`codex:${codexId}`);
|
||||
assert.equal(session.source, 'codex');
|
||||
assert.equal(session.project, '-tmp-obelisk-app');
|
||||
assert.equal(session.project_path, '/tmp/obelisk-app');
|
||||
assert.equal(session.git_branch, 'feat/codex');
|
||||
assert.equal(session.version, '0.135.0-alpha.1');
|
||||
assert.equal(session.message_count, 3);
|
||||
assert.deepEqual(result.affectedSessionIds, [`codex:${codexId}`]);
|
||||
|
||||
const messages = db.prepare('SELECT role, text, source, content_type, turn_duration_ms FROM messages WHERE session_id=? ORDER BY timestamp, uuid').all(`codex:${codexId}`);
|
||||
assert.deepEqual(messages.map(m => [m.role, m.text, m.source, m.content_type]), [
|
||||
['user', 'codex user asks for indexing', 'codex', 'text'],
|
||||
['assistant', 'codex assistant replies', 'codex', 'text'],
|
||||
['assistant', null, 'codex', 'tool_use'],
|
||||
]);
|
||||
assert.equal(messages[1].turn_duration_ms, 4321);
|
||||
|
||||
const tool = db.prepare('SELECT * FROM tool_calls WHERE id=?').get('codex:call_codex_1');
|
||||
assert.equal(tool.session_id, `codex:${codexId}`);
|
||||
assert.equal(tool.name, 'exec_command');
|
||||
assert.equal(tool.message_uuid, `codex:${codexId}:000004`);
|
||||
const toolResult = db.prepare('SELECT message_uuid, content FROM tool_results WHERE tool_use_id=?').get('codex:call_codex_1');
|
||||
assert.equal(toolResult.message_uuid, `codex:${codexId}:000004`);
|
||||
assert.equal(toolResult.content, '/tmp/obelisk-app');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app indexer accepts Codex changed paths relative to the sessions directory', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-sessions-change-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
const filename = `rollout-2026-06-15T00-19-59-${codexId}.jsonl`;
|
||||
writeFileSync(join(codexSessionDir, filename), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: codexId,
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
thread_source: 'user',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'sessions-relative codex change', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const result = buildIndex({
|
||||
claudeDir,
|
||||
codexDir,
|
||||
dbPath,
|
||||
DatabaseImpl: TestDatabase,
|
||||
changedPaths: [join('2026', '06', '15', filename)],
|
||||
});
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.deepEqual(result.affectedSessionIds, [`codex:${codexId}`]);
|
||||
assert.equal(
|
||||
db.prepare('SELECT text FROM messages WHERE session_id=?').get(`codex:${codexId}`).text,
|
||||
'sessions-relative codex change',
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app indexer uses Codex response_item messages only when no visible event message exists', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-response-message-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${codexId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: codexId,
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
source: 'vscode',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:00.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: [{ type: 'input_text', text: 'developer context should not be indexed' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
phase: 'final_answer',
|
||||
content: [{ type: 'output_text', text: 'assistant fallback message' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: 'assistant event message' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:03.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
phase: 'final_answer',
|
||||
content: [{ type: 'output_text', text: 'assistant event message' }],
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
const rows = db.prepare('SELECT text FROM messages WHERE session_id=? ORDER BY timestamp, uuid').all(`codex:${codexId}`);
|
||||
assert.deepEqual(rows.map(row => row.text), [
|
||||
'assistant fallback message',
|
||||
'assistant event message',
|
||||
]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app indexer skips Codex guardian review threads', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-guardian-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: guardianId,
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
thread_source: 'subagent',
|
||||
source: { subagent: { other: 'guardian' } },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: { cwd: '/tmp/obelisk-app', model: 'codex-auto-review' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'approval guardian prompt', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:03.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: '{"outcome":"allow"}' },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
const result = buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.deepEqual(result.affectedSessionIds, []);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(`codex:${guardianId}`).c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id=?').get(`codex:${guardianId}`).c, 0);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?').get(`codex:${guardianId}`).c, 0);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'approval'").get().c, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('app indexer removes stale Codex guardian rows when the JSONL was already indexed', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-guardian-stale-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
|
||||
const guardianSessionId = `codex:${guardianId}`;
|
||||
const jsonlPath = join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: guardianId,
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
thread_source: 'subagent',
|
||||
source: { subagent: { other: 'guardian' } },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:01.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'stale approval guardian prompt', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, codexDir: join(home, 'empty-codex'), dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
db.prepare('INSERT INTO sessions (id,jsonl_path,source,message_count) VALUES (?,?,?,?)').run(guardianSessionId, jsonlPath, 'codex', 1);
|
||||
db.prepare('INSERT INTO messages (uuid,session_id,type,timestamp,role,text,content_type,source) VALUES (?,?,?,?,?,?,?,?)')
|
||||
.run(`${guardianSessionId}:000002`, guardianSessionId, 'user', '2026-06-14T18:12:01.000Z', 'user', 'stale approval guardian prompt', 'text', 'codex');
|
||||
db.prepare('INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)')
|
||||
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'exec_command', '{}', null);
|
||||
db.prepare('INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)')
|
||||
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'ok', null, 0);
|
||||
db.prepare('INSERT INTO subagents (agent_id,session_id) VALUES (?,?)').run(guardianSessionId, guardianSessionId);
|
||||
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)')
|
||||
.run(jsonlPath, statSync(jsonlPath).mtimeMs, 2);
|
||||
db.close();
|
||||
|
||||
buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const cleanedDb = new TestDatabase(dbPath);
|
||||
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(guardianSessionId).c, 0);
|
||||
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM messages WHERE session_id=?').get(guardianSessionId).c, 0);
|
||||
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM tool_calls WHERE session_id=?').get(guardianSessionId).c, 0);
|
||||
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM tool_results WHERE session_id=?').get(guardianSessionId).c, 0);
|
||||
assert.equal(cleanedDb.prepare('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?').get(guardianSessionId).c, 0);
|
||||
assert.equal(cleanedDb.prepare("SELECT COUNT(*) AS c FROM messages_fts WHERE messages_fts MATCH 'stale'").get().c, 0);
|
||||
cleanedDb.close();
|
||||
});
|
||||
|
||||
test('app indexer maps Codex subagent threads onto parent sessions', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-app-indexer-codex-subagent-'));
|
||||
const claudeDir = join(home, '.claude');
|
||||
const codexDir = join(home, '.codex');
|
||||
const codexSessionDir = join(codexDir, 'sessions', '2026', '06', '15');
|
||||
mkdirSync(join(claudeDir, 'projects'), { recursive: true });
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const parentId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
const childId = '019ec739-9f75-7a02-ba2a-371986e23823';
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${parentId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: parentId,
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
source: 'vscode',
|
||||
git: { branch: 'feat/codex' },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'collab_agent_spawn_end',
|
||||
call_id: 'call_spawn_1',
|
||||
sender_thread_id: parentId,
|
||||
new_thread_id: childId,
|
||||
new_agent_nickname: 'Plato',
|
||||
new_agent_role: 'worker',
|
||||
model: 'gpt-5.5',
|
||||
reasoning_effort: 'xhigh',
|
||||
prompt: 'inspect app-side codex indexing',
|
||||
status: 'pending_init',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T01-41-42-${childId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:42.924Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: childId,
|
||||
timestamp: '2026-06-14T17:41:42.924Z',
|
||||
cwd: '/tmp/obelisk-app',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
source: {
|
||||
subagent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: parentId,
|
||||
depth: 1,
|
||||
agent_nickname: 'Plato',
|
||||
agent_role: 'worker',
|
||||
},
|
||||
},
|
||||
},
|
||||
agent_nickname: 'Plato',
|
||||
agent_role: 'worker',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:43.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'subagent prompt', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:44.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: 'subagent answer' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:45.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', call_id: 'call_child_1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:46.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call_output', call_id: 'call_child_1', output: '/tmp/obelisk-app' },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
||||
buildIndex({ claudeDir, codexDir, dbPath, DatabaseImpl: TestDatabase });
|
||||
|
||||
const db = new TestDatabase(dbPath);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM sessions WHERE id=?').get(`codex:${childId}`).c, 0);
|
||||
const subagent = db.prepare('SELECT * FROM subagents WHERE agent_id=?').get(`codex:${childId}`);
|
||||
assert.equal(subagent.session_id, `codex:${parentId}`);
|
||||
assert.equal(subagent.parent_tool_use_id, 'codex:call_spawn_1');
|
||||
assert.equal(subagent.agent_type, 'worker');
|
||||
assert.equal(subagent.description, 'Plato');
|
||||
|
||||
const spawnMessage = db.prepare('SELECT * FROM messages WHERE uuid=?').get(`codex:${parentId}:000002`);
|
||||
assert.equal(spawnMessage.session_id, `codex:${parentId}`);
|
||||
assert.equal(spawnMessage.content_type, 'tool_use');
|
||||
assert.equal(spawnMessage.source, 'codex');
|
||||
assert.equal(db.prepare('SELECT name, message_uuid FROM tool_calls WHERE id=?').get('codex:call_spawn_1').message_uuid, spawnMessage.uuid);
|
||||
|
||||
const childMessages = db.prepare('SELECT session_id, agent_id, is_sidechain, source, text FROM messages WHERE agent_id=? ORDER BY timestamp, uuid').all(`codex:${childId}`);
|
||||
assert.deepEqual(childMessages.map(m => [m.session_id, m.agent_id, m.is_sidechain, m.source, m.text]), [
|
||||
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent prompt'],
|
||||
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent answer'],
|
||||
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', null],
|
||||
]);
|
||||
const childResult = db.prepare(`
|
||||
SELECT tr.content FROM tool_results tr
|
||||
JOIN messages m ON m.uuid = tr.message_uuid
|
||||
WHERE m.agent_id = ?
|
||||
`).get(`codex:${childId}`);
|
||||
assert.equal(childResult.content, '/tmp/obelisk-app');
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,984 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
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);
|
||||
|
||||
async function loadMainForWindowFlags(flags) {
|
||||
const originalArgv = process.argv;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalLoad = Module._load;
|
||||
const mainPath = require.resolve('../app/main.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];
|
||||
|
||||
const windows = [];
|
||||
|
||||
class FakeDatabase {
|
||||
pragma() {}
|
||||
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; }
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
return windows;
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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 originalLoad = Module._load;
|
||||
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 = [];
|
||||
const mainPath = require.resolve('../app/main.js');
|
||||
delete require.cache[mainPath];
|
||||
|
||||
class FakeDatabase {
|
||||
pragma() {}
|
||||
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; }
|
||||
}
|
||||
|
||||
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: (options) => {
|
||||
serviceOptions.push(options);
|
||||
return {
|
||||
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);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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 {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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 });
|
||||
writeFileSync(join(home, '.obelisk', 'obelisk.sqlite'), '');
|
||||
process.env.HOME = home;
|
||||
|
||||
const ipcHandlers = new Map();
|
||||
const queries = [];
|
||||
const mainPath = require.resolve('../app/main.js');
|
||||
delete require.cache[mainPath];
|
||||
|
||||
class FakeDatabase {
|
||||
pragma() {}
|
||||
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; }
|
||||
}
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
whenReady: () => Promise.resolve(),
|
||||
on() {},
|
||||
quit() {},
|
||||
},
|
||||
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);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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 {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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');
|
||||
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();
|
||||
const mainPath = require.resolve('../app/main.js');
|
||||
delete require.cache[mainPath];
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
close() {}
|
||||
static getAllWindows() { return []; }
|
||||
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(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);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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 {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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/main.js');
|
||||
delete require.cache[mainPath];
|
||||
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() {}
|
||||
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; }
|
||||
}
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
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 {
|
||||
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 {
|
||||
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/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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 {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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 = [];
|
||||
const originalLoad = Module._load;
|
||||
const mainPath = require.resolve('../app/main.js');
|
||||
delete require.cache[mainPath];
|
||||
|
||||
class FakeDatabase {
|
||||
constructor(dbPath) {
|
||||
openedDbPaths.push(dbPath);
|
||||
}
|
||||
pragma() {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
whenReady: () => Promise.resolve(),
|
||||
on() {},
|
||||
quit() {},
|
||||
},
|
||||
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() { 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 {
|
||||
createWorkerBuildIndex: () => ({
|
||||
buildIndex: async (args) => {
|
||||
serviceEvents.push('build');
|
||||
buildCalls.push(args);
|
||||
writeFileSync(args.dbPath, 'rebuilt temp db');
|
||||
return { files: 2, affectedSessionIds: ['session-1', 'session-2'] };
|
||||
},
|
||||
stop() { return Promise.resolve(); },
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (request === 'chokidar') {
|
||||
return { watch: () => ({ on() { return this; }, close() {} }) };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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'));
|
||||
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'));
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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 = [];
|
||||
const originalLoad = Module._load;
|
||||
const mainPath = require.resolve('../app/main.js');
|
||||
delete require.cache[mainPath];
|
||||
|
||||
class FakeDatabase {
|
||||
constructor(dbPath) {
|
||||
this.dbPath = dbPath;
|
||||
openedDbPaths.push(dbPath);
|
||||
}
|
||||
pragma() {}
|
||||
close() { closedDbPaths.push(this.dbPath); }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
whenReady: () => Promise.resolve(),
|
||||
on() {},
|
||||
quit() {},
|
||||
},
|
||||
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() { 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 {
|
||||
createWorkerBuildIndex: () => ({
|
||||
buildIndex: async () => {
|
||||
serviceEvents.push('build');
|
||||
throw new Error('worker exploded');
|
||||
},
|
||||
stop() {},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (request === 'chokidar') {
|
||||
return { watch: () => ({ on() { return this; }, close() {} }) };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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 {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
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 = [];
|
||||
const originalLoad = Module._load;
|
||||
const mainPath = require.resolve('../app/main.js');
|
||||
delete require.cache[mainPath];
|
||||
|
||||
class FakeDatabase {
|
||||
pragma() {}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
whenReady: () => Promise.resolve(),
|
||||
on() {},
|
||||
quit() {},
|
||||
},
|
||||
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() { 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 {
|
||||
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();
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (request === 'chokidar') {
|
||||
return { watch: () => ({ on() { return this; }, close() {} }) };
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
try {
|
||||
require('../app/main.js');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
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 {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[mainPath];
|
||||
process.env.HOME = originalHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { extractContentType, extractMessageIsMeta } from '../scripts/db.mjs';
|
||||
|
||||
async function readExecutableSchema() {
|
||||
return readFile(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function readSchemaReference() {
|
||||
return readFile(new URL('../references/schema.md', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function readApiReference() {
|
||||
return readFile(new URL('../references/api-reference.md', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function readSkill() {
|
||||
return readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
test('db module loads the executable schema from scripts/schema.sql', async () => {
|
||||
const source = await readFile(new URL('../scripts/db.mjs', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /schema\.sql/);
|
||||
assert.doesNotMatch(source, /CREATE TABLE IF NOT EXISTS sessions/);
|
||||
});
|
||||
|
||||
test('memories schema indexes common recall filters', async () => {
|
||||
const source = await readExecutableSchema();
|
||||
|
||||
assert.match(source, /CREATE INDEX IF NOT EXISTS idx_memories_project ON memories\(project\)/);
|
||||
assert.match(source, /CREATE INDEX IF NOT EXISTS idx_memories_session ON memories\(session_id\)/);
|
||||
assert.match(source, /CREATE INDEX IF NOT EXISTS idx_memories_created ON memories\(created_at\)/);
|
||||
assert.match(source, /CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5/);
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS memories_fts_ai AFTER INSERT ON memories/);
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS memories_fts_au AFTER UPDATE ON memories/);
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS memories_fts_ad AFTER DELETE ON memories/);
|
||||
});
|
||||
|
||||
test('messages schema stores the raw content block type', async () => {
|
||||
const source = await readExecutableSchema();
|
||||
|
||||
assert.match(source, /content_type TEXT/);
|
||||
assert.match(source, /is_meta INTEGER DEFAULT 0/);
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages/);
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE ON messages/);
|
||||
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages/);
|
||||
});
|
||||
|
||||
test('schema reference stays focused on raw SQL structure', async () => {
|
||||
const ref = await readSchemaReference();
|
||||
|
||||
assert.ok(ref.split('\n').length < 420, 'schema.md should remain a quick SQL reference');
|
||||
assert.match(ref, /Raw SQL Quick Reference/i);
|
||||
assert.match(ref, /references\/api-reference\.md/);
|
||||
assert.match(ref, /sessions\.id\s+<--\s+messages\.session_id/);
|
||||
assert.match(ref, /tool_calls.*does not have timestamps/i);
|
||||
assert.match(ref, /COALESCE\(m\.is_meta, 0\) = 0/);
|
||||
assert.doesNotMatch(ref, /#### `summaries\(opts\?\)`/);
|
||||
assert.doesNotMatch(ref, /#### `raw\(uuid, opts\?\)`/);
|
||||
});
|
||||
|
||||
test('api reference documents query helpers and current return fields', async () => {
|
||||
const ref = await readApiReference();
|
||||
|
||||
assert.match(ref, /## Query API Reference/);
|
||||
assert.match(ref, /#### `summaries\(opts\?\)`/);
|
||||
assert.match(ref, /summary rows/i);
|
||||
assert.match(ref, /session_title/);
|
||||
assert.match(ref, /opts\.branch/);
|
||||
assert.match(ref, /#### `raw\(uuid, opts\?\)`/);
|
||||
assert.match(ref, /original JSONL line/i);
|
||||
assert.match(ref, /opts\.offset/);
|
||||
assert.match(ref, /totalLength/);
|
||||
assert.match(ref, /hasMore/);
|
||||
assert.match(ref, /messageCount/);
|
||||
assert.doesNotMatch(ref, /a\.messages\.length/);
|
||||
assert.doesNotMatch(ref, /Rebuilt on each index pass/);
|
||||
});
|
||||
|
||||
test('skill routes agents to the right reference document', async () => {
|
||||
const skill = await readSkill();
|
||||
|
||||
assert.match(skill, /Reference Map/);
|
||||
assert.match(skill, /references\/schema\.md.*raw SQL/i);
|
||||
assert.match(skill, /references\/api-reference\.md.*helper/i);
|
||||
assert.match(skill, /references\/query-patterns\.md.*synthesis/i);
|
||||
assert.match(skill, /references\/pitfalls\.md.*error/i);
|
||||
});
|
||||
|
||||
test('extractContentType maps Claude content blocks to the message evidence type', () => {
|
||||
assert.equal(extractContentType('hello'), 'text');
|
||||
assert.equal(extractContentType([{ type: 'text', text: 'hello' }]), 'text');
|
||||
assert.equal(extractContentType([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }]), 'text');
|
||||
assert.equal(extractContentType([{ type: 'thinking', thinking: 'hidden reasoning' }]), 'thinking');
|
||||
assert.equal(extractContentType([{ type: 'tool_use', id: 'tool-1', name: 'Read', input: {} }]), 'tool_use');
|
||||
assert.equal(extractContentType([{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }]), 'tool_result');
|
||||
assert.equal(extractContentType([{ type: 'text', text: 'reply' }, { type: 'thinking', thinking: 'hmm' }]), 'unknown');
|
||||
assert.equal(extractContentType([{ type: 'text', text: 'reply' }, { type: 'tool_result', content: 'ok' }]), 'unknown');
|
||||
assert.equal(extractContentType(null), 'unknown');
|
||||
});
|
||||
|
||||
test('extractMessageIsMeta marks injected and command-envelope messages', () => {
|
||||
assert.equal(extractMessageIsMeta({ isMeta: true, message: { content: 'caveat' } }, 'caveat'), 1);
|
||||
assert.equal(extractMessageIsMeta({ message: { isMeta: true, content: 'caveat' } }, 'caveat'), 1);
|
||||
assert.equal(extractMessageIsMeta(
|
||||
{ message: { content: [{ type: 'text', text: '<command-name>/exit</command-name>' }] } },
|
||||
'<command-name>/exit</command-name>',
|
||||
), 1);
|
||||
assert.equal(extractMessageIsMeta(
|
||||
{ message: { content: [{ type: 'text', text: '<system-reminder>Keep answers concise</system-reminder>' }] } },
|
||||
'<system-reminder>Keep answers concise</system-reminder>',
|
||||
), 1);
|
||||
assert.equal(extractMessageIsMeta(
|
||||
{ message: { content: [{ type: 'text', text: '<local-command>git status</local-command>' }] } },
|
||||
'<local-command>git status</local-command>',
|
||||
), 1);
|
||||
assert.equal(extractMessageIsMeta(
|
||||
{ message: { content: [{ type: 'text', text: 'quoted <command-name>/exit</command-name>' }] } },
|
||||
'quoted <command-name>/exit</command-name>',
|
||||
), 0);
|
||||
assert.equal(extractMessageIsMeta({ message: { content: 'normal user request' } }, 'normal user request'), 0);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
import { inferProjectPath, refreshSessionProjectPaths, shouldSkipBuild } from '../scripts/indexer.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
test('inferProjectPath preserves hyphens from observed cwd', () => {
|
||||
assert.equal(
|
||||
inferProjectPath('-Users-dev-Code-quiet-zero', ['/Users/dev/Code/quiet-zero']),
|
||||
'/Users/dev/Code/quiet-zero',
|
||||
);
|
||||
assert.equal(
|
||||
inferProjectPath('-Users-dev-Code-research-widget-svc', ['/Users/dev/Code/research/widget-svc']),
|
||||
'/Users/dev/Code/research/widget-svc',
|
||||
);
|
||||
});
|
||||
|
||||
test('inferProjectPath falls back to legacy slug decoding without cwd evidence', () => {
|
||||
assert.equal(
|
||||
inferProjectPath('-Users-dev-Code-quiet-zero', []),
|
||||
'/Users/dev/Code/quiet/zero',
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshSessionProjectPaths repairs indexed sessions from message cwd', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, project TEXT, project_path TEXT
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
uuid TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT, cwd TEXT
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO sessions (id, project, project_path) VALUES (?, ?, ?)').run(
|
||||
'sid-1',
|
||||
'-Users-dev-Code-quiet-zero',
|
||||
'/Users/dev/Code/quiet/zero',
|
||||
);
|
||||
db.prepare('INSERT INTO messages (uuid, session_id, timestamp, cwd) VALUES (?, ?, ?, ?)').run(
|
||||
'msg-1',
|
||||
'sid-1',
|
||||
'2026-06-10T10:00:00Z',
|
||||
'/Users/dev/Code/quiet-zero',
|
||||
);
|
||||
|
||||
refreshSessionProjectPaths(db);
|
||||
|
||||
assert.equal(
|
||||
db.prepare('SELECT project_path FROM sessions WHERE id=?').get('sid-1').project_path,
|
||||
'/Users/dev/Code/quiet-zero',
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('shouldSkipBuild requires both fresh app heartbeat and successful app build', () => {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE index_state (
|
||||
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER
|
||||
);
|
||||
`);
|
||||
db.prepare('INSERT INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(
|
||||
'__app_heartbeat__',
|
||||
100000,
|
||||
);
|
||||
|
||||
assert.equal(shouldSkipBuild(db, { now: 110000 }).skip, false);
|
||||
|
||||
db.prepare('INSERT INTO index_state (jsonl_path, mtime, lines_processed) VALUES (?, ?, 0)').run(
|
||||
'__app_last_successful_build__',
|
||||
100000,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
shouldSkipBuild(db, { now: 110000 }),
|
||||
{ skip: true, reason: 'app_successful_build' },
|
||||
);
|
||||
assert.equal(shouldSkipBuild(db, { now: 200000 }).skip, false);
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createQueryApi, createAttuneApi } from '../scripts/query.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const SCHEMA = readFileSync(new URL('../scripts/schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
function memoryDb({ projectPath = '/tmp/quiet-zero-test' } = {}) {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(SCHEMA);
|
||||
const insertSession = db.prepare(`
|
||||
INSERT INTO sessions (id, title, project, project_path, started_at, ended_at, git_branch, message_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertSession.run('sid-1', 'Older quiet-zero session', 'quiet-zero', projectPath, '2026-06-09T10:00:00Z', '2026-06-09T11:00:00Z', 'main', 12);
|
||||
insertSession.run('sid-2', 'Memory layer session', 'quiet-zero', projectPath, '2026-06-10T10:00:00Z', '2026-06-10T11:00:00Z', 'codex/memory-layer', 23);
|
||||
insertSession.run('sid-3', 'Other project session', 'other-project', '/tmp/other-project', '2026-06-11T10:00:00Z', '2026-06-11T11:00:00Z', 'main', 5);
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO memories (id, session_id, project, path, summary, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insert.run('mem-1', 'sid-1', 'quiet-zero', '.obelisk/memories/parallel-agents.md', 'Decision: use parallel agents for independent review facets.', '2026-06-09T12:00:00Z');
|
||||
insert.run('mem-2', 'sid-2', 'quiet-zero', '.obelisk/memories/sqlite-memory.md', 'Decision: store markdown memory records in SQLite.', '2026-06-10T12:00:00Z');
|
||||
insert.run('mem-3', 'sid-3', 'other-project', '.obelisk/memories/parallel-agents.md', 'Other project note about parallel agents.', '2026-06-11T12:00:00Z');
|
||||
db.exec("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')");
|
||||
return db;
|
||||
}
|
||||
|
||||
function searchDb() {
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, title TEXT, project TEXT, started_at TEXT,
|
||||
source TEXT DEFAULT 'claude'
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
uuid TEXT PRIMARY KEY, session_id TEXT, text TEXT, role TEXT,
|
||||
timestamp TEXT, model TEXT, cwd TEXT, content_type TEXT,
|
||||
is_meta INTEGER DEFAULT 0, source TEXT DEFAULT 'claude'
|
||||
);
|
||||
CREATE VIRTUAL TABLE messages_fts USING fts5(
|
||||
uuid UNINDEXED, session_id UNINDEXED, text,
|
||||
content=messages, content_rowid=rowid
|
||||
);
|
||||
`);
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, title, project, started_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run('sid-search', 'Search session', 'quiet-zero', '2026-06-10T10:00:00Z');
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO messages (uuid, session_id, text, role, timestamp, model, cwd, content_type, is_meta)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insert.run('msg-meta', 'sid-search', 'needle injected caveat', 'user', '2026-06-10T10:00:30Z', null, '/tmp/quiet-zero', 'text', 1);
|
||||
insert.run('msg-text', 'sid-search', 'needle visible reply', 'assistant', '2026-06-10T10:01:00Z', 'claude-opus', '/tmp/quiet-zero', 'text', 0);
|
||||
insert.run('msg-meta-near', 'sid-search', '<command-name>/exit</command-name>', 'user', '2026-06-10T10:01:30Z', null, '/tmp/quiet-zero', 'text', 1);
|
||||
insert.run('msg-thinking', 'sid-search', 'nearby reasoning trace', 'assistant', '2026-06-10T10:02:00Z', 'claude-opus', '/tmp/quiet-zero', 'thinking', 0);
|
||||
db.exec("INSERT INTO messages_fts(messages_fts) VALUES('rebuild')");
|
||||
return db;
|
||||
}
|
||||
|
||||
test('search exposes content_type on hits and temporal context', () => {
|
||||
const db = searchDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
const rows = api.search('needle', { limit: 1 });
|
||||
|
||||
assert.equal(rows[0].message.uuid, 'msg-text');
|
||||
assert.equal(rows[0].message.content_type, 'text');
|
||||
assert.equal(rows[0].message.is_meta, 0);
|
||||
assert.equal(rows[0].context[0].uuid, 'msg-thinking');
|
||||
assert.equal(rows[0].context[0].content_type, 'thinking');
|
||||
assert.equal(rows[0].context[0].is_meta, 0);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('search and thread omit meta messages by default and expose them on request', () => {
|
||||
const db = searchDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.deepEqual(api.search('injected', { limit: 5 }), []);
|
||||
|
||||
const withMeta = api.search('injected', { includeMeta: true, limit: 5 });
|
||||
assert.equal(withMeta[0].message.uuid, 'msg-meta');
|
||||
assert.equal(withMeta[0].message.is_meta, 1);
|
||||
|
||||
assert.deepEqual(api.thread('sid-search').map(m => m.uuid), ['msg-text', 'msg-thinking']);
|
||||
assert.deepEqual(
|
||||
api.thread('sid-search', { includeMeta: true }).map(m => m.uuid),
|
||||
['msg-meta', 'msg-text', 'msg-meta-near', 'msg-thinking'],
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('memories follows list-helper scalar opts and filters by query within scope', () => {
|
||||
const db = memoryDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.deepEqual(api.memories('sid-1').map(m => m.id), ['mem-1']);
|
||||
assert.deepEqual(api.memories(1).map(m => m.id), ['mem-3']);
|
||||
assert.deepEqual(
|
||||
api.memories({ project: '%quiet-zero%', query: 'parallel agents', limit: 5 }).map(m => m.id),
|
||||
['mem-1'],
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('memories requires English query terms', () => {
|
||||
const db = memoryDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.throws(
|
||||
() => api.memories({ query: '记忆层', limit: 5 }),
|
||||
/memories\(\) query must use English terms/,
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('memories uses FTS recall with safe English tokenization and rank', () => {
|
||||
const db = memoryDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
const rows = api.memories({ project: '%quiet-zero%', query: 'sqlite-memory', limit: 5 });
|
||||
|
||||
assert.deepEqual(rows.map(m => m.id), ['mem-2']);
|
||||
assert.equal(typeof rows[0].rank, 'number');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('memories does not broaden punctuation-only FTS queries into full recall', () => {
|
||||
const db = memoryDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.deepEqual(api.memories({ project: '%quiet-zero%', query: '---', limit: 5 }), []);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('overview returns a compact current-project map with bounded sessions', () => {
|
||||
const db = memoryDb({ projectPath: process.cwd() });
|
||||
const api = createQueryApi(db);
|
||||
|
||||
const view = api.overview({ limit: 1, projectLimit: 5 });
|
||||
|
||||
assert.equal(view.current.cwd, process.cwd());
|
||||
assert.equal(view.current.project.project, 'quiet-zero');
|
||||
assert.equal(view.current.project.source, 'cwd_project_path');
|
||||
assert.equal(view.current.project.confidence, 'exact');
|
||||
assert.equal('session' in view.current, false);
|
||||
assert.equal(view.current_project.session_total, 2);
|
||||
assert.deepEqual(view.current_project.sessions.map(s => s.id), ['sid-2']);
|
||||
assert.equal(view.current_project.memory_total, 2);
|
||||
assert.deepEqual(view.current_project.memories.map(m => m.id), ['mem-2', 'mem-1']);
|
||||
assert.equal(view.totals.projects, 2);
|
||||
assert.equal(view.totals.sessions, 3);
|
||||
assert.equal(view.totals.memories, 3);
|
||||
assert.ok(view.projects.some(p => p.project === 'quiet-zero' && p.session_count === 2 && p.memory_count === 2));
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('query api is read-only and does not expose attune helpers', () => {
|
||||
const db = memoryDb();
|
||||
const api = createQueryApi(db);
|
||||
|
||||
assert.equal(api.remember, undefined);
|
||||
assert.equal(api.forget, undefined);
|
||||
assert.equal(typeof api.overview, 'function');
|
||||
assert.deepEqual(api.sql('SELECT id FROM memories ORDER BY id').map(r => r.id), ['mem-1', 'mem-2', 'mem-3']);
|
||||
assert.throws(
|
||||
() => api.sql("INSERT INTO memories (id, path, summary) VALUES ('mem-x', '/tmp/x.md', 'x')"),
|
||||
/sql\(\) only supports read-only SELECT\/WITH queries/,
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('attune api exposes only memory mutation helpers', () => {
|
||||
const db = memoryDb();
|
||||
const api = createAttuneApi(db);
|
||||
|
||||
assert.deepEqual(Object.keys(api).sort(), ['forget', 'remember']);
|
||||
assert.equal(api.search, undefined);
|
||||
assert.equal(api.sql, undefined);
|
||||
assert.equal(typeof api.remember, 'function');
|
||||
assert.equal(typeof api.forget, 'function');
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('remember stores absolute project-relative memory path', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
|
||||
const memoryDir = join(projectDir, '.obelisk', 'memories');
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
const memoryPath = join(memoryDir, 'decision.md');
|
||||
writeFileSync(memoryPath, '# Decision\n');
|
||||
const db = memoryDb({ projectPath: projectDir });
|
||||
const api = createAttuneApi(db);
|
||||
|
||||
const result = api.remember({
|
||||
path: '.obelisk/memories/decision.md',
|
||||
session_id: 'sid-1',
|
||||
summary: 'Decision: store normalized memory paths.',
|
||||
});
|
||||
|
||||
assert.equal(result.path, memoryPath);
|
||||
assert.equal(db.prepare('SELECT path FROM memories WHERE id=?').get(result.id).path, memoryPath);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('remember updates FTS recall for the registered memory immediately', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
|
||||
const memoryDir = join(projectDir, '.obelisk', 'memories');
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
const memoryPath = join(memoryDir, 'query-plan.md');
|
||||
writeFileSync(memoryPath, '# Query Plan\n');
|
||||
const db = memoryDb({ projectPath: projectDir });
|
||||
const api = createAttuneApi(db);
|
||||
|
||||
const registered = api.remember({
|
||||
path: '.obelisk/memories/query-plan.md',
|
||||
session_id: 'sid-2',
|
||||
summary: 'Decision: use faceted query plans for synthesis recall.',
|
||||
});
|
||||
const rows = createQueryApi(db).memories({
|
||||
project: '%quiet-zero%',
|
||||
query: 'faceted query plans',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
assert.deepEqual(rows.map(m => m.id), [registered.id]);
|
||||
assert.equal(typeof rows[0].rank, 'number');
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('forget soft-deletes memory records from active recall', () => {
|
||||
const db = memoryDb();
|
||||
const api = createAttuneApi(db);
|
||||
|
||||
const result = api.forget({ id: 'mem-1', reason: 'Outdated project guidance.' });
|
||||
|
||||
assert.equal(result.id, 'mem-1');
|
||||
assert.equal(result.deleted_reason, 'Outdated project guidance.');
|
||||
assert.match(result.deleted_at, /^\d{4}-\d{2}-\d{2}T/);
|
||||
const row = db.prepare('SELECT deleted_at, deleted_reason FROM memories WHERE id=?').get('mem-1');
|
||||
assert.equal(row.deleted_at, result.deleted_at);
|
||||
assert.equal(row.deleted_reason, 'Outdated project guidance.');
|
||||
assert.deepEqual(createQueryApi(db).memories({ project: '%quiet-zero%', limit: 10 }).map(m => m.id), ['mem-2']);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('remember requires English summaries', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
|
||||
const memoryDir = join(projectDir, '.obelisk', 'memories');
|
||||
mkdirSync(memoryDir, { recursive: true });
|
||||
const memoryPath = join(memoryDir, 'decision.md');
|
||||
writeFileSync(memoryPath, '# Decision\n');
|
||||
const db = memoryDb({ projectPath: projectDir });
|
||||
const api = createAttuneApi(db);
|
||||
|
||||
assert.throws(
|
||||
() => api.remember({
|
||||
path: '.obelisk/memories/decision.md',
|
||||
session_id: 'sid-1',
|
||||
summary: '决策:记忆摘要必须使用英文。',
|
||||
}),
|
||||
/remember\(\) summary must be written in English/,
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('remember rejects missing memory files', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'obelisk-memory-project-'));
|
||||
const db = memoryDb({ projectPath: projectDir });
|
||||
const api = createAttuneApi(db);
|
||||
|
||||
assert.throws(
|
||||
() => api.remember({
|
||||
path: '.obelisk/memories/missing.md',
|
||||
session_id: 'sid-1',
|
||||
summary: 'Decision: this should not be registered.',
|
||||
}),
|
||||
/remember\(\) memory file does not exist/,
|
||||
);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { buildRecapExportQuery, cleanRecapFilename } = require('../app/recap-capture-query.js');
|
||||
|
||||
test('recap export query includes the selected recap filename', () => {
|
||||
const query = buildRecapExportQuery({
|
||||
cardIdx: 3,
|
||||
archetype: 'shipper',
|
||||
filename: 'recap-2026-06.json',
|
||||
});
|
||||
|
||||
assert.equal(query, 'card=3&arch=shipper&file=recap-2026-06.json');
|
||||
});
|
||||
|
||||
test('recap export query strips paths before passing filename to renderer', () => {
|
||||
assert.equal(cleanRecapFilename('/tmp/recap-2026-W24.json'), 'recap-2026-W24.json');
|
||||
assert.equal(
|
||||
buildRecapExportQuery({
|
||||
cardIdx: '2',
|
||||
archetype: 'architect',
|
||||
filename: '/Users/dev/.obelisk/recap/recap-2026-W24.json',
|
||||
}),
|
||||
'card=2&arch=architect&file=recap-2026-W24.json',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
async function read(path) {
|
||||
return readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
const cards = [
|
||||
['1', 'cover'],
|
||||
['2', 'thinking'],
|
||||
['3', 'vibe'],
|
||||
['4', 'workflow'],
|
||||
['5', 'closing'],
|
||||
];
|
||||
|
||||
test('skill routes only the explicit recap intent to the split recap overview', async () => {
|
||||
const skill = await read('SKILL.md');
|
||||
|
||||
assert.match(skill, /## Intent Routing/);
|
||||
assert.match(skill, /references\/recap\/overview\.md/);
|
||||
assert.match(skill, /first word is `recap`/i);
|
||||
assert.match(skill, /Everything after `recap` is the recap target/);
|
||||
assert.match(skill, /`\/obelisk recap this week`/);
|
||||
assert.match(skill, /`\/obelisk recap this month`/);
|
||||
assert.match(skill, /`\/obelisk recap last week`/);
|
||||
assert.match(skill, /`\/obelisk recap last month`/);
|
||||
assert.match(skill, /do not load\s+`references\/recap\/overview\.md`/);
|
||||
assert.doesNotMatch(skill, /NetEase-style chart/);
|
||||
assert.doesNotMatch(skill, /playful personal progress recap/);
|
||||
});
|
||||
|
||||
test('README lists the recap folder without making recap the core retrieval path', async () => {
|
||||
const readme = await read('README.md');
|
||||
|
||||
assert.match(readme, /references\/recap\/overview\.md/);
|
||||
for (const [n, name] of cards) {
|
||||
assert.match(readme, new RegExp(`references/recap/pattern${n}-${name}\\.md`));
|
||||
assert.match(readme, new RegExp(`references/recap/writing${n}-${name}\\.md`));
|
||||
}
|
||||
assert.match(readme, /optional .*\/obelisk recap/i);
|
||||
assert.match(readme, /explicit `\/obelisk recap` intent/);
|
||||
assert.match(readme, /card-by-card/i);
|
||||
});
|
||||
|
||||
test('old recap references are thin redirects to the split docs', async () => {
|
||||
const retrieval = await read('references/recap-patterns.md');
|
||||
const writing = await read('references/recap-writing.md');
|
||||
|
||||
assert.match(retrieval, /compatibility/i);
|
||||
assert.match(retrieval, /references\/recap\/overview\.md/);
|
||||
assert.match(retrieval, /Do not use this as an all-in-one/i);
|
||||
assert.match(writing, /compatibility/i);
|
||||
assert.match(writing, /references\/recap\/overview\.md/);
|
||||
assert.match(writing, /per-card writing/i);
|
||||
assert.ok(retrieval.length < 1200);
|
||||
assert.ok(writing.length < 1200);
|
||||
});
|
||||
|
||||
test('recap overview defines the card-by-card retrieval and writing loop', async () => {
|
||||
const ref = await read('references/recap/overview.md');
|
||||
|
||||
assert.match(ref, /Highest Priority: Phase Loop/i);
|
||||
assert.match(ref, /Spotify Wrapped-like/i);
|
||||
assert.match(ref, /share cards/i);
|
||||
assert.match(ref, /make the user's work feel seen/i);
|
||||
assert.match(ref, /Do not criticize/i);
|
||||
assert.match(ref, /Do not preload all recap files/i);
|
||||
assert.match(ref, /Do not gather all\s+evidence first/i);
|
||||
assert.match(ref, /Update\/write the JSON for Card 1 now/i);
|
||||
assert.match(ref, /Only after the JSON is updated, move to Card 2/i);
|
||||
assert.match(ref, /pattern1-cover\.md[\s\S]*writing1-cover\.md/);
|
||||
assert.match(ref, /pattern2-thinking\.md[\s\S]*writing2-thinking\.md/);
|
||||
assert.match(ref, /pattern3-vibe\.md[\s\S]*writing3-vibe\.md/);
|
||||
assert.match(ref, /pattern4-workflow\.md[\s\S]*writing4-workflow\.md/);
|
||||
assert.match(ref, /pattern5-closing\.md[\s\S]*writing5-closing\.md/);
|
||||
});
|
||||
|
||||
test('recap overview stays narrow and leaves card details to per-card files', async () => {
|
||||
const ref = await read('references/recap/overview.md');
|
||||
|
||||
assert.ok(ref.split('\n').length < 90);
|
||||
assert.match(ref, /The per-card files own retrieval details/i);
|
||||
assert.doesNotMatch(ref, /schema_version/);
|
||||
assert.doesNotMatch(ref, /obelisk\.recap\.v1/);
|
||||
assert.doesNotMatch(ref, /~\/\.obelisk\/recap\//);
|
||||
assert.doesNotMatch(ref, /references\/schema\.md/);
|
||||
assert.doesNotMatch(ref, /## JSON Shape/);
|
||||
|
||||
for (const archetype of [
|
||||
'architect',
|
||||
'debugger',
|
||||
'shipper',
|
||||
'curator',
|
||||
'director',
|
||||
'cartographer',
|
||||
'wanderer',
|
||||
]) {
|
||||
assert.match(ref, new RegExp(`\\b${archetype}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test('each recap card has a separate retrieval pattern and writing reference', async () => {
|
||||
for (const [n, name] of cards) {
|
||||
const pattern = await read(`references/recap/pattern${n}-${name}.md`);
|
||||
const writing = await read(`references/recap/writing${n}-${name}.md`);
|
||||
|
||||
assert.match(pattern, new RegExp(`# Card ${n} .* Retrieval`));
|
||||
assert.match(pattern, /Read this card's writing file immediately after/i);
|
||||
assert.match(pattern, /update the JSON/i);
|
||||
assert.match(pattern, /evidence/i);
|
||||
if (n !== '5') assert.match(pattern, /Do not read `pattern/);
|
||||
assert.doesNotMatch(pattern, /## JSON Shape/);
|
||||
|
||||
assert.match(writing, new RegExp(`# Card ${n} .* Writing`));
|
||||
assert.match(writing, /Mock taste anchor/i);
|
||||
assert.match(writing, /## JSON Shape/);
|
||||
assert.match(writing, /Before writing/i);
|
||||
assert.match(writing, /After writing/i);
|
||||
assert.match(writing, /evidence_refs/);
|
||||
if (n !== '1' && n !== '5') assert.match(writing, /Update the JSON now before reading/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('cover and closing writing own JSON initialization and final save rules', async () => {
|
||||
const cover = await read('references/recap/writing1-cover.md');
|
||||
const closing = await read('references/recap/writing5-closing.md');
|
||||
|
||||
assert.match(cover, /First JSON Write/i);
|
||||
assert.match(cover, /schema_version: "obelisk\.recap\.v1"/);
|
||||
assert.match(cover, /~\/\.obelisk\/recap\//);
|
||||
assert.match(cover, /recap-\{YYYY\}-W\{WW\}\.json/);
|
||||
assert.match(cover, /recap-\{YYYY\}-\{MM\}\.json/);
|
||||
assert.match(closing, /Final save rules/i);
|
||||
assert.match(closing, /file contains only the JSON object/i);
|
||||
assert.match(closing, /Keep exactly five cards/i);
|
||||
});
|
||||
|
||||
test('cover card retrieval and writing choose one dominant human claim', async () => {
|
||||
const pattern = await read('references/recap/pattern1-cover.md');
|
||||
const writing = await read('references/recap/writing1-cover.md');
|
||||
|
||||
assert.match(pattern, /dominant claim/i);
|
||||
assert.match(pattern, /persona/i);
|
||||
assert.match(pattern, /activity/i);
|
||||
assert.match(pattern, /footer/i);
|
||||
assert.match(pattern, /not a topic inventory/i);
|
||||
assert.match(writing, /从零设计了一个完整的 memory 系统。/);
|
||||
assert.match(writing, /one plain claim/i);
|
||||
assert.match(writing, /one breath/i);
|
||||
assert.match(writing, /not a topic list/i);
|
||||
assert.match(writing, /The Architect/);
|
||||
});
|
||||
|
||||
test('cover card schema uses claim instead of subtitle', async () => {
|
||||
const pattern = await read('references/recap/pattern1-cover.md');
|
||||
const writing = await read('references/recap/writing1-cover.md');
|
||||
const component = await read('app/renderer/src/components/recap/CoverCard.vue');
|
||||
const detail = await read('app/renderer/src/views/RecapDetail.vue');
|
||||
const list = await read('app/renderer/src/views/RecapList.vue');
|
||||
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
assert.match(pattern, /claim that lists three topics/i);
|
||||
assert.doesNotMatch(pattern, /subtitle/);
|
||||
assert.match(writing, /claim: string/);
|
||||
assert.match(writing, /persona: \{ archetype: string; title: string; claim: string; tone: string \}/);
|
||||
assert.match(writing, /cover\.claim/);
|
||||
assert.match(writing, /persona\.claim/);
|
||||
assert.doesNotMatch(writing, /cover\.subtitle/);
|
||||
assert.doesNotMatch(writing, /persona\.subtitle/);
|
||||
assert.match(component, /claim:\s*String/);
|
||||
assert.match(component, /claim\s*\|\|\s*subtitle/);
|
||||
assert.match(detail, /cover\.claim\s*\|\|\s*cover\.subtitle/);
|
||||
assert.match(list, /persona\?\.claim\s*\|\|\s*r\.persona\?\.subtitle/);
|
||||
assert.match(mock, /"claim": "从零设计了一个完整的 memory 系统。"/);
|
||||
});
|
||||
|
||||
test('thinking card retrieval searches for turns instead of implementation timeline', async () => {
|
||||
const pattern = await read('references/recap/pattern2-thinking.md');
|
||||
const writing = await read('references/recap/writing2-thinking.md');
|
||||
|
||||
assert.match(pattern, /turning points/i);
|
||||
assert.match(pattern, /user question/i);
|
||||
assert.match(pattern, /friction/i);
|
||||
assert.match(pattern, /what changed in the user's mind/i);
|
||||
assert.match(pattern, /not a project timeline/i);
|
||||
assert.match(pattern, /not an implementation log/i);
|
||||
assert.match(writing, /Five questions, five turns\./);
|
||||
assert.match(writing, /raw SQLite, no wiki/);
|
||||
assert.match(writing, /unified filter opts, not DSL/);
|
||||
assert.match(writing, /is_error in JSONL/);
|
||||
assert.match(writing, /soft-delete, human-only/);
|
||||
assert.match(writing, /GitHub-style activity timeline/);
|
||||
assert.match(writing, /turn.*short decision fragment/i);
|
||||
});
|
||||
|
||||
test('thinking path schema uses turn instead of outcome', async () => {
|
||||
const pattern = await read('references/recap/pattern2-thinking.md');
|
||||
const writing = await read('references/recap/writing2-thinking.md');
|
||||
const component = await read('app/renderer/src/components/recap/PathCard.vue');
|
||||
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
assert.match(pattern, /prompt, turn, and an `evidence` anchor/i);
|
||||
assert.doesNotMatch(pattern, /outcome/);
|
||||
assert.match(writing, /turn: string/);
|
||||
assert.match(writing, /"turn": "raw SQLite, no wiki"/);
|
||||
assert.match(writing, /`turn`: short decision fragment/i);
|
||||
assert.doesNotMatch(writing, /outcome: string/);
|
||||
assert.match(component, /item\.turn\s*\|\|\s*item\.outcome/);
|
||||
assert.match(mock, /"turn": "raw SQLite, no wiki"/);
|
||||
});
|
||||
|
||||
test('vibe card retrieval finds small user voice, not a correction audit', async () => {
|
||||
const pattern = await read('references/recap/pattern3-vibe.md');
|
||||
const writing = await read('references/recap/writing3-vibe.md');
|
||||
|
||||
assert.match(pattern, /catchphrases/i);
|
||||
assert.match(pattern, /visible user messages/i);
|
||||
assert.match(pattern, /COALESCE\(m\.is_meta,0\)=0/);
|
||||
assert.match(pattern, /not a correction log/i);
|
||||
assert.match(pattern, /not bracketed runtime/i);
|
||||
assert.match(writing, /A short character study\./);
|
||||
assert.match(writing, /这太丑了/);
|
||||
assert.match(writing, /可以/);
|
||||
assert.match(writing, /你在干什么/);
|
||||
assert.match(writing, /voice_lines\[\]\.text.*exact user words/i);
|
||||
assert.match(writing, /meter is not a diagnosis/i);
|
||||
assert.match(writing, /Do not use `\[Request interrupted by user\]`/);
|
||||
});
|
||||
|
||||
test('vibe card schema uses voice_lines instead of observations', async () => {
|
||||
const writing = await read('references/recap/writing3-vibe.md');
|
||||
const component = await read('app/renderer/src/components/recap/VibeCard.vue');
|
||||
const detail = await read('app/renderer/src/views/RecapDetail.vue');
|
||||
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
assert.match(writing, /voice_lines: Array/);
|
||||
assert.match(writing, /"voice_lines": \[/);
|
||||
assert.match(writing, /voice_lines\[\]\.label/);
|
||||
assert.doesNotMatch(writing, /observations: Array/);
|
||||
assert.match(component, /voiceLines:\s*Array/);
|
||||
assert.match(component, /voiceLines\s*\|\|\s*observations/);
|
||||
assert.match(detail, /vibe\.voice_lines\s*\|\|\s*vibe\.observations/);
|
||||
assert.match(mock, /"voice_lines": \[/);
|
||||
});
|
||||
|
||||
test('workflow card retrieval scopes real workflow runs and user reactions', async () => {
|
||||
const pattern = await read('references/recap/pattern4-workflow.md');
|
||||
const writing = await read('references/recap/writing4-workflow.md');
|
||||
|
||||
assert.match(pattern, /workflows\.timestamp/);
|
||||
assert.match(pattern, /workflows\(\{ project: .* after, before/i);
|
||||
assert.match(pattern, /Do not derive workflow counts only from `sessions\(\{ after, before \}\)`/i);
|
||||
assert.match(pattern, /Do not scope workflow lookup by exact `project_path`/i);
|
||||
assert.match(pattern, /user message immediately following/i);
|
||||
assert.match(pattern, /rank rows by the strength of the user reaction/i);
|
||||
assert.match(pattern, /not by agent count/i);
|
||||
assert.match(pattern, /actual workflow_name/i);
|
||||
assert.match(writing, /Three workflows\. Forty-two agents\./);
|
||||
assert.match(writing, /hono-plugin-review/);
|
||||
assert.match(writing, /vue-migration/);
|
||||
assert.match(writing, /split-render-js/);
|
||||
assert.match(writing, /完美/);
|
||||
assert.match(writing, /你这页面完全和之前的不一样/);
|
||||
assert.match(writing, /Mostly tolerated\./);
|
||||
assert.match(writing, /reaction.*user reaction/i);
|
||||
assert.match(writing, /Agent counts belong only in `title` or `stats`/i);
|
||||
assert.match(writing, /`13 agents, the big build`[\s\S]*invalid/i);
|
||||
assert.doesNotMatch(writing, /tiny factual verdict/i);
|
||||
assert.match(writing, /omit the row rather than write an implementation result/i);
|
||||
});
|
||||
|
||||
test('workflow card uses reaction instead of outcome for row copy', async () => {
|
||||
const pattern = await read('references/recap/pattern4-workflow.md');
|
||||
const writing = await read('references/recap/writing4-workflow.md');
|
||||
const component = await read('app/renderer/src/components/recap/WorkflowCard.vue');
|
||||
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
assert.match(pattern, /items\[\]\.reaction/i);
|
||||
assert.match(writing, /reaction: string/);
|
||||
assert.match(writing, /"reaction": "完美"/);
|
||||
assert.match(writing, /items\[\]\.reaction/);
|
||||
assert.doesNotMatch(writing, /items\[\]\.outcome/);
|
||||
assert.doesNotMatch(writing, /"outcome": "完美"/);
|
||||
assert.match(component, /item\.reaction\s*\|\|\s*item\.outcome/);
|
||||
assert.match(mock, /"reaction": "完美"/);
|
||||
});
|
||||
|
||||
test('workflow card schema uses deck instead of summary for the visible line', async () => {
|
||||
const writing = await read('references/recap/writing4-workflow.md');
|
||||
const component = await read('app/renderer/src/components/recap/WorkflowCard.vue');
|
||||
const detail = await read('app/renderer/src/views/RecapDetail.vue');
|
||||
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
assert.match(writing, /deck\?: string/);
|
||||
assert.match(writing, /"deck": "你召唤了机器军团。结果各有不同。"/);
|
||||
assert.match(writing, /`deck`: optional second line/i);
|
||||
assert.doesNotMatch(writing, /summary\?: string/);
|
||||
assert.match(component, /deck:\s*String/);
|
||||
assert.match(component, /deck\s*\|\|\s*summary/);
|
||||
assert.match(detail, /workflow\.deck\s*\|\|\s*workflow\.summary/);
|
||||
assert.match(mock, /"deck": "你召唤了机器军团。结果各有不同。"/);
|
||||
});
|
||||
|
||||
test('closing card retrieval and writing keep a small personal receipt', async () => {
|
||||
const pattern = await read('references/recap/pattern5-closing.md');
|
||||
const writing = await read('references/recap/writing5-closing.md');
|
||||
|
||||
assert.match(pattern, /same period and source scope/i);
|
||||
assert.match(pattern, /streak/i);
|
||||
assert.match(pattern, /most said phrase/i);
|
||||
assert.match(pattern, /consistent metric/i);
|
||||
assert.match(pattern, /not a second summary/i);
|
||||
assert.match(writing, /19 days/);
|
||||
assert.match(writing, /847 messages exchanged/);
|
||||
assert.match(writing, /好的开始做吧/);
|
||||
assert.match(writing, /See you next week\./);
|
||||
assert.match(writing, /not a naked number/i);
|
||||
assert.match(writing, /quiet goodbye/i);
|
||||
assert.match(writing, /at most two `receipts`/i);
|
||||
});
|
||||
|
||||
test('closing card schema uses receipts instead of stats', async () => {
|
||||
const pattern = await read('references/recap/pattern5-closing.md');
|
||||
const writing = await read('references/recap/writing5-closing.md');
|
||||
const component = await read('app/renderer/src/components/recap/ClosingCard.vue');
|
||||
const detail = await read('app/renderer/src/views/RecapDetail.vue');
|
||||
const mock = await read('app/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
assert.match(pattern, /one or two compact receipts/i);
|
||||
assert.doesNotMatch(pattern, /receipt stats/);
|
||||
assert.match(writing, /receipts: string\[\]/);
|
||||
assert.match(writing, /"receipts": \["847 messages exchanged", "12 corrections · 47 approvals"\]/);
|
||||
assert.match(writing, /`receipts`: at most two/i);
|
||||
assert.doesNotMatch(writing, /stats: string\[\]/);
|
||||
assert.match(component, /receipts:\s*Array/);
|
||||
assert.match(component, /receipts\s*\|\|\s*stats/);
|
||||
assert.match(detail, /closing\.receipts\s*\|\|\s*closing\.stats/);
|
||||
assert.match(mock, /"receipts": \["847 messages exchanged", "12 corrections · 47 approvals"\]/);
|
||||
});
|
||||
|
||||
test('split recap writing keeps mixed-language rhythm and plain speech', async () => {
|
||||
const overview = await read('references/recap/overview.md');
|
||||
const writingDocs = await Promise.all(
|
||||
cards.map(([n, name]) => read(`references/recap/writing${n}-${name}.md`)),
|
||||
);
|
||||
const combined = [overview, ...writingDocs].join('\n');
|
||||
|
||||
assert.match(combined, /not a translation task/i);
|
||||
assert.match(combined, /English chrome/i);
|
||||
assert.match(combined, /source language/i);
|
||||
assert.match(combined, /chat bubble/i);
|
||||
assert.match(combined, /If it sounds like a topic list or report heading/i);
|
||||
assert.match(combined, /one plain claim/i);
|
||||
assert.match(combined, /exact user words/i);
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
function runRuntime(args, { home }) {
|
||||
return spawnSync(process.execPath, ['scripts/runtime.mjs', ...args], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function tempHome() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
|
||||
mkdirSync(join(home, '.claude'), { recursive: true });
|
||||
return home;
|
||||
}
|
||||
|
||||
test('runtime query scripts cannot call attune helpers', () => {
|
||||
const home = tempHome();
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(scriptPath, 'return { rememberType: typeof remember, forgetType: typeof forget, overviewType: typeof overview };');
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
rememberType: 'undefined',
|
||||
forgetType: 'undefined',
|
||||
overviewType: 'function',
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime attune scripts expose only memory mutation helpers', () => {
|
||||
const home = tempHome();
|
||||
const memoryPath = join(home, 'memory.md');
|
||||
const scriptPath = join(home, 'attune.mjs');
|
||||
writeFileSync(memoryPath, '# Memory\n');
|
||||
writeFileSync(scriptPath, `
|
||||
return {
|
||||
rememberType: typeof remember,
|
||||
forgetType: typeof forget,
|
||||
searchType: typeof search,
|
||||
sqlType: typeof sql,
|
||||
overviewType: typeof overview,
|
||||
result: remember({
|
||||
path: ${JSON.stringify(memoryPath)},
|
||||
project: 'runtime-test',
|
||||
summary: 'Decision: runtime remember exposes only memory registration.'
|
||||
})
|
||||
};
|
||||
`);
|
||||
|
||||
const result = runRuntime(['--attune', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.equal(payload.rememberType, 'function');
|
||||
assert.equal(payload.forgetType, 'function');
|
||||
assert.equal(payload.searchType, 'undefined');
|
||||
assert.equal(payload.sqlType, 'undefined');
|
||||
assert.equal(payload.overviewType, 'undefined');
|
||||
assert.equal(payload.result.path, memoryPath);
|
||||
});
|
||||
|
||||
test('runtime rejects removed remember mode', () => {
|
||||
const home = tempHome();
|
||||
const scriptPath = join(home, 'remember.mjs');
|
||||
writeFileSync(scriptPath, 'return { ok: true };');
|
||||
|
||||
const result = runRuntime(['--remember', scriptPath], { home });
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, /--attune <file\.js>/);
|
||||
});
|
||||
|
||||
test('runtime migrates old memories schema before recall', () => {
|
||||
const home = tempHome();
|
||||
const dbPath = join(home, '.claude', 'obelisk.sqlite');
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE memories (
|
||||
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
|
||||
message_start TEXT, message_end TEXT,
|
||||
path TEXT, summary TEXT, created_at TEXT
|
||||
);
|
||||
INSERT INTO memories (id, project, path, summary, created_at)
|
||||
VALUES ('mem-old', 'legacy-project', '/tmp/old.md', 'Decision: keep legacy memory rows readable.', '2026-06-10T12:00:00Z');
|
||||
`);
|
||||
db.close();
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(scriptPath, `
|
||||
return memories({ project: "%legacy-project%", query: "legacy memory", limit: 5 })
|
||||
.map(m => ({ id: m.id, rankType: typeof m.rank }));
|
||||
`);
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout), [{ id: 'mem-old', rankType: 'number' }]);
|
||||
});
|
||||
|
||||
test('runtime indexes Codex root sessions into the shared query helpers', () => {
|
||||
const home = tempHome();
|
||||
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const codexId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${codexId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: codexId,
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
cwd: '/tmp/obelisk-runtime',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
source: 'vscode',
|
||||
git: { branch: 'feat/codex' },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'codex user asks for runtime indexing', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: [{ type: 'input_text', text: 'developer replay should stay out of visible search' }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: 'codex assistant replies from runtime' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:03.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call', call_id: 'call_codex_1', name: 'exec_command', arguments: '{"cmd":"pwd"}' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:04.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'function_call_output', call_id: 'call_codex_1', output: '/tmp/obelisk-runtime' },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(scriptPath, `
|
||||
const sid = ${JSON.stringify(`codex:${codexId}`)};
|
||||
return {
|
||||
sessions: sessions({ source: 'codex', limit: 5 }).map(s => ({
|
||||
id: s.id,
|
||||
source: s.source,
|
||||
project: s.project,
|
||||
project_path: s.project_path,
|
||||
git_branch: s.git_branch,
|
||||
version: s.version,
|
||||
message_count: s.message_count
|
||||
})),
|
||||
messages: thread(sid).map(m => ({ role: m.role, text: m.text, source: m.source, content_type: m.content_type })),
|
||||
search: search('runtime indexing', { source: 'codex', limit: 5 }).map(h => ({
|
||||
uuid: h.message.uuid,
|
||||
message_source: h.message.source,
|
||||
session_source: h.session.source
|
||||
})),
|
||||
developerReplay: search('developer replay', { source: 'codex', limit: 5 }).length,
|
||||
rawHasEventLine: raw(${JSON.stringify(`codex:${codexId}:000002`)}, { limit: 1000 })?.text.includes('codex user asks for runtime indexing') || false,
|
||||
tool: sql('SELECT id, message_uuid, session_id, name FROM tool_calls WHERE id=?', 'codex:call_codex_1')[0],
|
||||
toolResult: sql('SELECT tool_use_id, message_uuid, session_id, content FROM tool_results WHERE tool_use_id=?', 'codex:call_codex_1')[0],
|
||||
overviewSources: overview({ limit: 5 }).totals.sources
|
||||
};
|
||||
`);
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.deepEqual(payload.sessions, [{
|
||||
id: `codex:${codexId}`,
|
||||
source: 'codex',
|
||||
project: '-tmp-obelisk-runtime',
|
||||
project_path: '/tmp/obelisk-runtime',
|
||||
git_branch: 'feat/codex',
|
||||
version: '0.135.0-alpha.1',
|
||||
message_count: 3,
|
||||
}]);
|
||||
assert.deepEqual(payload.messages.map(m => [m.role, m.text, m.source, m.content_type]), [
|
||||
['user', 'codex user asks for runtime indexing', 'codex', 'text'],
|
||||
['assistant', 'codex assistant replies from runtime', 'codex', 'text'],
|
||||
['assistant', null, 'codex', 'tool_use'],
|
||||
]);
|
||||
assert.equal(payload.search[0].message_source, 'codex');
|
||||
assert.equal(payload.search[0].session_source, 'codex');
|
||||
assert.equal(payload.developerReplay, 0);
|
||||
assert.equal(payload.rawHasEventLine, true);
|
||||
assert.equal(payload.tool.session_id, `codex:${codexId}`);
|
||||
assert.equal(payload.tool.message_uuid, `codex:${codexId}:000005`);
|
||||
assert.equal(payload.toolResult.message_uuid, `codex:${codexId}:000005`);
|
||||
assert.equal(payload.toolResult.content, '/tmp/obelisk-runtime');
|
||||
assert.ok(payload.overviewSources.some(s => s.source === 'codex' && s.session_count === 1));
|
||||
});
|
||||
|
||||
test('runtime skips Codex guardian review threads', () => {
|
||||
const home = tempHome();
|
||||
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: guardianId,
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
cwd: '/tmp/obelisk-runtime',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
thread_source: 'subagent',
|
||||
source: { subagent: { other: 'guardian' } },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:01.000Z',
|
||||
type: 'turn_context',
|
||||
payload: { cwd: '/tmp/obelisk-runtime', model: 'codex-auto-review' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:02.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'approval guardian prompt', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:03.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: '{"outcome":"allow"}' },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(scriptPath, `
|
||||
const sid = ${JSON.stringify(`codex:${guardianId}`)};
|
||||
return {
|
||||
sessions: sessions({ source: 'codex', limit: 5 }).map(s => s.id),
|
||||
searchCount: search('approval', { source: 'codex', limit: 5 }).length,
|
||||
sessionRows: sql('SELECT COUNT(*) AS c FROM sessions WHERE id=?', sid)[0].c,
|
||||
messageRows: sql('SELECT COUNT(*) AS c FROM messages WHERE session_id=?', sid)[0].c,
|
||||
subagentRows: sql('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?', sid)[0].c
|
||||
};
|
||||
`);
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
sessions: [],
|
||||
searchCount: 0,
|
||||
sessionRows: 0,
|
||||
messageRows: 0,
|
||||
subagentRows: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime removes stale Codex guardian rows when the JSONL was already indexed', () => {
|
||||
const home = tempHome();
|
||||
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const initScriptPath = join(home, 'init.mjs');
|
||||
writeFileSync(initScriptPath, 'return sessions({ source: "codex", limit: 5 }).length;');
|
||||
assert.equal(runRuntime(['--query', initScriptPath], { home }).status, 0);
|
||||
|
||||
const guardianId = '019ed5c4-8d52-7bc0-91f3-447a15e987d1';
|
||||
const guardianSessionId = `codex:${guardianId}`;
|
||||
const jsonlPath = join(codexSessionDir, `rollout-2026-06-15T02-12-00-${guardianId}.jsonl`);
|
||||
writeFileSync(jsonlPath, [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: guardianId,
|
||||
timestamp: '2026-06-14T18:12:00.000Z',
|
||||
cwd: '/tmp/obelisk-runtime',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
thread_source: 'subagent',
|
||||
source: { subagent: { other: 'guardian' } },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T18:12:01.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'stale approval guardian prompt', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const db = new DatabaseSync(join(home, '.obelisk', 'obelisk.sqlite'));
|
||||
db.prepare('INSERT INTO sessions (id,jsonl_path,source,message_count) VALUES (?,?,?,?)').run(guardianSessionId, jsonlPath, 'codex', 1);
|
||||
db.prepare('INSERT INTO messages (uuid,session_id,type,timestamp,role,text,content_type,source) VALUES (?,?,?,?,?,?,?,?)')
|
||||
.run(`${guardianSessionId}:000002`, guardianSessionId, 'user', '2026-06-14T18:12:01.000Z', 'user', 'stale approval guardian prompt', 'text', 'codex');
|
||||
db.prepare('INSERT INTO tool_calls (id,message_uuid,session_id,name,input_json,file_path) VALUES (?,?,?,?,?,?)')
|
||||
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'exec_command', '{}', null);
|
||||
db.prepare('INSERT INTO tool_results (tool_use_id,message_uuid,session_id,content,file_path,is_error) VALUES (?,?,?,?,?,?)')
|
||||
.run('codex:call_guardian', `${guardianSessionId}:000002`, guardianSessionId, 'ok', null, 0);
|
||||
db.prepare('INSERT INTO subagents (agent_id,session_id) VALUES (?,?)').run(guardianSessionId, guardianSessionId);
|
||||
db.prepare('INSERT OR REPLACE INTO index_state (jsonl_path,mtime,lines_processed) VALUES (?,?,?)')
|
||||
.run(jsonlPath, statSync(jsonlPath).mtimeMs, 2);
|
||||
db.prepare("UPDATE index_state SET mtime=? WHERE jsonl_path='__last_build__'").run(Date.now() - 31000);
|
||||
db.close();
|
||||
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(scriptPath, `
|
||||
const sid = ${JSON.stringify(guardianSessionId)};
|
||||
return {
|
||||
sessions: sessions({ source: 'codex', limit: 5 }).map(s => s.id),
|
||||
searchCount: search('stale', { source: 'codex', limit: 5 }).length,
|
||||
sessionRows: sql('SELECT COUNT(*) AS c FROM sessions WHERE id=?', sid)[0].c,
|
||||
messageRows: sql('SELECT COUNT(*) AS c FROM messages WHERE session_id=?', sid)[0].c,
|
||||
toolRows: sql('SELECT COUNT(*) AS c FROM tool_calls WHERE session_id=?', sid)[0].c,
|
||||
resultRows: sql('SELECT COUNT(*) AS c FROM tool_results WHERE session_id=?', sid)[0].c,
|
||||
subagentRows: sql('SELECT COUNT(*) AS c FROM subagents WHERE agent_id=?', sid)[0].c
|
||||
};
|
||||
`);
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(result.stdout), {
|
||||
sessions: [],
|
||||
searchCount: 0,
|
||||
sessionRows: 0,
|
||||
messageRows: 0,
|
||||
toolRows: 0,
|
||||
resultRows: 0,
|
||||
subagentRows: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime maps Codex child threads onto subagents', () => {
|
||||
const home = tempHome();
|
||||
const codexSessionDir = join(home, '.codex', 'sessions', '2026', '06', '15');
|
||||
mkdirSync(codexSessionDir, { recursive: true });
|
||||
|
||||
const parentId = '019ec6ee-cebd-7431-9c93-ceec89a98a5f';
|
||||
const childId = '019ec739-9f75-7a02-ba2a-371986e23823';
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T00-19-59-${parentId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: parentId,
|
||||
timestamp: '2026-06-14T16:19:59.842Z',
|
||||
cwd: '/tmp/obelisk-runtime',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
source: 'vscode',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T16:20:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'collab_agent_spawn_end',
|
||||
call_id: 'call_spawn_1',
|
||||
sender_thread_id: parentId,
|
||||
new_thread_id: childId,
|
||||
new_agent_nickname: 'Plato',
|
||||
new_agent_role: 'worker',
|
||||
prompt: 'inspect skill-side codex indexing',
|
||||
},
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
writeFileSync(join(codexSessionDir, `rollout-2026-06-15T01-41-42-${childId}.jsonl`), [
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:42.924Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: childId,
|
||||
timestamp: '2026-06-14T17:41:42.924Z',
|
||||
cwd: '/tmp/obelisk-runtime',
|
||||
cli_version: '0.135.0-alpha.1',
|
||||
source: {
|
||||
subagent: {
|
||||
thread_spawn: {
|
||||
parent_thread_id: parentId,
|
||||
agent_nickname: 'Plato',
|
||||
agent_role: 'worker',
|
||||
},
|
||||
},
|
||||
},
|
||||
agent_nickname: 'Plato',
|
||||
agent_role: 'worker',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:43.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'subagent prompt', images: [], local_images: [], text_elements: [] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-06-14T17:41:44.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', phase: 'final_answer', message: 'subagent answer' },
|
||||
}),
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const scriptPath = join(home, 'query.mjs');
|
||||
writeFileSync(scriptPath, `
|
||||
return {
|
||||
parentSessions: sessions({ source: 'codex', limit: 5 }).map(s => s.id),
|
||||
subagents: subagents({ source: 'codex', limit: 5 }).map(sa => ({
|
||||
agent_id: sa.agent_id,
|
||||
session_id: sa.session_id,
|
||||
parent_tool_use_id: sa.parent_tool_use_id,
|
||||
agent_type: sa.agent_type,
|
||||
description: sa.description,
|
||||
messageCount: sa.messageCount
|
||||
})),
|
||||
childMessages: sql(
|
||||
'SELECT session_id, agent_id, is_sidechain, source, text FROM messages WHERE agent_id=? ORDER BY timestamp, uuid',
|
||||
${JSON.stringify(`codex:${childId}`)}
|
||||
)
|
||||
};
|
||||
`);
|
||||
|
||||
const result = runRuntime(['--query', scriptPath], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
assert.deepEqual(payload.parentSessions, [`codex:${parentId}`]);
|
||||
assert.deepEqual(payload.subagents, [{
|
||||
agent_id: `codex:${childId}`,
|
||||
session_id: `codex:${parentId}`,
|
||||
parent_tool_use_id: 'codex:call_spawn_1',
|
||||
agent_type: 'worker',
|
||||
description: 'Plato',
|
||||
messageCount: 2,
|
||||
}]);
|
||||
assert.deepEqual(payload.childMessages.map(m => [m.session_id, m.agent_id, m.is_sidechain, m.source, m.text]), [
|
||||
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent prompt'],
|
||||
[`codex:${parentId}`, `codex:${childId}`, 1, 'codex', 'subagent answer'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
createSessionLiveState,
|
||||
consumeSessionDirty,
|
||||
noteSessionUpdated,
|
||||
} from '../app/renderer/src/session-live.mjs';
|
||||
|
||||
test('session live state marks non-visible updated sessions as dirty', () => {
|
||||
const live = createSessionLiveState();
|
||||
|
||||
const action = noteSessionUpdated(live, 'session-2', 'session-1');
|
||||
|
||||
assert.deepEqual(action, { reload: false, sessionId: 'session-2' });
|
||||
assert.equal(consumeSessionDirty(live, 'session-2'), true);
|
||||
assert.equal(consumeSessionDirty(live, 'session-2'), false);
|
||||
});
|
||||
|
||||
test('session live state reloads the visible session without leaving it dirty', () => {
|
||||
const live = createSessionLiveState();
|
||||
|
||||
const action = noteSessionUpdated(live, 'session-1', 'session-1');
|
||||
|
||||
assert.deepEqual(action, { reload: true, sessionId: 'session-1' });
|
||||
assert.equal(consumeSessionDirty(live, 'session-1'), false);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildSidebarProjects } from '../app/renderer/src/sidebar-projects.mjs';
|
||||
|
||||
test('session sidebar projects follow database recency order instead of label order', () => {
|
||||
const sessions = [
|
||||
{ project: '-Users-dev-Code-sample-cli-' },
|
||||
{ project: '-Users-dev-Code-quiet-zero' },
|
||||
{ project: '-Users-dev-Code-quiet-zero' },
|
||||
{ project: '-Users-dev-Library-Application-Support-Example-App-namespaces-release-stable-data-projects-00000000-1111-2222-3333-444444444444' },
|
||||
];
|
||||
const projects = [
|
||||
{ project: '-Users-dev-Code-quiet-zero' },
|
||||
{ project: '-Users-dev-Code-sample-cli-' },
|
||||
{ project: '-Users-dev-Library-Application-Support-Example-App-namespaces-release-stable-data-projects-00000000-1111-2222-3333-444444444444' },
|
||||
];
|
||||
const labels = {
|
||||
'-Users-dev-Code-sample-cli-': 'sample-cli+',
|
||||
'-Users-dev-Code-quiet-zero': 'quiet-zero',
|
||||
'-Users-dev-Library-Application-Support-Example-App-namespaces-release-stable-data-projects-00000000-1111-2222-3333-444444444444': '00000000-1111-2...',
|
||||
};
|
||||
|
||||
const result = buildSidebarProjects({
|
||||
routeType: 'sessions',
|
||||
sessions,
|
||||
projects,
|
||||
formatProjectLabel: slug => labels[slug] || slug,
|
||||
});
|
||||
|
||||
assert.deepEqual(result.map(project => project.label), [
|
||||
'quiet-zero',
|
||||
'sample-cli+',
|
||||
'00000000-1111-2...',
|
||||
]);
|
||||
assert.equal(result[0].count, 2);
|
||||
});
|
||||
|
||||
test('memory sidebar projects filter by archive state and label search', () => {
|
||||
const result = buildSidebarProjects({
|
||||
routeType: 'memory',
|
||||
view: 'active',
|
||||
search: 'quiet',
|
||||
memories: [
|
||||
{ project: 'quiet-zero', archived: false },
|
||||
{ project: 'quiet-zero', archived: true },
|
||||
{ project: 'sample-lib', archived: false },
|
||||
],
|
||||
projects: [
|
||||
{ project: 'sample-lib' },
|
||||
{ project: 'quiet-zero' },
|
||||
],
|
||||
formatProjectLabel: slug => slug,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [{ slug: 'quiet-zero', label: 'quiet-zero', count: 1 }]);
|
||||
});
|
||||
Reference in New Issue
Block a user