AtlatestRepositoryfolio
1
import assert from 'node:assert/strict';2
import { spawn } from 'node:child_process';3
import { mkdtemp, mkdir, writeFile, readFile, rm, unlink, stat, utimes, symlink } from 'node:fs/promises';4
import { tmpdir } from 'node:os';5
import { join, resolve } from 'node:path';6
import { once } from 'node:events';7
import test from 'node:test';9
const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');10
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));12
function client(root) {13
const child = spawn(binary, ['serve', '--stdio'], {14
env: { ...process.env, FOLIO_ROOT: root }, stdio: ['pipe', 'pipe', 'pipe'],15
});16
let sequence = 0, pending = new Map(), buffer = '', diagnostic = '';17
child.stderr.on('data', data => { diagnostic = (diagnostic + data).slice(-4000); });18
child.stdout.on('data', data => {19
buffer += data;20
while (buffer.includes('\n')) {21
const end = buffer.indexOf('\n'), line = buffer.slice(0, end);22
buffer = buffer.slice(end + 1);23
let response;24
try { response = JSON.parse(line); } catch { continue; }25
const request = pending.get(response.id);26
if (!request) continue;27
pending.delete(response.id);28
clearTimeout(request.timer);29
if (response.error || response.result?.isError) request.reject(new Error(JSON.stringify(response)));30
else request.resolve(response.result);31
}32
});33
child.on('exit', (code, signal) => {34
for (const request of pending.values()) {35
clearTimeout(request.timer);36
request.reject(new Error(`Server exited ${code}/${signal}: ${diagnostic}`));37
}38
pending.clear();39
});40
return {41
async call(name, args = {}) {42
const id = ++sequence;43
return new Promise((resolve, reject) => {44
const timer = setTimeout(() => {45
pending.delete(id);46
reject(new Error(`Timeout calling ${name}: ${diagnostic}`));47
}, 30000);48
pending.set(id, { resolve, reject, timer });49
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call',50
params: { name: `folio/${name}`, arguments: args } }) + '\n');51
}).then(result => (result.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n'));52
},53
async stop() {54
if (child.exitCode !== null || child.signalCode !== null) return;55
const exited = once(child, 'exit');56
child.kill('SIGTERM');57
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);58
await exited;59
clearTimeout(timer);60
},61
async close() {62
const exited = once(child, 'exit');63
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);64
child.stdin.end();65
const [code, signal] = await exited;66
clearTimeout(timer);67
assert.equal(signal, null, 'stdio EOF must stop its background census');68
assert.equal(code, 0);69
},70
};71
}73
async function eventually(check, message) {74
const deadline = performance.now() + 25000;75
do {76
if (await check()) return;77
await pause(250);78
} while (performance.now() < deadline);79
assert.fail(message);80
}82
test('two stdio writers share backlinks and discover external changes', { timeout: 90000 }, async t => {83
const root = await mkdtemp(join(tmpdir(), 'folio-backlink-processes-'));84
t.after(() => rm(root, { recursive: true, force: true }));85
await mkdir(join(root, 'notes'));86
await mkdir(join(root, 'projects'));87
await writeFile(join(root, 'notes', 'one.md'), '# One\n');88
await writeFile(join(root, 'notes', 'two.md'), '# Two\n');89
const source = join(root, 'notes', 'source.md');90
await writeFile(source, '# Source\n');91
await writeFile(join(root, 'projects', 'sample.md'), '# Sample\n[[one]]\n');92
const clients = [client(root), client(root)];93
t.after(() => Promise.all(clients.map(client => client.stop())));94
await Promise.all(clients.map(client => client.call('note-links', { name: 'one' })));95
await Promise.all([96
clients[0].call('note-edit', { name: 'source', append: 'first [[one]]' }),97
clients[1].call('note-edit', { name: 'source', append: 'second [[two]]' }),98
]);99
const body = await readFile(source, 'utf8');100
assert.equal(body.split('first [[one]]').length - 1, 1);101
assert.equal(body.split('second [[two]]').length - 1, 1);102
for (const client of clients) {103
assert.match(await client.call('note-links', { name: 'one' }), /← source \(note\)/);104
assert.match(await client.call('note-links', { name: 'two' }), /← source \(note\)/);105
}106
// A rebuild in either session must preserve both complete documents for107
// the other session's readers throughout every per-document replacement.108
const observer = client(root);109
t.after(() => observer.stop());110
await observer.call('note-links', { name: 'one' });111
const rebuilds = clients.map(client => client.call('rebuild-index'));112
for (let i = 0; i < 8; i++) {113
assert.match(await observer.call('note-links', { name: 'one' }), /← source \(note\)/);114
}115
await Promise.all(rebuilds);116
await observer.close();117
await clients[0].call('note-rename', { name: 'one', 'new-name': 'renamed' });118
for (const client of clients) {119
const links = await client.call('note-links', { name: 'renamed' });120
assert.match(links, /← source \(note\)/);121
assert.match(links, /← sample \(project\)/);122
}123
await clients[1].call('project-archive', { name: 'sample' });124
assert.doesNotMatch(await clients[0].call('note-links', { name: 'renamed' }), /← sample \(project\)/);125
const before = await stat(source);126
await writeFile(source, '# Source\nexternal [[two]]\n');127
await utimes(source, before.atime, before.mtime);128
await eventually(async () => {129
const responses = await Promise.all(clients.map(client => client.call('note-links', { name: 'renamed' })));130
return responses.every(response => !response.includes('← source (note)'));131
}, 'both processes must detect an external edit with restored mtime');132
await unlink(source);133
await eventually(async () => {134
const responses = await Promise.all(clients.map(client => client.call('note-links', { name: 'two' })));135
return responses.every(response => !response.includes('← source (note)'));136
}, 'both processes must remove externally deleted backlinks');137
await clients[0].close();138
});140
async function migrate(root) {141
const child = spawn(binary, ['--migrate-index'], { env: { ...process.env, FOLIO_ROOT: root } });142
let output = '';143
child.stdout.on('data', data => { output += data; });144
child.stderr.resume();145
const [code] = await once(child, 'exit');146
return { code, output };147
}149
test('startup survives a busy removal and dangling project; migration reports incomplete work',150
{ timeout: 90000 }, async t => {151
const root = await mkdtemp(join(tmpdir(), 'folio-startup-maintenance-'));152
const resources = [];153
t.after(async () => {154
for (const resource of resources) await resource();155
await rm(root, { recursive: true, force: true });156
});157
await mkdir(join(root, 'notes'));158
await mkdir(join(root, 'projects'));159
await writeFile(join(root, 'notes', 'target.md'), '# Target\n');160
await writeFile(join(root, 'notes', 'source.md'), '# Source\n[[target]]\n');161
await writeFile(join(root, 'notes', 'deleted.md'), '# Deleted\n[[target]]\n');162
assert.equal((await migrate(root)).code, 0);163
await unlink(join(root, 'notes', 'deleted.md'));164
await symlink(join(root, 'missing-project'), join(root, 'projects', 'a-bad.md'));165
const holder = spawn(process.env.SIGIL_TEST_CLI || 'sigil',166
['-L', resolve(process.env.FOLIO_TEST_LIB || 'build/release/lib'),167
'test/fixtures/index-writer-lock.sgl'],168
{ env: { ...process.env, FOLIO_TEST_ROOT: root } });169
holder.stderr.resume();170
resources.push(async () => {171
if (holder.exitCode === null) { holder.stdin.end(); await once(holder, 'exit'); }172
});173
let ready = '';174
holder.stdout.on('data', data => { ready += data; });175
await eventually(async () => ready.includes('LOCKED'), 'database lock helper must start');176
const session = client(root);177
resources.unshift(() => session.stop());178
assert.match(await session.call('note-links', { name: 'target' }), /← source \(note\)/);179
holder.stdin.end();180
await once(holder, 'exit');181
await writeFile(join(root, 'projects', 'z-good.md'), '# Good\n[[target]]\n');182
await eventually(async () => (await session.call('note-links', { name: 'target' }))183
.includes('← z-good (project)'), 'census must continue beyond the dangling project');184
await session.close();185
assert.notEqual((await migrate(root)).code, 0, 'offline migration must report unindexable items');186
await unlink(join(root, 'projects', 'a-bad.md'));187
const repaired = await migrate(root);188
assert.equal(repaired.code, 0);189
assert.match(repaired.output, /Index migration complete/);190
});