571 lines
40 KiB
JavaScript
571 lines
40 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync, realpathSync } from 'node:fs';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { dateRange, messageKind, issueNumbers, stackRefs, totalsLine, summarize } from '../src/ledger.mjs';
|
|
import { readT3 } from '../src/t3.mjs';
|
|
|
|
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
|
|
const range = dateRange('2026-09-06', '2026-09-12');
|
|
// T3 fixture schema: the live tables, cut to the columns the reader uses plus
|
|
// one it doesn't. `text` allows NULL so a non-text row can be tested.
|
|
const T3_SCHEMA = `
|
|
create table projection_projects (project_id text primary key, title text not null, workspace_root text not null, deleted_at text);
|
|
create table projection_threads (thread_id text primary key, project_id text not null, title text not null, archived_at text, deleted_at text);
|
|
create table projection_thread_messages (message_id text primary key, thread_id text not null, role text not null, text, created_at text not null);
|
|
create table orchestration_events (sequence integer primary key autoincrement, stream_id text not null, event_type text not null, payload_json text not null, metadata_json text not null);`;
|
|
// Writes a T3 database in WAL mode. Threads default to project p1, which is
|
|
// the fixture root. Returns the open writer when keepOpen is set.
|
|
function t3db(file, { root, projects, threads = [], messages = [], after = [], keepOpen = false }) {
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
for (const old of [file, `${file}-wal`, `${file}-shm`]) rmSync(old, { force: true });
|
|
const db = new DatabaseSync(file);
|
|
db.exec('pragma journal_mode=wal'); db.exec(T3_SCHEMA);
|
|
for (const [id, workspace, deleted = null] of projects ?? [['p1', root]]) {
|
|
db.prepare('insert into projection_projects values (?, ?, ?, ?)').run(id, 'project', workspace, deleted);
|
|
}
|
|
for (const t of threads) {
|
|
db.prepare('insert into projection_threads values (?, ?, ?, ?, ?)').run(t.id, t.project ?? 'p1', t.title, t.archived ?? null, t.deleted ?? null);
|
|
}
|
|
for (const m of messages) addMessage(db, m);
|
|
for (const sql of after) db.exec(sql);
|
|
if (keepOpen) return db;
|
|
db.close();
|
|
}
|
|
let messageId = 0;
|
|
function addMessage(db, { thread, text, role = 'user', at = '2026-09-08T12:00:00Z', origin = 'app', id = `m${++messageId}` }) {
|
|
db.prepare('insert into projection_thread_messages values (?, ?, ?, ?, ?)').run(id, thread, role, text, at);
|
|
if (origin !== 'none') db.prepare('insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values (?, ?, ?, ?)')
|
|
.run(thread, 'thread.message-sent', JSON.stringify({ messageId: id, threadId: thread, role, text }), JSON.stringify({ origin: origin === 'app' ? { appVersion: '0.0.0' } : {} }));
|
|
}
|
|
const fixtureIssues = [
|
|
{ number: 1, title: 'First issue', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T12:00:00Z' },
|
|
{ number: 2, title: 'Second issue', created_at: '2026-09-06T00:00:00Z', closed_at: null },
|
|
];
|
|
function fixture(t) {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
|
|
// No test opens the real ~/.t3: every CLI run gets this HOME, with an empty
|
|
// T3 database at the default path. The one in-process readT3 call passes an
|
|
// explicit fixture path. The CLI's root is a realpath.
|
|
const home = mkdtempSync(path.join(os.tmpdir(), 'ledger-home-'));
|
|
t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); });
|
|
const defaultDb = path.join(home, '.t3/userdata/state.sqlite');
|
|
t3db(defaultDb, { root: realpathSync(root) });
|
|
const put = (name, data) => { const p = path.join(root, name); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, data); return p; };
|
|
const git = (args, date = '2026-09-07T00:00:00Z') => execFileSync('git', args, { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, stdio: 'pipe' });
|
|
git(['init', '-b', 'refactor']); git(['config', 'user.email', '[email protected]']); git(['config', 'user.name', 'Fixture']);
|
|
const commit = (subject, date, body) => git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', subject, ...(body ? ['-m', body] : [])], date);
|
|
commit('first #1'); commit('first #2'); commit('follow-up #2'); commit('untagged', undefined, 'body only #3');
|
|
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
|
|
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
|
|
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
|
|
// The queue section imports the queue validator and the seat registry.
|
|
for (const pkg of ['queue', 'seat']) cpSync(path.join(source, '../..', pkg, 'src'), path.join(root, 'packages', pkg, 'src'), { recursive: true });
|
|
mkdirSync(path.join(root, 'packages/organization/src'), { recursive: true });
|
|
for (const file of ['util.mjs', 'errors.mjs', 'vocabulary.mjs'])
|
|
cpSync(path.join(source, '../../organization/src', file), path.join(root, 'packages/organization/src', file));
|
|
// The fake serves ISSUES for every page, or page N of the ISSUE_PAGES array
|
|
// (an empty page past its end). API_FAIL fails every call, or only FAIL_PAGE.
|
|
const api = put('bin/gitea-api.sh', `#!/usr/bin/env node
|
|
const fs = require('fs'), args = process.argv.slice(2), page = Number(new URL(args[1], 'http://x/').searchParams.get('page'));
|
|
fs.appendFileSync(process.env.CALLS, JSON.stringify(args) + '\\n');
|
|
if (process.env.API_FAIL && (!process.env.FAIL_PAGE || Number(process.env.FAIL_PAGE) === page)) { console.error('secret must not escape'); process.exit(Number(process.env.API_FAIL)); }
|
|
if (process.env.ISSUE_PAGES) process.stdout.write(JSON.stringify(JSON.parse(fs.readFileSync(process.env.ISSUE_PAGES, 'utf8'))[page - 1] ?? []));
|
|
else process.stdout.write(fs.readFileSync(process.env.ISSUES, 'utf8'));
|
|
`);
|
|
execFileSync('chmod', ['+x', api]);
|
|
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
|
|
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
|
|
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
|
|
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
|
|
// These tests cover the weekly table; queue-checks.test.mjs covers the queue section.
|
|
// The fixture numbers sit below the stack floor, so runs pass --first-issue 1
|
|
// unless a test asks for the default floor with { floor: [] }.
|
|
const run = (args = [], env = {}, { floor = ['--first-issue', '1'] } = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', '--no-queue', ...floor, ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: home, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
|
|
return { root, real: realpathSync(root), home, defaultDb, put, commit, run, entry, logs };
|
|
}
|
|
test('fixture git subjects only, follow-ups and three session kinds', t => {
|
|
const f = fixture(t), result = f.run(['--json']);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const r = JSON.parse(result.stdout);
|
|
assert.deepEqual(r.issues.map(x => [x.issue, x.commits, x.followUps, x.hoursOpen, x.seats]), [[1, 1, 0, 36, ['alice']], [2, 2, 1, 'open', ['alice']]]);
|
|
assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
|
|
assert.deepEqual(r.totals, { issuesClosed: 1, medianHoursOpen: 36, commits: 3, followUpsPerIssue: 0.5, humanMessagesPerClosedIssue: 1 });
|
|
const calls = readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
|
assert.equal(calls.length, 1); assert.equal(calls[0][0], 'GET');
|
|
assert.match(calls[0][1], /^repos\/mosaicstack\/stack\/issues\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=1$/);
|
|
});
|
|
test('text and JSON carry same numbers, open and truncated title', t => {
|
|
const f = fixture(t); f.put('issues.json', JSON.stringify([{ ...fixtureIssues[0], title: 'x'.repeat(100) }, fixtureIssues[1]]));
|
|
const text = f.run(), json = f.run(['--json']);
|
|
assert.equal(text.status, 0, text.stderr); const r = JSON.parse(json.stdout);
|
|
assert.ok(text.stdout.includes(totalsLine(r.totals)));
|
|
assert.match(text.stdout, /#1 \| x{48} \| .* \| 36.0 \| 1 \| 0 \| alice/);
|
|
assert.match(text.stdout, /#2 \| Second issue \| .* \| open \| 2 \| 1 \| alice/);
|
|
assert.match(text.stdout, /alice \| 1 \| 1 \| 1/);
|
|
});
|
|
test('missing credentials exit 2, no-issues never calls API and shows unknown', t => {
|
|
const f = fixture(t); const bad = f.run([], { API_FAIL: '3' });
|
|
assert.equal(bad.status, 2); assert.match(bad.stderr, /credentials missing, unreadable, or invalid/); assert.doesNotMatch(bad.stderr, /secret must/); assert.equal(bad.stdout, '');
|
|
f.put('calls.jsonl', ''); const skip = f.run(['--no-issues'], { API_FAIL: '3' });
|
|
assert.equal(skip.status, 0, skip.stderr); assert.match(skip.stdout, /#1 \| unknown \| unknown \| unknown/);
|
|
assert.match(skip.stdout, /issues closed unknown/); assert.equal(readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8'), '');
|
|
});
|
|
test('empty range gives no rows and zero totals', t => {
|
|
const f = fixture(t);
|
|
f.put('issues.json', '[]');
|
|
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json', '--no-queue'], { encoding: 'utf8', env: { ...process.env, HOME: f.home, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
|
|
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
|
|
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
|
|
});
|
|
test('inclusive UTC dates, first-line preamble only, role and seat boundaries', t => {
|
|
const f = fixture(t);
|
|
f.put('.pi/state/alice/sessions/one.jsonl', [f.entry('start #1', '2026-09-06T00:00:00Z'), f.entry('end #2', '2026-09-12T23:59:59.999Z'), f.entry('outside', '2026-09-13T00:00:00Z'), f.entry('human\n[host:control-board -> host:alice] quoted')].map(JSON.stringify).join('\n'));
|
|
f.put('.pi/state/not-a-seat/sessions/one.jsonl', JSON.stringify(f.entry('ignored')));
|
|
f.commit('at end #2', '2026-09-12T23:59:59Z'); f.commit('outside #2', '2026-09-13T00:00:00Z');
|
|
const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
|
|
assert.equal(r.seats[0].human, 3); assert.equal(r.totals.commits, 4);
|
|
});
|
|
test('close-only issue included, even median, missing metadata stays unknown', t => {
|
|
const f = fixture(t);
|
|
f.put('issues.json', JSON.stringify([fixtureIssues[0], { number: 3, title: 'close only', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-08T12:00:00Z' }]));
|
|
const r = JSON.parse(f.run(['--json']).stdout);
|
|
assert.equal(r.issues[1].hoursOpen, 'unknown'); assert.equal(r.issues[2].commits, 0); assert.equal(r.totals.medianHoursOpen, 48); assert.equal(r.totals.issuesClosed, 2);
|
|
});
|
|
test('unique commits but per-issue links count multiple tags once each', t => {
|
|
const f = fixture(t); f.commit('both #1 #2 #2'); const r = JSON.parse(f.run(['--json']).stdout);
|
|
assert.equal(r.totals.commits, 4); assert.deepEqual(r.issues.map(x => x.commits), [2, 3]);
|
|
});
|
|
// Issue pages. Each issue closes in range, so issuesClosed counts every one read.
|
|
const closedIssue = number => ({ number, title: `Issue ${number}`, created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T00:00:00Z' });
|
|
const issuePages = sizes => { let next = 0; return sizes.map(n => Array.from({ length: n }, () => closedIssue(++next))); };
|
|
function paged(t, pages) {
|
|
const f = fixture(t), file = f.put('pages.json', JSON.stringify(pages));
|
|
const calls = () => readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)[1]);
|
|
return { ...f, run: (args = [], env = {}) => f.run(args, { ISSUE_PAGES: file, ...env }), calls };
|
|
}
|
|
test('issue pages of 50, 50 and 7 read 107 issues in 3 pages', t => {
|
|
const f = paged(t, issuePages([50, 50, 7]));
|
|
const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr);
|
|
const r = JSON.parse(result.stdout);
|
|
assert.equal(r.totals.issuesClosed, 107); assert.deepEqual(r.issueSource, { read: true, pages: 3 });
|
|
const calls = f.calls(); assert.equal(calls.length, 3);
|
|
calls.forEach((call, i) => assert.match(call, new RegExp(`^repos/mosaicstack/stack/issues\\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=${i + 1}$`)));
|
|
const text = f.run(); assert.equal(text.status, 0, text.stderr);
|
|
assert.match(text.stdout, /^Issues: 3 pages read from Gitea$/m); assert.match(text.stdout, /issues closed 107 /);
|
|
});
|
|
test('one short page reads 1 page; --no-issues reads none and says so', t => {
|
|
const f = fixture(t), one = f.run();
|
|
assert.equal(one.status, 0, one.stderr); assert.match(one.stdout, /^Issues: 1 page read from Gitea$/m);
|
|
assert.deepEqual(JSON.parse(f.run(['--no-issues', '--json']).stdout).issueSource, { read: false });
|
|
assert.match(f.run(['--no-issues']).stdout, /^Issues: not read \(--no-issues\)$/m);
|
|
});
|
|
test('a short page 10 is read; a full page 10 refuses at the ceiling', t => {
|
|
const last = paged(t, issuePages([...Array(9).fill(50), 1]));
|
|
const ok = last.run(['--json']); assert.equal(ok.status, 0, ok.stderr);
|
|
assert.deepEqual(JSON.parse(ok.stdout).issueSource, { read: true, pages: 10 }); assert.equal(JSON.parse(ok.stdout).totals.issuesClosed, 451);
|
|
const f = paged(t, issuePages(Array(11).fill(50)));
|
|
const result = f.run(); assert.equal(result.status, 2); assert.equal(result.stdout, '');
|
|
assert.match(result.stderr, /page 10 is full, so the 10-page ceiling was reached/);
|
|
assert.equal(f.calls().length, 10);
|
|
});
|
|
test('an issue number on two pages refuses', t => {
|
|
const pages = issuePages([50, 7]); pages[1][3] = closedIssue(50);
|
|
const f = paged(t, pages), result = f.run();
|
|
assert.equal(result.status, 2); assert.equal(result.stdout, ''); assert.match(result.stderr, /duplicate issue number on page 2/);
|
|
});
|
|
test('a failing page 2 refuses the whole read without its stderr', t => {
|
|
const f = paged(t, issuePages([50, 50, 7])), result = f.run([], { API_FAIL: '1', FAIL_PAGE: '2' });
|
|
assert.equal(result.status, 2); assert.equal(result.stdout, '');
|
|
assert.match(result.stderr, /credential or Gitea request failure on page 2/); assert.doesNotMatch(result.stderr, /secret must/);
|
|
assert.equal(f.calls().length, 2);
|
|
});
|
|
for (const [name, bad] of [['a malformed record', { number: 'x' }], ['more than 50 rows', null]]) test(`page 2 with ${name} refuses`, t => {
|
|
const pages = issuePages([50, 7]);
|
|
if (bad) pages[1][0] = bad; else pages[1] = issuePages([101])[0].slice(50);
|
|
const result = paged(t, pages).run(); assert.equal(result.status, 2); assert.equal(result.stdout, '');
|
|
assert.match(result.stderr, /invalid Gitea issue records on page 2/);
|
|
});
|
|
for (const payload of ['not JSON', '{}', '[{"number":1}]']) test(`bad API payload ${payload} refuses`, t => {
|
|
const f = fixture(t); f.put('issues.json', payload); const r = f.run(); assert.equal(r.status, 2); assert.equal(r.stdout, '');
|
|
});
|
|
test('partial or malformed session log refuses with location, not content', t => {
|
|
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
|
|
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
|
|
});
|
|
test('a U+2028 or U+2029 inside a session string is one line, not a malformed record', t => {
|
|
const f = fixture(t);
|
|
f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two\u2029three #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
|
|
const written = readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8');
|
|
assert.ok(written.includes('\u2028') && written.includes('\u2029'));
|
|
const r = f.run(['--json']); assert.equal(r.status, 0, r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout).seats[1], { seat: 'bob', board: 0, agent: 1, human: 1 });
|
|
});
|
|
test('no sessions is an empty table; symlink source refuses', t => {
|
|
const f = fixture(t); rmSync(path.join(f.root, '.pi'), { recursive: true });
|
|
assert.deepEqual(JSON.parse(f.run(['--json']).stdout).seats, []);
|
|
symlinkSync(path.join(f.root, 'agents'), path.join(f.root, '.pi'));
|
|
const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /Cannot read ledger directory/);
|
|
});
|
|
test('reads only refactor even when another branch is checked out', t => {
|
|
const f = fixture(t); execFileSync('git', ['checkout', '-b', 'other'], { cwd: f.root, stdio: 'pipe' }); f.commit('other #1');
|
|
assert.equal(JSON.parse(f.run(['--json']).stdout).totals.commits, 3);
|
|
});
|
|
test('invalid dates, reverse dates and duplicate options refuse', t => {
|
|
assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06'));
|
|
const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1);
|
|
});
|
|
test('T3 agent assignments do not count as human in Table 2', t => {
|
|
const f = fixture(t);
|
|
f.put('.pi/state/bob/sessions/t3.jsonl', [
|
|
f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e) class=actionable]\nassign #1'),
|
|
f.entry('[from: sage (1ef1e4f8) -> to: bob (9cb9731e)]\nfollow-up #1'),
|
|
f.entry('Jason: go ahead'),
|
|
].map(x => JSON.stringify(x)).join('\n') + '\n');
|
|
const result = f.run(['--json']);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const r = JSON.parse(result.stdout);
|
|
assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }, { seat: 'bob', board: 0, agent: 2, human: 1 }]);
|
|
assert.equal(r.totals.humanMessagesPerClosedIssue, 2);
|
|
});
|
|
test('preamble parsing and issue number boundaries', () => {
|
|
assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board');
|
|
assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent');
|
|
assert.equal(messageKind('human\n[h:control-board -> h:seat] hi'), 'human');
|
|
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
|
|
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
|
|
});
|
|
test('tracker names skip their numbers; the floor drops the rest of the foreign refs', () => {
|
|
// A tracker name then the number, any case, one space between.
|
|
assert.deepEqual(issueNumbers('Vikunja #259'), []);
|
|
assert.deepEqual(issueNumbers('P19 #252'), []);
|
|
assert.deepEqual(issueNumbers('Vikunja P19 #232'), []);
|
|
assert.deepEqual(issueNumbers('SetSpark #35 and Launchpad #36'), []);
|
|
assert.deepEqual(issueNumbers('vikunja #1600 p19 #1601 SETSPARK #1602'), []);
|
|
assert.deepEqual(issueNumbers('xvikunja #1600 Vikunja #1601'), [1600, 1601]);
|
|
// A range after a tracker name: the second number has no name before it.
|
|
assert.deepEqual(issueNumbers('P19 #219-#222'), [222]);
|
|
// Bare foreign numbers pass the skip; the floor drops them.
|
|
assert.deepEqual(issueNumbers('#237 proxy'), [237]);
|
|
assert.deepEqual(issueNumbers('Q61a #19'), [19]);
|
|
// Stack issues after another name stay counted.
|
|
assert.deepEqual(issueNumbers('Gitea #1578'), [1578]);
|
|
assert.deepEqual(issueNumbers('issue #1600'), [1600]);
|
|
assert.deepEqual(issueNumbers('#1603, Vikunja #259'), [1603]);
|
|
const commits = [{ hash: 'a', issues: [222, 1600] }, { hash: 'b', issues: [237] }, { hash: 'c', issues: [1494, 1495] }];
|
|
const sessions = { rows: [], mentions: new Map([[19, new Set(['bob'])], [1600, new Set(['alice'])]]) };
|
|
const refs = stackRefs(commits, sessions);
|
|
assert.deepEqual(refs.commits, [{ hash: 'a', issues: [1600] }, { hash: 'c', issues: [1495] }]);
|
|
assert.deepEqual([...refs.sessions.mentions.keys()], [1600]);
|
|
assert.deepEqual(stackRefs(commits, sessions, 1).commits, commits);
|
|
});
|
|
test('the default floor counts only stack issues in the table, commit total and seats', t => {
|
|
const f = fixture(t);
|
|
f.commit('land #1600, P19 #219-#222'); f.commit('Gitea #1601 and Vikunja #1602'); f.commit('P19 #252 only');
|
|
f.put('.pi/state/bob/sessions/b.jsonl', [f.entry('Vikunja #1601 then #1600'), f.entry('Q61a #19')].map(x => JSON.stringify(x)).join('\n') + '\n');
|
|
const result = f.run(['--json'], {}, { floor: [] });
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const r = JSON.parse(result.stdout);
|
|
// #1 stays as a close-only row from Gitea, but alice's "do #1" no longer names it.
|
|
assert.deepEqual(r.issues.map(x => [x.issue, x.commits, x.seats]), [[1, 0, []], [1600, 1, ['bob']], [1601, 1, []]]);
|
|
assert.equal(r.totals.commits, 2);
|
|
assert.equal(r.totals.followUpsPerIssue, 0);
|
|
// With the floor at 1 the bare #222 from the range counts again; tracker names still skip.
|
|
const all = JSON.parse(f.run(['--json']).stdout);
|
|
assert.deepEqual(all.issues.map(x => x.issue), [1, 2, 222, 1600, 1601]);
|
|
assert.equal(all.totals.commits, 5);
|
|
});
|
|
test('--first-issue refuses a missing, zero or non-integer value', t => {
|
|
const f = fixture(t);
|
|
for (const bad of [[], ['0'], ['1.5'], ['abc'], ['-3'], ['--json'], ['99999999999999999']]) {
|
|
const r = f.run(['--first-issue', ...bad], {}, { floor: [] });
|
|
assert.equal(r.status, 1, bad.join(' ')); assert.match(r.stderr, /^--first-issue requires a positive integer$/m);
|
|
}
|
|
assert.match(f.run(['--first-issue', '7'], {}, { floor: [] }).stdout, /^Ledger 2026-09-06 through 2026-09-12 UTC$/m);
|
|
});
|
|
test('T3 header: agent, or board from control-board; anything short of the full header is human', () => {
|
|
const sage = 'sage (1ef1e4f8-3ead-4208-beca-38f9f1add079)', filbert = 'filbert (9cb9731e-a10f-4c8f-a212-c4fa1f5f4731)';
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]\nbuild #1506`), 'agent');
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=actionable]\nbuild`), 'agent');
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}] same line`), 'agent');
|
|
assert.equal(messageKind(`[from: darkwing (thread-id: unknown) -> to: reviewer (new-thread)]\nreview`), 'agent');
|
|
assert.equal(messageKind(`[from: control-board (b) -> to: ${filbert}]\nhi`), 'board');
|
|
assert.equal(messageKind(`Jason here\n[from: ${sage} -> to: ${filbert}]\nquoted`), 'human');
|
|
assert.equal(messageKind(` [from: ${sage} -> to: ${filbert}]`), 'human');
|
|
assert.equal(messageKind(`[from: sage -> to: filbert]\nno thread ids`), 'human');
|
|
// Classes match in either case (Gate F). HEAD before the fix called these human.
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=Actionable]`), 'agent');
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=REVIEW-REQUEST]\nreview`), 'agent');
|
|
assert.equal(messageKind('[h:sage -> h:bob class=DECISION] go'), 'agent');
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert} class=review_request]`), 'human');
|
|
assert.equal(messageKind(`[from: ${sage} -> to: ${filbert}]trailing`), 'human');
|
|
assert.equal(messageKind(`[From: ${sage} -> to: ${filbert}]`), 'human');
|
|
});
|
|
test('no closed issues with human messages means undefined ratio, not invented zero', () => {
|
|
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
|
|
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
|
|
});
|
|
|
|
// T3 thread source (Gate F, docs/plans/2026-09-26_ledger-t3-source.md).
|
|
const T1 = 't-alice', T2 = 't-bob', T3 = 't-sagebrush', T4 = 't-researcher', T5 = 't-discord';
|
|
const header = (from, to, toId, cls = '') => `[from: ${from} (x1) -> to: ${to} (${toId})${cls}]`;
|
|
function t3Fixture(t) {
|
|
const f = fixture(t);
|
|
for (const seat of ['sage', 'researcher']) mkdirSync(path.join(f.root, 'agents', seat), { recursive: true });
|
|
const db = path.join(f.home, 'fixture/t3.sqlite');
|
|
const threads = [
|
|
{ id: T1, title: 'Alice' }, { id: T2, title: 'Bob in Claude', archived: '2026-09-09T00:00:00Z' },
|
|
{ id: T3, title: 'Sagebrush' }, { id: T4, title: 'Researcher' }, { id: T5, title: 'Discord Bot' },
|
|
{ id: 'import:claudeAgent:1', title: 'alice' }, { id: 't-deleted', title: 'Alice', deleted: '2026-09-09T00:00:00Z' },
|
|
{ id: 't-other', project: 'p2', title: 'Alice' },
|
|
];
|
|
const messages = [
|
|
{ thread: T1, text: 'Jason: go #1' },
|
|
{ thread: T1, text: `${header('sage', 'alice', T1, ' class=REVIEW-REQUEST')}\nreview #2`, origin: 'api' },
|
|
{ thread: T1, text: '[h:sage -> h:alice class=DECISION] go', origin: 'api' },
|
|
{ thread: T1, text: `${header('control-board', 'alice', T1)}\nbuzz`, origin: 'api' },
|
|
{ thread: T1, text: 'outside', at: '2026-09-13T00:00:00Z' },
|
|
{ thread: T1, text: 'an answer #9', role: 'assistant', origin: 'none' },
|
|
{ thread: T2, text: 'archived still counts #2' },
|
|
{ thread: T3, text: 'Sagebrush is not sage' },
|
|
{ thread: T3, text: `${header('sage', 'discord', T3)}\nnot a seat role`, origin: 'api' },
|
|
{ thread: T4, text: 'research this' },
|
|
{ thread: T5, text: '[from: SetSpark coordinator (x1) -> to: Discord Bot (x2)]\nfree text', origin: 'api' },
|
|
{ thread: 'import:claudeAgent:1', text: 'imported' },
|
|
{ thread: 't-deleted', text: 'deleted' },
|
|
{ thread: 't-other', text: 'other project' },
|
|
];
|
|
const write = (overrides = {}) => t3db(db, { root: f.real, projects: [['p1', f.real], ['p2', '/elsewhere']], threads, messages, ...overrides });
|
|
return { ...f, db, threads, messages, write };
|
|
}
|
|
test('T3: seat, archived, unmapped and Researcher threads count; imported, deleted and other-project threads do not', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
const result = f.run(['--json', '--t3-db', f.db]);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
const r = JSON.parse(result.stdout);
|
|
assert.deepEqual(r.seats, [
|
|
{ seat: 'alice', board: 2, agent: 3, human: 2 }, { seat: 'bob', board: 0, agent: 0, human: 1 },
|
|
{ seat: 'researcher', board: 0, agent: 0, human: 1 }, { seat: 't3:unmapped', board: 0, agent: 1, human: 2 },
|
|
]);
|
|
assert.deepEqual(r.pi, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
|
|
assert.deepEqual(r.t3.database, { path: f.db, default: false });
|
|
assert.deepEqual(r.t3.seats, [
|
|
{ seat: 'alice', board: 1, agent: 2, human: 1, threads: [{ id: T1, title: 'Alice', archived: false }] },
|
|
{ seat: 'bob', board: 0, agent: 0, human: 1, threads: [{ id: T2, title: 'Bob in Claude', archived: true }] },
|
|
{ seat: 'researcher', board: 0, agent: 0, human: 1, threads: [{ id: T4, title: 'Researcher', archived: false }] },
|
|
]);
|
|
assert.deepEqual(r.t3.unmapped, { board: 0, agent: 1, human: 2, threads: [
|
|
{ id: T5, title: 'Discord Bot', archived: false }, { id: T3, title: 'Sagebrush', archived: false }] });
|
|
assert.deepEqual(r.t3.excluded, { importedThreads: 1, deletedThreads: 1, reasoningMessages: 0 });
|
|
// The free-text header counts as human; only the diagnostic shows it was sent through the API.
|
|
assert.deepEqual(r.t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 0 });
|
|
assert.equal(r.totals.humanMessagesPerClosedIssue, 6);
|
|
assert.deepEqual(r.issues.map(x => [x.issue, x.seats]), [[1, ['alice']], [2, ['alice', 'bob']]]);
|
|
const text = f.run(['--t3-db', f.db]);
|
|
assert.equal(text.status, 0, text.stderr);
|
|
assert.ok(text.stdout.includes(`T3: read from ${f.db}, not the default`));
|
|
assert.match(text.stdout, /t3:unmapped \| 0 \| 1 \| 2/);
|
|
});
|
|
test('T3: the default path is read from HOME and prints no path line; --no-t3 says so', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
rmSync(f.defaultDb); cpSync(f.db, f.defaultDb);
|
|
const json = JSON.parse(f.run(['--json']).stdout);
|
|
assert.deepEqual(json.t3.database, { path: f.defaultDb, default: true });
|
|
assert.equal(json.seats.at(-1).seat, 't3:unmapped');
|
|
const text = f.run(); assert.equal(text.status, 0, text.stderr); assert.doesNotMatch(text.stdout, /^T3:/m);
|
|
rmSync(path.join(f.home, '.t3'), { recursive: true });
|
|
const off = f.run(['--no-t3']); assert.equal(off.status, 0, off.stderr);
|
|
assert.match(off.stdout, /^T3: not read \(--no-t3\)$/m);
|
|
const offJson = JSON.parse(f.run(['--no-t3', '--json']).stdout);
|
|
assert.deepEqual(offJson.t3, { read: false }); assert.deepEqual(offJson.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
|
|
const both = f.run(['--no-t3', '--t3-db', f.db]); assert.equal(both.status, 1); assert.match(both.stderr, /cannot be combined/);
|
|
assert.equal(f.run(['--t3-db']).status, 1);
|
|
});
|
|
test('T3: a HOME with no database exits 1 and names --no-t3', t => {
|
|
const f = fixture(t); rmSync(path.join(f.home, '.t3'), { recursive: true });
|
|
const r = f.run(); assert.equal(r.status, 1); assert.equal(r.stdout, '');
|
|
assert.match(r.stderr, /T3 database unavailable: .*\.t3 is missing or unreadable; use --no-t3/);
|
|
});
|
|
test('T3: a file that is not a database exits 1 and names --no-t3', t => {
|
|
const f = fixture(t); writeFileSync(f.defaultDb, 'not sqlite'.repeat(100));
|
|
const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /T3 database cannot be read: .*\(SQLite \d+\); use --no-t3/);
|
|
});
|
|
test('T3: a seat thread renamed to another seat exits 1 naming thread, title and roles', t => {
|
|
const f = t3Fixture(t);
|
|
f.write({ messages: [...f.messages, { thread: T2, text: `${header('sage', 'alice', T2, ' class=INFO')}\nfor alice`, origin: 'api' }] });
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1);
|
|
assert.equal(r.stderr.trim(), `T3 header conflict: thread ${T2} "Bob in Claude" maps to bob, but a header addresses alice`);
|
|
});
|
|
test('T3: an unmapped thread addressed as a seat exits 1', t => {
|
|
const f = t3Fixture(t);
|
|
f.write({ messages: [...f.messages, { thread: T3, text: `${header('bob', 'Sage', T3)}\nhi`, origin: 'api' }] });
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1);
|
|
assert.match(r.stderr, /thread t-sagebrush "Sagebrush" maps to no seat, but a header addresses Sage/);
|
|
});
|
|
test('T3: a header to another thread id is not cross-checked', t => {
|
|
const f = t3Fixture(t);
|
|
f.write({ messages: [...f.messages, { thread: T2, text: `${header('sage', 'alice', T1)}\ncopied`, origin: 'api' }] });
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(JSON.parse(r.stdout).t3.seats[1].agent, 1);
|
|
});
|
|
test('T3: no project, or two, for this root exits 1', t => {
|
|
const f = t3Fixture(t);
|
|
f.write({ projects: [['p1', `${f.real}-link`], ['p2', '/elsewhere']] });
|
|
let r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /T3 has no project for .*symlink does not match/);
|
|
f.write({ projects: [['p1', f.real], ['p2', f.real]] });
|
|
r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /T3 has more than one project for/);
|
|
f.write({ projects: [['p1', f.real], ['p2', f.real, '2026-09-01T00:00:00Z']] });
|
|
assert.equal(f.run(['--t3-db', f.db]).status, 0);
|
|
});
|
|
for (const [name, after, pattern] of [
|
|
['a removed column', ['alter table projection_threads drop column title'], /T3 schema changed: missing projection_threads.title/],
|
|
['a missing table', ['drop table projection_thread_messages'], /T3 schema changed: missing projection_thread_messages$/m],
|
|
]) test(`T3: ${name} exits 1 and names it`, t => {
|
|
const f = t3Fixture(t); f.write({ after, messages: [] });
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern);
|
|
});
|
|
for (const [name, message, pattern] of [
|
|
['an unknown role', { role: 'system' }, /T3 message m\d+ in thread t-alice has an unknown role/],
|
|
['role reasoning without the reasoning: id prefix', { role: 'reasoning', id: 'x-1' }, /T3 message x-1 in thread t-alice has an unknown role/],
|
|
['non-text content', { text: null }, /has non-text content/],
|
|
['an unparseable created_at', { at: 'yesterday' }, /has an invalid created_at/],
|
|
]) test(`T3: a counted row with ${name} exits 1 without its text`, t => {
|
|
const f = t3Fixture(t);
|
|
f.write({ messages: [...f.messages, { thread: T1, text: 'secret words', ...message }] });
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, pattern); assert.doesNotMatch(r.stderr, /secret/);
|
|
});
|
|
test('T3: a reasoning summary is skipped before any check and counted as excluded', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
|
const same = after => {
|
|
for (const key of ['seats', 'pi', 'issues', 'totals']) assert.deepEqual(after[key], before[key]);
|
|
assert.deepEqual([after.t3.seats, after.t3.unmapped, after.t3.diagnostic], [before.t3.seats, before.t3.unmapped, before.t3.diagnostic]);
|
|
};
|
|
// Null text and a bad created_at would each refuse a counted row.
|
|
const summary = { thread: T1, role: 'reasoning', id: 'reasoning:summary:1', text: null, at: 'yesterday', origin: 'none' };
|
|
f.write({ messages: [...f.messages, summary] });
|
|
let r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout).t3.excluded, { importedThreads: 1, deletedThreads: 1, reasoningMessages: 1 });
|
|
same(JSON.parse(r.stdout));
|
|
// So would a header conflict, and a #N mention would add an issue seat.
|
|
f.write({ messages: [...f.messages, summary, { thread: T2, role: 'reasoning', id: 'reasoning:summary:2', text: `${header('sage', 'alice', T2)}\n#7`, origin: 'none' }] });
|
|
r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(JSON.parse(r.stdout).t3.excluded.reasoningMessages, 2);
|
|
same(JSON.parse(r.stdout));
|
|
});
|
|
test('T3: a missing orchestration_events makes the diagnostic unknown and keeps the counts', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
|
f.write({ after: ['drop table orchestration_events'] });
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
const after = JSON.parse(r.stdout);
|
|
assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
|
assert.deepEqual(after.seats, before.seats); assert.deepEqual(after.totals, before.totals);
|
|
});
|
|
test('T3: a human message with no event counts in humanWithoutEvent', t => {
|
|
const f = t3Fixture(t);
|
|
f.write({ messages: [...f.messages, { thread: T1, text: 'typed, no event', origin: 'none' }] });
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout).t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 1 });
|
|
});
|
|
const badEvent = payload => `insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values ('${T1}', 'thread.message-sent', '${payload}', '{}')`;
|
|
for (const [name, payload] of [['an unparseable event', '{bad'], ['an event with no string messageId', '{"messageId":7}']]) {
|
|
test(`T3: ${name} makes the diagnostic unknown and keeps the counts`, t => {
|
|
const f = t3Fixture(t); f.write();
|
|
const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
|
f.write({ after: [badEvent(payload)] });
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
const after = JSON.parse(r.stdout);
|
|
assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
|
assert.deepEqual(after.seats, before.seats);
|
|
});
|
|
}
|
|
test('T3: an error that is not from SQLite is rethrown, not reported as a database failure', async t => {
|
|
const f = t3Fixture(t); f.write();
|
|
// In process with an explicit path, so the real ~/.t3 stays closed. A null
|
|
// range makes inRange throw a TypeError inside the read transaction.
|
|
await assert.rejects(readT3(f.real, null, { dbPath: f.db, isDefault: false }), TypeError);
|
|
});
|
|
for (const link of ['.t3', '.t3/userdata', '.t3/userdata/state.sqlite']) test(`T3: a symlink at ~/${link} exits 1`, t => {
|
|
const f = fixture(t), target = path.join(f.home, 'real', link);
|
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
cpSync(path.join(f.home, link), target, { recursive: true });
|
|
rmSync(path.join(f.home, link), { recursive: true }); symlinkSync(target, path.join(f.home, link));
|
|
const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, new RegExp(`${link.replaceAll('.', '\\.')} is a symlink; use --no-t3`));
|
|
});
|
|
test('T3: with --t3-db, a symlinked file or directory exits 1', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
const file = path.join(f.home, 'file-link.sqlite'); symlinkSync(f.db, file);
|
|
let r = f.run(['--t3-db', file]); assert.equal(r.status, 1); assert.match(r.stderr, /file-link.sqlite is a symlink/);
|
|
const dir = path.join(f.home, 'dir-link'); symlinkSync(path.dirname(f.db), dir);
|
|
r = f.run(['--t3-db', path.join(dir, 't3.sqlite')]); assert.equal(r.status, 1); assert.match(r.stderr, /dir-link is a symlink/);
|
|
});
|
|
|
|
// WAL states. The CLI reads with mode=ro; it may create -wal and -shm but must
|
|
// never change the main file.
|
|
const sha = file => execFileSync('sha256sum', [file], { encoding: 'utf8' }).split(' ')[0];
|
|
const humans = r => JSON.parse(r.stdout).t3.seats.find(s => s.seat === 'alice').human;
|
|
const asRoot = process.getuid?.() === 0;
|
|
function killedWriter(db) {
|
|
// A writer that commits into the WAL and dies without a checkpoint.
|
|
const code = `const { DatabaseSync } = require('node:sqlite'); const db = new DatabaseSync(${JSON.stringify(db)});
|
|
db.exec('pragma wal_autocheckpoint=0');
|
|
db.prepare("insert into projection_thread_messages values ('late', 't-alice', 'user', 'late human', '2026-09-08T13:00:00Z')").run();
|
|
process.kill(process.pid, 'SIGKILL');`;
|
|
const r = spawnSync(process.execPath, ['-e', code]);
|
|
assert.equal(r.signal, 'SIGKILL');
|
|
rmSync(`${db}-shm`);
|
|
}
|
|
function inReadOnlyDir(dir, check) {
|
|
execFileSync('chmod', ['0555', dir]);
|
|
try { check(); } finally { execFileSync('chmod', ['0755', dir]); }
|
|
}
|
|
test('T3 WAL: the newest message only in -wal, writer attached, is counted', t => {
|
|
const f = t3Fixture(t), writer = f.write({ keepOpen: true });
|
|
t.after(() => writer.close());
|
|
writer.exec('pragma wal_autocheckpoint=0');
|
|
addMessage(writer, { thread: T1, text: 'newest', at: '2026-09-08T13:00:00Z' });
|
|
const main = sha(f.db);
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(humans(r), 2); assert.equal(sha(f.db), main);
|
|
});
|
|
test('T3 WAL: stopped cleanly, counts are correct and the main file is unchanged', t => {
|
|
const f = t3Fixture(t); f.write();
|
|
assert.throws(() => readFileSync(`${f.db}-wal`));
|
|
const main = sha(f.db);
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(humans(r), 1); assert.equal(sha(f.db), main);
|
|
});
|
|
test('T3 WAL: -wal without -shm in a writable directory is read', t => {
|
|
const f = t3Fixture(t); f.write(); killedWriter(f.db);
|
|
const main = sha(f.db);
|
|
const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
assert.equal(humans(r), 2); assert.equal(sha(f.db), main);
|
|
});
|
|
test('T3 WAL: -wal without -shm in a read-only directory exits 1', { skip: asRoot && 'mode bits do not bind root' }, t => {
|
|
const f = t3Fixture(t); f.write(); killedWriter(f.db);
|
|
inReadOnlyDir(path.dirname(f.db), () => {
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 14\); use --no-t3/);
|
|
});
|
|
});
|
|
test('T3 WAL: stopped cleanly in a read-only directory exits 1', { skip: asRoot && 'mode bits do not bind root' }, t => {
|
|
const f = t3Fixture(t); f.write();
|
|
inReadOnlyDir(path.dirname(f.db), () => {
|
|
const r = f.run(['--t3-db', f.db]); assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 1544\); use --no-t3/);
|
|
});
|
|
});
|
|
test('T3: a lock held past the 5 s busy timeout exits 1 and names --no-t3', t => {
|
|
const f = t3Fixture(t), writer = f.write({ keepOpen: true });
|
|
t.after(() => writer.close());
|
|
writer.exec('pragma locking_mode=exclusive'); writer.exec('begin exclusive');
|
|
addMessage(writer, { thread: T1, text: 'held' });
|
|
const started = Date.now(), r = f.run(['--t3-db', f.db]);
|
|
writer.exec('commit');
|
|
assert.equal(r.status, 1); assert.match(r.stderr, /cannot be read: .*\(SQLite 5\); use --no-t3/);
|
|
assert.ok(Date.now() - started >= 4500, 'the reader waited for the busy timeout');
|
|
});
|