feat(activity): add contribution ledger
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
activityGroupHasMixedSources,
|
||||
activitySessionMetaParts,
|
||||
activitySourceLabel,
|
||||
} from '../app/src/renderer/src/activity-ledger.mjs';
|
||||
|
||||
const claudeSession = {
|
||||
source: 'claude',
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_count: 2716,
|
||||
};
|
||||
const codexSession = {
|
||||
source: 'codex',
|
||||
project: '-Users-tomiya-Code-quiet-zero',
|
||||
message_count: 2052,
|
||||
};
|
||||
|
||||
test('single-source activity groups omit provider provenance', () => {
|
||||
const split = { normal: [codexSession], noise: [{ ...codexSession, message_count: 12 }] };
|
||||
|
||||
assert.equal(activityGroupHasMixedSources(split), false);
|
||||
assert.deepEqual(
|
||||
activitySessionMetaParts(codexSession, {
|
||||
mixedSources: false,
|
||||
projectLabel: 'quiet-zero',
|
||||
}),
|
||||
[
|
||||
{ kind: 'project', text: 'quiet-zero' },
|
||||
{ kind: 'count', text: '2,052 msg' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed activity groups expose provider before project and count', () => {
|
||||
const split = { normal: [codexSession], noise: [claudeSession] };
|
||||
|
||||
assert.equal(activityGroupHasMixedSources(split), true);
|
||||
assert.deepEqual(
|
||||
activitySessionMetaParts(claudeSession, {
|
||||
mixedSources: true,
|
||||
projectLabel: 'quiet-zero',
|
||||
}),
|
||||
[
|
||||
{ kind: 'source', text: 'Claude Code' },
|
||||
{ kind: 'project', text: 'quiet-zero' },
|
||||
{ kind: 'count', text: '2,716 msg' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace activity omits redundant project scope', () => {
|
||||
assert.deepEqual(
|
||||
activitySessionMetaParts(codexSession, {
|
||||
mixedSources: false,
|
||||
projectLabel: 'quiet-zero',
|
||||
includeProject: false,
|
||||
}),
|
||||
[{ kind: 'count', text: '2,052 msg' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown providers retain their own provenance label', () => {
|
||||
assert.equal(activitySourceLabel({ source: 'opencode' }), 'Opencode');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const preloadUrl = new URL('../app/src/preload/index.ts', import.meta.url);
|
||||
const preloadDir = fileURLToPath(new URL('.', preloadUrl));
|
||||
|
||||
function esmResolve(specifier) {
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
['--input-type=module', '-e', `process.stdout.write(import.meta.resolve(${JSON.stringify(specifier)}))`],
|
||||
{ cwd: preloadDir, encoding: 'utf8' },
|
||||
).trim();
|
||||
}
|
||||
|
||||
test('Activity requests usage across all indexed providers', () => {
|
||||
const source = readFileSync(new URL('../app/src/renderer/src/views/Activity.vue', import.meta.url), 'utf8');
|
||||
assert.match(source, /getUsageStats\(\{\s*source:\s*['"]all['"]\s*\}\)/);
|
||||
assert.match(source, /onIndexUpdated\?\.\(\(\)\s*=>\s*\{?\s*(?:void\s+)?loadUsageStats\(\)/s);
|
||||
assert.match(source, /Array\.from\(\{\s*length:\s*loadedMonths\.value\s*\}/);
|
||||
assert.doesNotMatch(source, /monthBlocks\s*=\s*ref\(/);
|
||||
});
|
||||
|
||||
test('preload forwards usage source options to the main process', async () => {
|
||||
const calls = [];
|
||||
let api;
|
||||
const electron = mock.module(esmResolve('electron'), {
|
||||
namedExports: {
|
||||
contextBridge: {
|
||||
exposeInMainWorld(_name, exposedApi) {
|
||||
api = exposedApi;
|
||||
},
|
||||
},
|
||||
ipcRenderer: {
|
||||
invoke(...args) {
|
||||
calls.push(args);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
on() {},
|
||||
removeListener() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await import(`${preloadUrl.href}?activity-usage=${Date.now()}`);
|
||||
await api.getUsageStats({ source: 'all' });
|
||||
assert.deepEqual(calls.at(-1), ['db:getUsageStats', { source: 'all' }]);
|
||||
} finally {
|
||||
electron.restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -405,7 +405,7 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
|
||||
ipcHandlers.get('db:getProjects')(null, {});
|
||||
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
||||
|
||||
ipcHandlers.get('db:getSessions')(null, { includeCodex: true });
|
||||
ipcHandlers.get('db:getSessions')(null, { source: 'all' });
|
||||
assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
|
||||
|
||||
ipcHandlers.get('db:getSessions')(null, { source: 'codex' });
|
||||
@@ -426,6 +426,79 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
|
||||
}
|
||||
});
|
||||
|
||||
test('usage IPC aggregates normalized tokens across all indexed providers', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = join(tmpdir(), `obelisk-main-usage-${Date.now()}`);
|
||||
const obeliskDir = join(home, '.obelisk');
|
||||
mkdirSync(obeliskDir, { recursive: true });
|
||||
process.env.HOME = home;
|
||||
|
||||
const dbPath = join(obeliskDir, 'obelisk.sqlite');
|
||||
const setup = new DatabaseSync(dbPath);
|
||||
setup.exec(readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8'));
|
||||
setup.prepare(`
|
||||
INSERT INTO messages (
|
||||
uuid, session_id, type, timestamp, role, text,
|
||||
input_tokens, output_tokens, source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('claude-message', 'claude-session', 'assistant', '2026-07-10T10:00:00Z', 'assistant', 'ok', 60, 5, 'claude');
|
||||
setup.prepare(`
|
||||
INSERT INTO messages (
|
||||
uuid, session_id, type, timestamp, role, text,
|
||||
input_tokens, output_tokens, source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('codex-message', 'codex:session', 'assistant', '2026-07-10T11:00:00Z', 'assistant', 'ok', 100, 10, 'codex');
|
||||
setup.close();
|
||||
|
||||
const ipcHandlers = new Map();
|
||||
|
||||
class FakeBrowserWindow {
|
||||
constructor() {
|
||||
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
|
||||
}
|
||||
loadFile() {}
|
||||
loadURL() {}
|
||||
close() {}
|
||||
static getAllWindows() { return []; }
|
||||
static fromWebContents() { return null; }
|
||||
}
|
||||
|
||||
const restore = registerMocks([
|
||||
[ELECTRON_URL, {
|
||||
namedExports: electronNamespace({
|
||||
BrowserWindow: FakeBrowserWindow,
|
||||
ipcMain: {
|
||||
handle(channel, handler) {
|
||||
ipcHandlers.set(channel, handler);
|
||||
},
|
||||
},
|
||||
}),
|
||||
}],
|
||||
[DATABASE_URL, { defaultExport: SqliteCompatDatabase }],
|
||||
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
|
||||
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
|
||||
[INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }],
|
||||
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
|
||||
]);
|
||||
|
||||
try {
|
||||
await importMain();
|
||||
|
||||
const claudeOnly = ipcHandlers.get('db:getUsageStats')(null, {});
|
||||
assert.equal(claudeOnly.totalTokens, 65);
|
||||
assert.equal(claudeOnly.daily[0].tokens, 65);
|
||||
|
||||
const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' });
|
||||
assert.equal(allSources.totalTokens, 175);
|
||||
assert.equal(allSources.daily[0].tokens, 175);
|
||||
assert.equal(allSources.peakDay.tokens, 175);
|
||||
} finally {
|
||||
restore();
|
||||
process.env.HOME = originalHome;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('main process migrates an existing app database before source-filtered IPC queries', async () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);
|
||||
|
||||
Reference in New Issue
Block a user