// Second-invocation hand-off: OS file/URL associations start a NEW process, // so opening a .tile or clicking ziran:// while Ziran runs must hand the // argument to the running instance and exit — not boot a second app. With no // instance running, the new process keeps the argument and boots normally. // Run: deno run -A src/server/deeplink.test.ts import { join } from '@std/path'; let failures = 0; const check = (label: string, ok: boolean, detail = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); if (!ok) failures++; }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const tmp = await Deno.makeTempDir({ prefix: 'ziran-dl-' }); const PORT = 4876; const KEY = 'dl-key'; function spawnApp(port: number, args: string[] = []): Deno.ChildProcess { return new Deno.Command(Deno.execPath(), { args: ['run', '-A', 'main.ts', ...args], env: { ZIRAN_SYNC: 'relay', // these suites exercise the relay stack ZIRAN_DATA_DIR: join(tmp, 'app'), ZIRAN_KEY: KEY, ZIRAN_PORT: String(port), ZIRAN_DID: 'did:ziran:dl-a', ZIRAN_HANDLE: 'dl.test', }, stdout: 'null', stderr: 'null', }).spawn(); } const req = async (path: string, body?: unknown) => { const res = await fetch(`http://127.0.0.1:${PORT}${path}`, { method: body === undefined ? 'GET' : 'POST', headers: { 'x-ziran-key': KEY, 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error ?? `${path} → ${res.status}`); return data; }; const waitUp = async () => { for (let i = 0; i < 80; i++) { const ok = await fetch(`http://127.0.0.1:${PORT}/api/state`, { headers: { 'x-ziran-key': KEY } }) .then((r) => (r.body?.cancel(), r.ok), () => false); if (ok) return; await sleep(250); } throw new Error('app did not start'); }; let a: Deno.ChildProcess | undefined; try { // Instance A, with a document whose window we then close. a = spawnApp(PORT); await waitUp(); const state = await req('/api/state'); const model = state.models.find((m: { id: string }) => m.id === 'com.berjon.ziran.checklist'); const opened = await req('/api/docs/from-model', { modelId: model.id }); const docId = opened.doc.id as string; const docPath = opened.doc.path as string; await req(`/api/windows/${opened.win.id}/op`, { op: 'close' }); const before = await req('/api/state'); check('window closed before the hand-off', !before.windows.some((w: { panes: { tabs: string[] }[] }) => w.panes.some((p) => p.tabs.includes(docId)))); // A second invocation with the .tile path: hands off and exits promptly. const t0 = Date.now(); const second = spawnApp(PORT + 1, [docPath]); const status = await second.status; const took = Date.now() - t0; check('second invocation exits cleanly', status.code === 0, `code ${status.code}`); check('…without booting a second app', took < 8_000, `${took}ms`); await sleep(400); // makeTempDir hands out /var/… on macOS while realPath canonicalizes to // /private/var/…, so match by file rather than by path-derived id. const stem = docPath.split('/').pop()!; const windowedDoc = (s: { open: Array<{ id: string; path: string }>; windows: Array<{ panes: { tabs: string[] }[] }> }) => { const ids = s.open.filter((d) => d.path.endsWith(stem)).map((d) => d.id); return s.windows.some((w) => w.panes.some((p) => p.tabs.some((t) => ids.includes(t)))); }; const after = await req('/api/state'); check('the running instance opened the document', windowedDoc(after), JSON.stringify(after.windows.length)); // A bogus invite link: still hands off (the running instance surfaces the // error), still exits cleanly, instance A unharmed. const bogus = spawnApp(PORT + 1, ['ziran://invite/deadbeef']); const bogusStatus = await bogus.status; check('bogus deep link exits cleanly too', bogusStatus.code === 0, `code ${bogusStatus.code}`); check('instance A is unharmed', (await req('/api/state')).recents.length >= 1); // A plain double-launch (no arguments): the second copy raises A and exits // instead of booting a twin over the same data dir — two instances share // one OAuth session store, and single-use refresh tokens make that a // session-killing fight. const twin = spawnApp(PORT + 1); const twinStatus = await Promise.race([ twin.status, sleep(8_000).then(() => { twin.kill(); return { code: -1 } as Deno.CommandStatus; }), ]); check('a plain double-launch exits instead of booting a twin', twinStatus.code === 0, `code ${twinStatus.code}`); const twinUp = await fetch(`http://127.0.0.1:${PORT + 1}/api/state`, { headers: { 'x-ziran-key': KEY } }) .then((r) => (r.body?.cancel(), true), () => false); check('…and no second server came up', !twinUp); // No instance running: the argument boots the app and opens the doc itself. a.kill(); a = undefined; await sleep(800); a = spawnApp(PORT, [docPath]); await waitUp(); await sleep(600); const solo = await req('/api/state'); check('with no instance, the argument boots the app and opens the doc', windowedDoc(solo), JSON.stringify(solo.windows.length)); } catch (err) { check('script completed', false, (err as Error).message); } finally { try { a?.kill(); } catch { /* gone */ } await sleep(300); await Deno.remove(tmp, { recursive: true }).catch(() => {}); } if (failures) { console.error(`\n${failures} failure(s)`); Deno.exit(1); } console.log('\ndeep-link hand-off: all good');