// @vitest-environment jsdom import { COLLECTIONS, type DeviceKeyRecord, type DeviceKeyStore } from '@radial/core' import { flushSync, mount, unmount } from 'svelte' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Space } from '$lib/space.js' import PrivateDevices from './PrivateDevices.svelte' // The private-mode section, drawn. What it must not get wrong is behaviour rather than layout: the // three transport states read as three different sentences, no key action claims to take anything // back, and retiring a device — the one action here that is a response to something going wrong — // is offered on exactly the rows where it is this account's to do, and confirms before it writes. const fixture = vi.hoisted(() => { const me = 'did:plc:member' const space = 'at://did:plc:founder/com.disnetdev.radial.space/skunk' const now = '2026-03-01T00:00:00.000Z' return { me, space, now, /** * What the replica reports about its transport, swapped per test. * * Mutable because the three states this section has to tell apart — no endpoint, an endpoint * nobody answered, an endpoint with peers on it — are three renders of one component, and the * whole point of the change is that they read as three different sentences (ADR §21). */ transport: { localOnly: true, connections: undefined as undefined | { connected: number; state: () => unknown[] }, error: undefined as string | undefined, }, records: [ { did: me, collection: 'com.disnetdev.radial.project', rkey: 'p1', uri: `at://${me}/com.disnetdev.radial.project/p1`, cid: 'cid-p1', rev: 'r1', firstSeenAt: now, deviceKeyIds: ['browser-mine'], value: {}, }, ], } }) const ME = fixture.me const SPACE = fixture.space const NOW = fixture.now vi.mock('$lib/auth.svelte.js', () => ({ account: { status: 'signed-in', did: fixture.me }, })) vi.mock('$lib/ui.svelte.js', () => ({ toast: () => undefined })) vi.mock('$lib/session.svelte.js', () => ({ openSpace: async () => undefined, privateTransportError: () => fixture.transport.error, privateReplica: () => ({ localOnly: fixture.transport.localOnly, connections: fixture.transport.connections, spaceUri: fixture.space, meta: { spaceUri: fixture.space, founder: 'did:plc:founder', protocol: 1, peerHints: ['did:plc:founder'], createdAt: fixture.now, }, stores: { records: { records: () => fixture.records }, envelopes: { all: () => [1, 2] }, blobs: { list: () => [] }, }, ingestor: { missingBlobs: () => [], outstanding: () => ({ quarantined: 0, wants: 0, blobs: 0 }), }, }), })) const key: DeviceKeyRecord = { did: ME, deviceKeyId: 'browser-mine', publicKey: 'aaaa', kind: 'browser', createdAt: NOW, } const keys: DeviceKeyStore = { list: async () => [key], active: async () => key, create: async () => key, retire: async () => key, signer: async () => { throw new Error('not used') }, } const device = (deviceKeyId: string, did: string, endpointId?: string) => ({ did, deviceKeyId, uri: `at://${did}/${COLLECTIONS.device}/${deviceKeyId}`, cid: `cid-${deviceKeyId}`, publicKey: 'aaaa', algorithm: 'ed25519' as const, kind: 'browser' as const, createdAt: NOW, ...(endpointId ? { endpointId } : {}), }) const spaceWith = (devices: unknown[]) => ({ uri: SPACE, index: { private: true, devices }, directory: { get: () => ({ name: 'founder.test' }) }, asOf: NOW, fixture: false, }) as unknown as Space const space = spaceWith([ device('browser-mine', ME), // Another machine of mine: published, saying where it is, and not one this browser can sign // with. That is the row retiring exists for — the laptop somebody no longer has. device('node-mine-old', ME, 'endpoint-old'), // Already withdrawn. It carries no `endpointId`, because that is what the fold does with a // retired address: it keeps it out of the map a dialler reads (ADR §29). { ...device('node-gone', ME), retiredAt: NOW }, device('node-theirs', 'did:plc:founder', 'endpoint-theirs'), // A device somebody else withdrew. This browser holds no key store for them, so their published // `retiredAt` is the whole of what it can know about it — and the whole of what matters, since it // is what decides whether anything will connect to them. { ...device('node-theirs-gone', 'did:plc:founder'), retiredAt: NOW }, ]) let target: HTMLElement | undefined let component: Record | undefined afterEach(() => { if (component) unmount(component as never) target?.remove() component = undefined target = undefined }) async function render(props: Record = {}): Promise { target = document.createElement('div') document.body.appendChild(target) component = mount(PrivateDevices, { target, props: { space, keys, reopen: async () => undefined, ...props }, }) as Record await vi.waitFor(() => { flushSync() expect(target?.textContent).toContain('browser-mine') }) return target } const buttons = (root: HTMLElement, text: string): HTMLButtonElement[] => ([...root.querySelectorAll('button')] as HTMLButtonElement[]).filter( (button) => button.textContent?.trim() === text, ) describe('the private mode section', () => { afterEach(() => { fixture.transport = { localOnly: true, connections: undefined, error: undefined } }) it('counts what the replica holds', async () => { const root = await render() expect(root.textContent).toContain('2 envelopes') }) it('offers no per-device revoke action', async () => { const root = await render() expect(buttons(root, 'Revoke')).toHaveLength(0) expect(root.textContent).toContain('node-theirs') }) // Retiring a device (ADR §29). Asserted as behaviour rather than as layout, for §19's reason: what // has to hold is that the first click does not write and that the offer appears on exactly the // rows where withdrawing an address is this account's to do. it('offers Retire on another addressed machine of mine, and confirms before writing', async () => { const root = await render() expect(buttons(root, 'Retire')).toHaveLength(1) buttons(root, 'Retire')[0]?.click() flushSync() // Nothing has been written: the first click swaps the control for the consequence and a second // button, which is the whole of the gate. expect(buttons(root, 'Retire it')).toHaveLength(1) expect(buttons(root, 'Cancel')).toHaveLength(1) expect(root.textContent).toContain('node-mine-old') // The three things a person has to know, in the order they matter: what it stops, what it does // not take back, and why it may not stick. expect(root.textContent).toContain('will connect to it') expect(root.textContent).toContain('keeps counting') expect(root.textContent).toContain('end its session at your PDS') buttons(root, 'Cancel')[0]?.click() flushSync() expect(buttons(root, 'Retire it')).toHaveLength(0) }) it('keeps retired devices in a closed section of their own', async () => { const root = await render() const mine = root.querySelector('details[data-retired="mine"]') as HTMLDetailsElement | null expect(mine).not.toBeNull() expect(mine?.open).toBe(false) expect(mine?.textContent).toContain('node-gone') // The list above shows what is in use, and the retired one is not in it. const live = [...root.querySelectorAll('ul.list')][0] as HTMLElement expect(live.textContent).not.toContain('node-gone') expect(live.textContent).toContain('browser-mine') // Still listed rather than dropped: its history is in the space and still counts, so a list that // forgot it would answer "who signed this?" with a shrug. expect(mine?.textContent).toContain('still counts') }) it('holds another member’s retired device apart too, in a section of its own', async () => { // The read-only list had the same problem and needed the same answer. Two disclosures rather // than one, because whose device it is, is the whole of what separates the two lists. const root = await render() const theirs = root.querySelector('details[data-retired="others"]') as HTMLDetailsElement | null expect(theirs).not.toBeNull() expect(theirs?.open).toBe(false) expect(theirs?.textContent).toContain('node-theirs-gone') expect(theirs?.textContent).not.toContain('node-gone') // The two lists nothing has collapsed: this account's live devices, then everybody else's. const open = ([...root.querySelectorAll('ul.list')] as HTMLElement[]).filter( (list) => list.closest('details') === null, ) expect(open).toHaveLength(2) expect(open[1]?.textContent).toContain('node-theirs') expect(open[1]?.textContent).not.toContain('node-theirs-gone') // …and it is still nobody else's to act on: a device's address is its owner's to write. expect(theirs?.querySelector('button')).toBeNull() }) it('has no retired section at all when nothing has been retired', async () => { // DESIGN.md's disclosure rule: absent when there is nothing to report, rather than an empty // section a person has to open to learn it is empty. const root = await render({ space: spaceWith([device('browser-mine', ME), device('node-theirs', 'did:plc:founder')]), }) expect(root.querySelector('details[data-retired]')).toBeNull() }) it('does not offer it for the key this tab signs with, or for anybody else’s device', async () => { // A device that is still running republishes its own address on the next tick and undoes this, // and this tab is the clearest case of one. Another member's address is in THEIR repo, which is // exactly why retiring works at all: a device's address is its owner's to write. const root = await render() const rows = [...root.querySelectorAll('li.arow')] const rowFor = (id: string) => rows.find((row) => row.textContent?.includes(id)) expect(rowFor('browser-mine')?.querySelector('button')).toBeNull() expect(rowFor('node-theirs')?.querySelector('button')).toBeNull() expect(rowFor('node-mine-old')?.querySelector('button')).not.toBeNull() }) // The three transport states, which are the reason this section exists at all. "No endpoint" and // "nobody answered" are different facts with different next steps, and the one thing this must // never do is report the first as the second: a tab that never asked has not found the space // empty (ADR §21). it('says it has no endpoint, and why, when it could not bind one', async () => { fixture.transport.error = 'the WebAssembly binding would not load' const root = await render() expect(root.textContent).toContain('no transport endpoint') expect(root.textContent).toContain('the WebAssembly binding would not load') expect(root.textContent).not.toContain('peers connected') }) it('distinguishes an endpoint nobody answered from having none', async () => { fixture.transport.localOnly = false fixture.transport.connections = { connected: 0, state: () => [] } const root = await render() expect(root.textContent).toContain('listening') expect(root.textContent).toContain('no other member is online') expect(root.textContent).not.toContain('no transport endpoint') }) // A ticket is how ONE PERSON gets in, so it is minted on that person's row in Members and is not // an operation this section offers. Asserted because the two sections are adjacent and a ticket // control drifting back here would put a secret beside a list of keys, where nobody is named. it('offers no ticket of its own — that is a member’s row', async () => { const root = await render() expect(root.querySelector('[data-ticket]')).toBeNull() expect(buttons(root, 'Mint a ticket')).toHaveLength(0) expect(buttons(root, 'Ticket')).toHaveLength(0) // The disclosure still explains what accepting one publishes, and should: that is a sentence // about the space, not a control. Only the sections above are asserted bare. const sections = [...root.querySelectorAll('.sec-head')].map((head) => head.textContent?.trim()) expect(sections.some((head) => head?.includes('ticket'))).toBe(false) }) it('counts the peers it is connected to', async () => { fixture.transport.localOnly = false fixture.transport.connections = { connected: 2, state: () => [] } const root = await render() // Normalised: the sentence wraps across lines in the template, and where it wraps is not a // claim this test has any business making. const said = (root.textContent ?? '').replace(/\s+/g, ' ') expect(said).toContain('2 peers connected') expect(said).toContain('connected to 2 other devices') }) })