// 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);