/** * The whole backup chain, against real data. * * Talks to a running `scaffoldd` over its unix socket, asks what the backup * set is, reads those files off disk, pushes them through the vault, and pulls * them back — then compares byte-for-byte. * * This is the test that would catch the things unit tests cannot: a path that * survives the round trip wrong, a 600 KB log that trips a size ceiling, a * workspace whose file count makes the manifest too large to commit. It is * skipped when no daemon is listening, so it never blocks an ordinary run. * * scaffoldd --socket /tmp/scaffoldd-e2e.sock & * SCAFFOLDD_SOCKET=/tmp/scaffoldd-e2e.sock bun test scaffoldd-e2e */ import { describe, test, expect } from 'bun:test'; import { connect } from 'node:net'; import { readFile } from 'node:fs/promises'; import { SubtleCryptoProvider } from '@shade/crypto-web'; import { createVaultRoutes } from '../src/server.js'; import { MemoryVaultStore } from '../src/store.js'; import { HttpVaultTransport } from '../src/http-transport.js'; import { VaultClient, deriveVaultKeys } from '../src/client.js'; import type { VaultFile } from '../src/client.js'; const SOCKET = process.env.SCAFFOLDD_SOCKET; const crypto = new SubtleCryptoProvider(); interface BackupFile { path: string; source: string; size: number; } function ask(socketPath: string, request: object): Promise { return new Promise((resolve, reject) => { const sock = connect(socketPath); let buf = ''; const timer = setTimeout(() => { sock.destroy(); reject(new Error('scaffoldd svarte ikke innen 20 s')); }, 20_000); // Deliberately no `end()` after writing: Bun's socket closes both // directions, so the daemon's reply is discarded before it arrives. The // protocol is line-delimited, so read until the first complete line and // close from here instead. sock.on('connect', () => { sock.write(`${JSON.stringify(request)}\n`); }); sock.on('data', (c) => { buf += c.toString(); const nl = buf.indexOf('\n'); if (nl === -1) return; clearTimeout(timer); sock.destroy(); const res = JSON.parse(buf.slice(0, nl)); res.ok ? resolve(res.result) : reject(new Error(`${res.code}: ${res.error}`)); }); sock.on('end', () => { clearTimeout(timer); if (!buf.includes('\n')) reject(new Error('tomt svar')); }); sock.on('error', (e) => { clearTimeout(timer); reject(e); }); }); } async function harness() { const store = new MemoryVaultStore(); const routes = createVaultRoutes(store, crypto); const transport = new HttpVaultTransport('http://vault.test', (i, init) => routes.fetch(new Request(i, init)), ); const keys = await deriveVaultKeys(new Uint8Array(32).fill(42), 'scaffold-e2e'); return { store, client: new VaultClient(crypto, keys, transport) }; } describe.skipIf(!SOCKET)('scaffoldd → vault, real workspace', () => { test('the whole backup set round-trips byte-for-byte', async () => { const set = (await ask(SOCKET!, { op: 'backupSet' })) as { files: BackupFile[]; count: number; bytes: number; }; expect(set.count).toBeGreaterThan(50); const files: VaultFile[] = []; for (const f of set.files) { try { files.push({ path: f.path, bytes: new Uint8Array(await readFile(f.source)) }); } catch { // Matches the bridge: one unreadable file is skipped, not fatal. } } const { client } = await harness(); const push = await client.push(files, 1_786_600_000_000, 'e2e'); expect(push.seq).toBe(1); expect(push.uploaded).toBe(files.length); const { files: back } = await client.pull(); expect(back.size).toBe(files.length); // Every single file, not a sample: a backup that restores 99% of a // workspace is not a backup. for (const f of files) { const restored = back.get(f.path); expect(restored).toBeDefined(); expect(restored!.length).toBe(f.bytes.length); expect(Buffer.from(restored!).equals(Buffer.from(f.bytes))).toBe(true); } }, 120_000); test('a second push after one edit sends almost nothing', async () => { // The property that decides whether hourly backup is affordable. const set = (await ask(SOCKET!, { op: 'backupSet' })) as { files: BackupFile[] }; const files: VaultFile[] = []; for (const f of set.files.slice(0, 120)) { try { files.push({ path: f.path, bytes: new Uint8Array(await readFile(f.source)) }); } catch { /* skipped */ } } const { client } = await harness(); const first = await client.push(files, 1_786_600_000_000); const edited = files.map((f, i) => i === 0 ? { path: f.path, bytes: new TextEncoder().encode('endret én linje') } : f, ); const second = await client.push(edited, 1_786_600_001_000); expect(first.uploaded).toBe(files.length); expect(second.uploaded).toBe(1); expect(second.reused).toBe(files.length - 1); }, 120_000); test('the largest file in the workspace survives the round trip', async () => { // Terra's log.md is ~617 KB. Nothing in the chain may quietly cap it. const set = (await ask(SOCKET!, { op: 'backupSet' })) as { files: BackupFile[] }; const biggest = set.files.reduce((a, b) => (a.size > b.size ? a : b)); expect(biggest.size).toBeGreaterThan(100_000); const bytes = new Uint8Array(await readFile(biggest.source)); const { client } = await harness(); await client.push([{ path: biggest.path, bytes }], 1_786_600_000_000); const { files } = await client.pull(); expect(files.get(biggest.path)!.length).toBe(bytes.length); }, 120_000); });