AtlatestRepositoryfolio

folio / tree / testtest-backlink-processes.mjs

1import assert from 'node:assert/strict';
2import { spawn } from 'node:child_process';
3import { mkdtemp, mkdir, writeFile, readFile, rm, unlink, stat, utimes, symlink } from 'node:fs/promises';
4import { tmpdir } from 'node:os';
5import { join, resolve } from 'node:path';
6import { once } from 'node:events';
7import test from 'node:test';
8
9const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');
10const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
12function 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 };
73async 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);
82test('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 for
107 // 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});
140async 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 };
149test('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 });