// The ziran CLI, end to end as a real subprocess against the fake network: // login/logout/accounts/use, publish from .tile AND from unpacked sources, // list, version chain, unpublish. // Run: deno run -A src/cli/cli.test.ts import { join } from 'jsr:@std/path@1'; import { FakeSpacesNetwork } from '../sync/spaces/fake.ts'; import { getPublished } from '../store/store.ts'; import { writeTile } from '../server/tilefile.ts'; let failures = 0; const check = (label: string, ok: boolean, detail = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); if (!ok) failures++; }; const net = new FakeSpacesNetwork(); const alice = net.account('alice'); const bob = net.account('bob'); const dir = await Deno.makeTempDir({ prefix: 'ziran-cli-' }); const configDir = join(dir, 'config'); // A packed tile on disk. const tilePath = join(dir, 'My Notes.tile'); await writeTile( { name: 'Untitled notes', description: 'Notes for testing.', model: { id: 'com.example.notes', name: 'Notes' }, resources: {}, }, new Map([['/', new TextEncoder().encode('n')]]), tilePath, ); // An UNPACKED tile: a directory with manifest.json + index.html + an asset. const unpacked = join(dir, 'Party Game'); await Deno.mkdir(unpacked); await Deno.writeTextFile( join(unpacked, 'manifest.json'), JSON.stringify({ name: 'Untitled party', description: 'A game.', resources: {} }), ); await Deno.writeTextFile(join(unpacked, 'index.html'), 'p'); await Deno.writeTextFile(join(unpacked, 'style.css'), 'body { margin: 0 }'); const CLI = new URL('./ziran.ts', import.meta.url).pathname; async function ziran(args: string[], env: Record = {}): Promise<{ code: number; out: string; err: string }> { const cmd = await new Deno.Command(Deno.execPath(), { args: ['run', '-A', CLI, ...args], // ZIRAN_NO_ENV_CREDS: the test must NEVER fall through to ambient // credentials (a real .env.store-publisher sits at the repo root). env: { ZIRAN_CONFIG_DIR: configDir, ZIRAN_NO_ENV_CREDS: '1', ...env }, stdout: 'piped', stderr: 'piped', }).output(); return { code: cmd.code, out: new TextDecoder().decode(cmd.stdout), err: new TextDecoder().decode(cmd.stderr), }; } // The legacy-chain test provides a COMPLETE env identity (so no file is // ever consulted) and re-enables the chain. const legacyEnv = { ZIRAN_HANDLE: alice.handle, ZIRAN_PASSWORD: alice.password, ZIRAN_PDS: net.url, ZIRAN_NO_ENV_CREDS: '', }; /* ——— help & refusals ——— */ const help = await ziran(['help']); check('help prints usage', help.code === 0 && help.out.includes('publish '), help.err); const unknown = await ziran(['frobnicate']); check('an unknown command is refused with guidance', unknown.code === 1 && unknown.err.includes('ziran help')); const noone = await ziran(['publish', tilePath]); check('publishing signed out says how to sign in', noone.code === 1 && noone.err.includes('ziran login'), noone.err); /* ——— login / accounts / use / logout ——— */ const badPw = await ziran(['login', alice.handle, '--password', 'wrong', '--pds', net.url]); check('login verifies before storing (bad password fails)', badPw.code === 1, badPw.out); const inA = await ziran(['login', alice.handle, '--password', alice.password, '--pds', net.url]); check('login signs in and stores', inA.code === 0 && inA.out.includes(`signed in as ${alice.handle}`), inA.err); check('the first login is the default', inA.out.includes('default')); await ziran(['login', bob.handle, '--password', bob.password, '--pds', net.url]); const who = await ziran(['accounts']); check( 'accounts lists everyone, * marks the default', who.out.includes(alice.handle) && who.out.includes(bob.handle) && who.out.split('\n').some((l) => l.startsWith('*') && l.includes(alice.handle)), who.out, ); const mode = (await Deno.stat(join(configDir, 'accounts.json'))).mode; check('the accounts file is owner-only', mode !== null && (mode & 0o077) === 0, String(mode! & 0o777)); /* ——— publish: default account, --as, sources ——— */ const pub = await ziran(['publish', tilePath]); check('publish uses the default account', pub.code === 0 && pub.out.includes(`@${alice.handle}/my-notes`), pub.err); check('…reports the version cid', /version: baf/.test(pub.out), pub.out); check('the record is really on the server', (await getPublished(fetch, net.url, alice.did, 'my-notes'))?.value.title === 'Notes'); const asBob = await ziran(['publish', tilePath, '--as', bob.handle, '--name', 'notes-b']); check('--as picks another signed-in account', asBob.code === 0 && asBob.out.includes(`@${bob.handle}/notes-b`), asBob.err); check('…and lands in THEIR repo', (await getPublished(fetch, net.url, bob.did, 'notes-b')) !== null); const fromDir = await ziran(['publish', unpacked]); check('publish packs an unpacked-tile directory', fromDir.code === 0 && fromDir.out.includes(`@${alice.handle}/party-game`), fromDir.err); const dirPub = await getPublished(fetch, net.url, alice.did, 'party-game'); check('…as an app (no model field)', dirPub?.value.kind === 'app'); check('…with its resources aboard', Boolean(dirPub?.value.tile.resources['/style.css']), JSON.stringify(Object.keys(dirPub?.value.tile.resources ?? {}))); const fromManifest = await ziran(['publish', join(unpacked, 'manifest.json'), '--name', 'party-two']); check('a manifest.json path publishes its directory', fromManifest.code === 0 && fromManifest.out.includes('party-two'), fromManifest.err); /* ——— use, list, legacy env, version chain, unpublish ——— */ await ziran(['use', bob.handle]); const who2 = await ziran(['accounts']); check('use switches the default', who2.out.split('\n').some((l) => l.startsWith('*') && l.includes(bob.handle)), who2.out); const listed = await ziran(['list', alice.did, '--pds', net.url]); check('list shows publishes', listed.code === 0 && listed.out.includes('my-notes'), listed.out || listed.err); const legacy = await ziran(['publish', tilePath, '--name', 'legacy-envs'], { ...legacyEnv, ZIRAN_CONFIG_DIR: join(dir, 'empty-config') }); check('the env credential chain still works when nobody is signed in', legacy.code === 0 && legacy.out.includes(alice.handle), legacy.err); await writeTile( { name: 'Untitled notes', description: 'Better notes.', model: { id: 'com.example.notes', name: 'Notes' }, resources: {}, }, new Map([['/', new TextEncoder().encode('n2')]]), tilePath, ); const pub2 = await ziran(['publish', tilePath, '--name', 'my-notes', '--as', alice.handle]); check('re-publishing a changed tile chains a version', pub2.code === 0 && pub2.out.includes('replaces baf'), pub2.out || pub2.err); const gone = await ziran(['unpublish', 'my-notes', '--as', alice.handle]); check('unpublish succeeds', gone.code === 0, gone.err); check('the record is really gone', (await getPublished(fetch, net.url, alice.did, 'my-notes')) === null); const outB = await ziran(['logout', bob.handle]); check('logout removes the account and moves the default', outB.code === 0 && outB.out.includes(`default is now ${alice.handle}`), outB.out); const who3 = await ziran(['accounts']); check('the signed-out account is gone', !who3.out.includes(bob.handle), who3.out); await Deno.remove(dir, { recursive: true }); await net.close(); console.log(failures ? `\n${failures} FAILED` : '\nall green'); Deno.exit(failures ? 1 : 0);