// The web host end to end (Phase C, docs/WEB-APP.md), headless: two
// browser-profile stand-ins against the fake network, driving the exact
// bridge the tile frames speak — open from the home space, save through
// tp-data (with the web durability rule), send ops through tp-sync, and
// prove a second profile receives both.
// Run: deno run -A src/web/webhost.test.ts
import { MemoryFs, setZiranFs, zfs } from '../lib/zfs.ts';
import { passwordSession } from '../sync/spaces/client.ts';
import { FakeSpacesNetwork } from '../sync/spaces/fake.ts';
import { SpaceClient } from '../sync/spaces/client.ts';
import { parseTile, writeTile } from '../server/tilefile.ts';
import { importTileToHome, listHomeDocs } from './home.ts';
import { WebHost } from './host.ts';
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 b64 = (s: string) => btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const unb64 = (s: string) => atob(s.replace(/-/g, '+').replace(/_/g, '/'));
const net = new FakeSpacesNetwork();
const resolver = { plcDirectory: net.url };
const alice = net.account('alice');
const auth = await passwordSession(net.url, alice.handle, alice.password);
/* ——— profile A: import, open, save, send ——— */
const aMem = new MemoryFs();
setZiranFs(aMem);
const client = new SpaceClient({ auth, resolver });
const mkPath = '/mk/doc.tile';
await writeTile(
{ name: 'Web Phase C', model: { id: 'com.berjon.ziran.testtile' }, resources: {} },
new Map([
['/', new TextEncoder().encode('
c')],
['/.well-known/web-tiles-storage/state', new TextEncoder().encode('{"v":0}')],
]),
mkPath,
);
const imported = await importTileToHome(client, await zfs.readFile(mkPath), 'doc.tile');
const a = new WebHost(auth, fetch, { resolver, autoPoll: false, publishDelayMs: 50 });
await a.init();
const aDoc = await a.openHomeDoc(imported.rkey);
check('openHomeDoc materializes from the home space', aDoc.name === 'Web Phase C', aDoc.path);
check('…verified on the way in', !aDoc.tile.damaged);
const attach = await a.bridge.syncAttach(aDoc.localId);
check('the tile attaches live (tp-sync hello)', attach.shared === true);
check(
'tp-data load reads the materialized self-storage',
new TextDecoder().decode((await a.bridge.loadData(aDoc.localId, 'state'))!) === '{"v":0}',
);
await a.bridge.putData(aDoc.localId, 'state', new TextEncoder().encode('{"v":1}'));
check(
'tp-data put rewrites it',
new TextDecoder().decode((await a.bridge.loadData(aDoc.localId, 'state'))!) === '{"v":1}',
);
await sleep(150); // the shrunken durability window
await a.bridge.syncSend(aDoc.localId, b64('op-from-a'));
await sleep(400); // flush quiet window
await a.provider.pollNow(true);
/* ——— profile B: a different browser, nothing local ——— */
const bMem = new MemoryFs();
setZiranFs(bMem);
const b = new WebHost(auth, fetch, { resolver, autoPoll: false, publishDelayMs: 50 });
const bEvents: Array> = [];
b.events.addEventListener('sync-msg', (ev) => bEvents.push((ev as CustomEvent).detail as Record));
await b.init();
const bDoc = await b.openHomeDoc(imported.rkey);
check(
'profile B materializes the SAVED state (putData survived the tab)',
new TextDecoder().decode((await b.bridge.loadData(bDoc.localId, 'state'))!) === '{"v":1}',
);
await b.provider.pollNow(true);
check(
'profile B replays A’s op through tp-sync',
bEvents.some((e) => e.docId === bDoc.localId && unb64(String(e.b64)) === 'op-from-a'),
JSON.stringify(bEvents.map((e) => unb64(String(e.b64)))),
);
// And back the other way: B sends, A (on its own store) receives.
await b.bridge.syncSend(bDoc.localId, b64('op-from-b'));
await sleep(400);
await b.provider.pollNow(true);
setZiranFs(aMem);
const aEvents: Array> = [];
a.events.addEventListener('sync-msg', (ev) => aEvents.push((ev as CustomEvent).detail as Record));
await a.provider.pollNow(true);
check(
'ops flow back to profile A',
aEvents.some((e) => e.docId === aDoc.localId && unb64(String(e.b64)) === 'op-from-b'),
JSON.stringify(aEvents.map((e) => unb64(String(e.b64)))),
);
/* ——— rename: the manifest, the space record, and the list all follow ——— */
setZiranFs(aMem);
await a.rename(aDoc.localId, 'Renamed on the web');
check('rename updates the open doc', a.doc(aDoc.localId)?.name === 'Renamed on the web');
check(
'rename rewrites the materialized manifest',
(await parseTile(aDoc.path, { verify: false })).manifest.name === 'Renamed on the web',
);
check(
'rename reaches the home space record (the document list)',
(await listHomeDocs(client)).find((d) => d.rkey === imported.rkey)?.name === 'Renamed on the web',
);
/* ——— go live from home: the doc MOVES into a shared space ——— */
const moved = await a.provider.share(aDoc.localId, { newSpaceName: 'Web Crew' });
check('sharing a home doc lands it in a real space', !moved.space.includes('/space/space.ziran.home/'), moved.space);
const sum = a.provider.summary();
const dSum = sum.docs[aDoc.localId];
check('…same localId, new address', dSum?.space === moved.space && dSum?.home !== true);
check('…now with a share link and invitable roles', Boolean(dSum?.link) && (dSum?.roles ?? []).some((r) => r.invitable));
check('…the space carries the renamed title', moved.at.length > 0 &&
(await client.getRecord(moved.space, auth.did, 'space.ziran.doc', moved.docRkey)).value.name === 'Renamed on the web');
const movedCp = await client.getRecord(moved.space, auth.did, 'space.ziran.checkpoint', moved.docRkey)
.then(() => true).catch(() => false);
check('…with a go-live checkpoint (others can instantiate)', movedCp);
check('the home space no longer lists it', !(await listHomeDocs(client)).some((d) => d.rkey === imported.rkey));
check('sharing again is idempotent (already live)', (await a.provider.share(aDoc.localId)) === moved);
check('the home space itself is flagged, never a go-live target',
sum.spaces?.some((s) => s.home === true) === true &&
sum.spaces?.every((s) => s.home !== true || s.ref !== moved.space) === true);
a.close();
b.close();
await net.close();
console.log(failures ? `\n${failures} FAILED` : '\nall green');
Deno.exit(failures ? 1 : 0);