AtlatestRepositoryfolio
1
// Runner-only integration and load coverage. Every store is synthetic.2
import assert from 'node:assert/strict';3
import { spawn } from 'node:child_process';4
import { mkdtemp, mkdir, writeFile, readFile, rm, stat, chmod, utimes, rename, unlink } from 'node:fs/promises';5
import { tmpdir, networkInterfaces } from 'node:os';6
import { resolve, join } from 'node:path';7
import { createServer } from 'node:net';8
import { once } from 'node:events';9
import test from 'node:test';11
const binary = resolve(process.env.FOLIO_TEST_BINARY || 'build/dev/bin/folio');12
const token = 'folio-test-only-0123456789abcdef0123456789abcdef';13
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));14
const meta = {15
'io.modelcontextprotocol/protocolVersion': '2026-07-28',16
'io.modelcontextprotocol/clientCapabilities': {},17
};19
function boundedFetch(url, options = {}) {20
return fetch(url, { signal: AbortSignal.timeout(15000), ...options });21
}23
async function unusedPort() {24
const socket = createServer();25
socket.listen(0, '127.0.0.1');26
await once(socket, 'listening');27
const port = socket.address().port;28
await new Promise(resolve => socket.close(resolve));29
return port;30
}32
async function eventually(check, label, timeout = 25000) {33
const deadline = performance.now() + timeout;34
let last;35
while (performance.now() < deadline) {36
try { if (await check()) return; } catch (error) { last = error; }37
await pause(150);38
}39
throw new Error(`Timed out: ${label}`, { cause: last });40
}42
async function stop(child) {43
if (child.exitCode !== null || child.signalCode !== null) return;44
const exited = once(child, 'exit');45
child.kill('SIGTERM');46
const timer = setTimeout(() => child.kill('SIGKILL'), 3000);47
await exited;48
clearTimeout(timer);49
}51
async function cpuSeconds(pid) {52
const text = await readFile(`/proc/${pid}/stat`, 'utf8');53
const fields = text.slice(text.lastIndexOf(')') + 2).split(' ');54
return (Number(fields[11]) + Number(fields[12])) / 100;55
}57
test('stdio initialize still works and HTTP startup refuses invalid token configuration',58
{ timeout: 20000 }, async t => {59
const root = await mkdtemp(join(tmpdir(), 'folio-stdio-test-'));60
t.after(() => rm(root, { recursive: true, force: true }));61
const child = spawn(binary, ['serve', '--stdio'], { env: { ...process.env, FOLIO_ROOT: root } });62
t.after(() => stop(child));63
let received = '';64
let diagnostic = '';65
child.stdout.on('data', chunk => { received += chunk; });66
child.stderr.on('data', chunk => { diagnostic += chunk; });67
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize',68
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'stdio-test', version: '1' } } }) + '\n');69
await eventually(() => received.split('\n').some(line => {70
try { return JSON.parse(line).id === 42 && JSON.parse(line).result.serverInfo; }71
catch { return false; }72
}), `stdio initialize: ${diagnostic}`, 10000);73
await stop(child);74
const insecureToken = join(root, 'insecure-token');75
await writeFile(insecureToken, token + '\n', { mode: 0o644 });76
await chmod(insecureToken, 0o644);77
for (const args of [78
['serve', '--http', '18765', '--token-file', insecureToken],79
['serve', '--http', '18765', '--http', '18766', '--token-file', insecureToken],80
['serve', '--http', '18765'],81
['serve', '--http', '18765', '--stdio'],82
['serve', '--token-file', '/missing/folio-token'],83
]) {84
const invalid = spawn(binary, args, { env: { ...process.env, FOLIO_ROOT: root } });85
invalid.stdout.resume();86
invalid.stderr.resume();87
const timer = setTimeout(() => invalid.kill('SIGKILL'), 4000);88
const [code, signal] = await once(invalid, 'exit');89
clearTimeout(timer);90
assert.equal(signal, null, `invalid startup hung: ${args.join(' ')}`);91
assert.notEqual(code, 0, `invalid startup accepted: ${args.join(' ')}`);92
}93
});95
test('deprecated viewer flags warn once, never bind, and preserve stdio EOF', { timeout: 20000 }, async t => {96
const root = await mkdtemp(join(tmpdir(), 'folio-stdio-web-'));97
t.after(() => rm(root, { recursive: true, force: true }));98
await writeFile(join(root, 'inbox.md'), '# Inbox\n');99
const occupied = createServer();100
occupied.listen(0, '0.0.0.0');101
await once(occupied, 'listening');102
t.after(() => new Promise(resolve => occupied.close(resolve)));103
const port = occupied.address().port;104
for (const flags of [['--web-port', String(port)], ['--web']]) {105
const child = spawn(binary, ['serve', '--stdio', ...flags],106
{ env: { ...process.env, FOLIO_ROOT: root } });107
t.after(() => stop(child));108
let output = '';109
let diagnostic = '';110
child.stdout.on('data', chunk => { output += chunk; });111
child.stderr.on('data', chunk => { diagnostic += chunk; });112
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 44, method: 'initialize',113
params: { protocolVersion: '2025-11-25', capabilities: {} } }) + '\n');114
await eventually(() => output.includes('serverInfo'), 'deprecated flag stdio initialize', 10000);115
assert.equal(diagnostic.split('\n').filter(line => line.includes('deprecated and ignored')).length, 1);116
assert.ok(!diagnostic.includes('Folio web UI'));117
const sockets = await readFile('/proc/net/tcp', 'utf8');118
const suffix = ':' + port.toString(16).toUpperCase().padStart(4, '0');119
const listeners = sockets.split('\n').filter(line => {120
const fields = line.trim().split(/\s+/);121
return fields[1]?.endsWith(suffix) && fields[3] === '0A';122
});123
assert.equal(listeners.length, 1, 'deprecated flag created a second listener');124
assert.equal(listeners[0].trim().split(/\s+/)[1], '00000000' + suffix);125
const exited = once(child, 'exit');126
child.stdin.end();127
const timer = setTimeout(() => child.kill('SIGKILL'), 4000);128
const [code, signal] = await exited;129
clearTimeout(timer);130
assert.equal(signal, null, 'deprecated viewer flag prevented stdin EOF exit');131
assert.equal(code, 0);132
}133
});135
test('shared HTTP authentication, serialization, external edits and 20 clients',136
{ timeout: 180000 }, async t => {137
const root = await mkdtemp(join(tmpdir(), 'folio-http-test-'));138
let child;139
let logs = '';140
t.after(async () => {141
const exitBeforeCleanup = child?.exitCode;142
const signalBeforeCleanup = child?.signalCode;143
if (child) await stop(child);144
console.log(JSON.stringify({ exitBeforeCleanup, signalBeforeCleanup, serverExit: child?.exitCode, serverSignal: child?.signalCode, serverLog: logs }));145
await rm(root, { recursive: true, force: true });146
});147
for (const dir of ['notes', 'projects', 'archive/notes', 'archive/projects']) {148
await mkdir(join(root, dir), { recursive: true });149
}150
await writeFile(join(root, 'inbox.md'), '# Inbox\n');151
await writeFile(join(root, 'notes/storm.md'), '# Storm\n');152
await writeFile(join(root, 'notes/external.md'), '# External\noldneedle\n');153
await writeFile(join(root, 'notes/large.md'), '# Unicode\n' + 'λ → résumé\n'.repeat(30000));154
for (let i = 0; i < 1000; i++) {155
await writeFile(join(root, `notes/fixture-${i}.md`), `# Fixture ${i}\nsearchable content\n`);156
}157
const tokenFile = join(root, 'bearer');158
await writeFile(tokenFile, token + '\n', { mode: 0o600 });159
const port = await unusedPort();160
const url = `http://127.0.0.1:${port}/mcp`;161
child = spawn(binary, ['serve', '--http', String(port), '--token-file', tokenFile,162
'--web-port', String(port)], { env: { ...process.env, FOLIO_ROOT: root } });163
child.stdout.on('data', chunk => { logs = (logs + chunk).slice(-16000); });164
child.stderr.on('data', chunk => { logs = (logs + chunk).slice(-16000); });165
let id = 0;166
async function rpc(method, params = {}, headers = {}) {167
const response = await boundedFetch(url, {168
method: 'POST', signal: AbortSignal.timeout(15000),169
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',170
'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': method,171
...(params.name ? { 'Mcp-Name': params.name } : {}), ...headers },172
body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method,173
params: { ...params, _meta: meta } }),174
});175
assert.equal(response.status, 200, await response.clone().text());176
const body = await response.json();177
assert.ok(!body.error, JSON.stringify(body));178
assert.ok(!body.result?.isError, JSON.stringify(body));179
return body.result;180
}181
const call = (name, args) => rpc('tools/call', { name, arguments: args });182
const text = result => result.content.map(item => item.text || '').join('\n');183
await eventually(async () => {184
assert.equal(child.exitCode, null, logs);185
const response = await boundedFetch(url, { signal: AbortSignal.timeout(1000) });186
await response.text();187
return response.status === 401;188
}, 'server ready', 45000);190
await t.test('bearer required before protocol parsing; legacy initialize works', async () => {191
for (const authorization of ['', 'Bearer wrong', 'Basic wrong', 'Bearer wrongé']) {192
console.log(JSON.stringify({ authenticationProbe: authorization ? (authorization.includes('é') ? 'non-ASCII' : authorization.split(' ')[0]) : 'empty' }));193
const response = await boundedFetch(url, { method: 'POST', headers: { Authorization: authorization }, body: '{' });194
assert.equal(response.status, 401);195
assert.equal(response.headers.get('www-authenticate'), 'Bearer');196
await response.text();197
}198
const response = await boundedFetch(url, { method: 'POST',199
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },200
body: JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize',201
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }) });202
assert.equal(response.status, 200);203
assert.ok((await response.json()).result.serverInfo);204
const legacy = await boundedFetch(url, { method: 'POST',205
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',206
'MCP-Protocol-Version': '2025-11-25' },207
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }) });208
assert.equal(legacy.status, 200, await legacy.clone().text());209
const legacyResult = (await legacy.json()).result;210
assert.ok(legacyResult.tools.some(tool => tool.name === 'folio/note-read'));211
assert.equal(legacyResult.resultType, undefined);212
assert.equal(legacy.headers.get('mcp-session-id'), null);213
const invalidModern = await boundedFetch(url, { method: 'POST',214
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',215
'MCP-Protocol-Version': '2026-07-28' },216
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) });217
assert.equal(invalidModern.status, 400);218
const forbidden = await boundedFetch(url, { method: 'POST',219
headers: { Authorization: `Bearer ${token}`, Origin: 'https://example.com' }, body: '{}' });220
assert.equal(forbidden.status, 403);221
});223
await t.test('HTTP rebuild is refused immediately and invalid crosslinks stay usable', async () => {224
const started = performance.now();225
const response = await boundedFetch(url, { method: 'POST',226
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',227
'MCP-Protocol-Version': '2026-07-28', 'Mcp-Method': 'tools/call',228
'Mcp-Name': 'folio/rebuild-index' },229
body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method: 'tools/call',230
params: { name: 'folio/rebuild-index', arguments: {}, _meta: meta } }) });231
assert.equal(response.status, 200);232
const result = await response.json();233
assert.match(JSON.stringify(result), /rebuild-index is unavailable over HTTP/);234
assert.ok(result.error || result.result?.isError);235
assert.ok(performance.now() - started < 2000, 'HTTP rebuild blocked the listener');236
await call('folio/note-edit', { name: 'storm', append: '\n[[../x]] and [[/tmp/x]]\n' });237
const links = text(await call('folio/note-links', { name: 'storm' }));238
assert.match(links, /\.\.\/x/);239
await call('folio/note-read', { name: 'storm' });240
});242
await t.test('actual listener is IPv4 loopback only with ignored viewer port', async () => {243
const sockets = await readFile('/proc/net/tcp', 'utf8');244
const suffix = ':' + port.toString(16).toUpperCase().padStart(4, '0');245
const listeners = sockets.split('\n').filter(line => {246
const fields = line.trim().split(/\s+/);247
return fields[1]?.endsWith(suffix) && fields[3] === '0A';248
});249
assert.equal(listeners.length, 1);250
assert.equal(listeners[0].trim().split(/\s+/)[1], '0100007F' + suffix);251
for (const addresses of Object.values(networkInterfaces())) {252
for (const address of addresses || []) {253
if (address.family === 'IPv4' && !address.internal) {254
await assert.rejects(boundedFetch(`http://${address.address}:${port}/mcp`, { signal: AbortSignal.timeout(1000) }));255
}256
}257
}258
assert.equal(logs.split('\n').filter(line => line.includes('deprecated and ignored')).length, 1);259
});261
await t.test('20 concurrent appenders preserve every unique append exactly once', async () => {262
await Promise.all(Array.from({ length: 20 }, async (_, client) => {263
for (let sequence = 0; sequence < 4; sequence++) {264
await call('folio/note-edit', { name: 'storm', append: `\nappend-${client}-${sequence}-end\n` });265
}266
}));267
const disk = await readFile(join(root, 'notes/storm.md'), 'utf8');268
for (let client = 0; client < 20; client++) {269
for (let sequence = 0; sequence < 4; sequence++) {270
assert.equal(disk.split(`append-${client}-${sequence}-end`).length - 1, 1);271
}272
}273
assert.match(text(await call('folio/notes', { query: 'append-19-3-end' })), /storm/);274
});276
await t.test('same-size external write with restored mtime, additions and removals converge', async () => {277
const external = join(root, 'notes/external.md');278
const before = await stat(external);279
await writeFile(external, '# External\nnewneedle\n');280
await utimes(external, before.atime, before.mtime);281
await eventually(async () => /external/.test(text(await call('folio/notes', { query: 'newneedle' }))), 'external edit');282
assert.doesNotMatch(text(await call('folio/notes', { query: 'oldneedle' })), /external/);283
await mkdir(join(root, 'notes/nested'));284
await rename(external, join(root, 'notes/nested/moved.md'));285
await writeFile(join(root, 'notes/added.md'), '# Added\naddedneedle\n');286
await unlink(join(root, 'notes/fixture-0.md'));287
await eventually(async () => {288
const names = text(await call('folio/notes', {}));289
return names.includes('nested/moved') && names.includes('added') && !names.includes(' external') && !names.includes(' fixture-0\n');290
}, 'external rename, add and delete');291
});293
await t.test('large Unicode response and 20-client load have bounded CPU and latency', async () => {294
const largeStart = performance.now();295
assert.match(text(await call('folio/note-read', { name: 'large' })), /λ → résumé/);296
const largeMs = performance.now() - largeStart;297
assert.ok(largeMs < 12000, `large Unicode read took ${largeMs} ms`);298
const cpuStart = await cpuSeconds(child.pid);299
const started = performance.now();300
const latencies = [];301
await Promise.all(Array.from({ length: 20 }, async (_, client) => {302
for (let iteration = 0; iteration < 8; iteration++) {303
const start = performance.now();304
if (iteration % 4 === 0) {305
await call('folio/note-edit', { name: 'storm', append: `\nload-${client}-${iteration}\n` });306
} else {307
await call('folio/note-read', { name: 'fixture-1' });308
}309
latencies.push(performance.now() - start);310
}311
}));312
const elapsedMs = performance.now() - started;313
const cpu = (await cpuSeconds(child.pid)) - cpuStart;314
latencies.sort((a, b) => a - b);315
const p95 = latencies[Math.floor(latencies.length * 0.95)];316
console.log(JSON.stringify({ clients: 20, requests: latencies.length, elapsedMs, cpuSeconds: cpu, p95Ms: p95, maxMs: latencies.at(-1), largeUnicodeMs: largeMs }));317
assert.ok(elapsedMs < 30000, `load elapsed ${elapsedMs} ms`);318
assert.ok(cpu < 15, `load consumed ${cpu} CPU seconds`);319
assert.ok(p95 < 5000, `p95 ${p95} ms`);320
const idleCpu = await cpuSeconds(child.pid);321
await pause(16000); // includes one metadata census322
const idleSeconds = (await cpuSeconds(child.pid)) - idleCpu;323
console.log(JSON.stringify({ idleWallSeconds: 16, idleCpuSeconds: idleSeconds }));324
assert.ok(idleSeconds < 1, `idle census consumed ${idleSeconds} CPU seconds`);325
});326
});