diff --git a/lib/src/host/remote/service.test.ts b/lib/src/host/remote/service.test.ts index 6619e1607..5efcb607b 100644 --- a/lib/src/host/remote/service.test.ts +++ b/lib/src/host/remote/service.test.ts @@ -2117,6 +2117,45 @@ describe('one-time connection', () => { expect(phone.socket.closeCode).not.toBeNull(); }); + /** The one-time entries in the access log, after every append already queued. */ + async function oneTimeLog(): Promise { + const { entries } = (await command('accessLog')).result as AccessLogResult; + return entries.filter((entry) => entry.event.startsWith('one-time-')); + } + + it('logs a connection and its end', async () => { + createHostedService(); + await connectPhone(); + await command('oneTimeEnd'); + expect(await oneTimeLog()).toEqual([ + expect.objectContaining({ event: 'one-time-connected', device: 'iPhone', path: 'direct' }), + expect.objectContaining({ event: 'one-time-ended', device: 'iPhone', reason: 'user-ended', durationMs: expect.any(Number) }), + ]); + }); + + it('logs the end of a connection the service takes down as it is disposed', async () => { + createHostedService(); + await connectPhone(); + service.dispose(); + await settle(); + expect(store.accessLog.map((entry) => entry.event)).toEqual(['one-time-connected', 'one-time-ended']); + }); + + it('logs a confirming phone whose link a new one replaced as denied', async () => { + createHostedService(); + const phone = await join((await open()).url); + await request(phone); + await open(); + expect(await oneTimeLog()).toEqual([expect.objectContaining({ event: 'one-time-denied', device: 'iPhone', reason: 'user-ended' })]); + }); + + it('logs nothing for a link no phone confirmed', async () => { + createHostedService(); + await open(); + await command('oneTimeEnd'); + expect(await oneTimeLog()).toEqual([]); + }); + it('queues the request in its own slot, and routes the answer by kind', async () => { createHostedService(); const phone = await join((await open()).url); diff --git a/lib/src/host/remote/service.ts b/lib/src/host/remote/service.ts index a2a55073e..408d8991e 100644 --- a/lib/src/host/remote/service.ts +++ b/lib/src/host/remote/service.ts @@ -1486,8 +1486,10 @@ export class BurrowService { } #onOneTimeChanged(runtime: OneTimeRuntime, state: OneTimeState): void { - if (this.#oneTime !== runtime) return; + // A replaced or disposed runtime's end is still logged; only the current + // one's state reaches the webviews. this.#logOneTime(runtime, state); + if (this.#oneTime !== runtime) return; // An ended runtime holds nothing more; its last state stays for the panel. if (state.status === 'ended') this.#oneTime = null; this.#setOneTimeState(state); diff --git a/lib/src/host/remote/test-burrow-link.ts b/lib/src/host/remote/test-burrow-link.ts index a916957b2..cada13061 100644 --- a/lib/src/host/remote/test-burrow-link.ts +++ b/lib/src/host/remote/test-burrow-link.ts @@ -155,9 +155,10 @@ const HOUR_MS = 60 * 60_000; * first paired an hour ago and connected now; each after it paired a day * before the last and was last seen two hours after pairing. Relative to now, * like {@link setupQrResult}, so "Last seen" reads the same in every story. + * A caller that runs before a story pins the clock (a `parameters` literal is + * built at module load) passes its pinned `now`. */ -export function pairedDevices(count: number): PairedDevice[] { - const now = Date.now(); +export function pairedDevices(count: number, now = Date.now()): PairedDevice[] { return Array.from({ length: count }, (_, index) => { const pairedAt = now - HOUR_MS - index * 24 * HOUR_MS; return { @@ -176,7 +177,10 @@ export function pairedDevices(count: number): PairedDevice[] { * one's pairing, the second's session that went direct and ended, a one-time * connection, an unpaired phone refused, and the first's session open now. */ -export function accessLogEntries(devices: PairedDevice[] = pairedDevices(2)): AccessLogEntry[] { +export function accessLogEntries( + devices: PairedDevice[] = pairedDevices(2), + now = Date.now(), +): AccessLogEntry[] { const entries: AccessLogEntry[] = []; for (const [index, { label: device, id: deviceId, pairedAt, lastSeenAt }] of devices.entries()) { entries.push({ at: pairedAt, event: 'paired', device, deviceId }); @@ -192,7 +196,6 @@ export function accessLogEntries(devices: PairedDevice[] = pairedDevices(2)): Ac { at: lastSeenAt, event: 'ended', device, deviceId, path: 'direct', durationMs: 4 * 60_000 }, ); } - const now = Date.now(); const oneTime = 'Dormouse Pocket (browser)'; entries.push( { at: now - 3 * HOUR_MS, event: 'one-time-connected', device: oneTime, path: 'direct' }, diff --git a/lib/src/stories/RemoteControlSection.stories.tsx b/lib/src/stories/RemoteControlSection.stories.tsx index 8e6dcdd7d..c465d3f92 100644 --- a/lib/src/stories/RemoteControlSection.stories.tsx +++ b/lib/src/stories/RemoteControlSection.stories.tsx @@ -39,7 +39,9 @@ function RemoteControlStory() { } /** The clock every story here reads. A setup code encodes its expiry, so a real - * clock would draw a different QR on every run. */ + * clock would draw a different QR on every run. `beforeEach` pins it only + * around render: a `parameters` literal is built at module load, under the + * real clock, so a timestamp there must use `STORY_NOW` itself. */ const STORY_NOW = Date.UTC(2026, 0, 1); const meta: Meta = { @@ -342,7 +344,7 @@ export const PairedPhonesAndAccessLog: Story = { parameters: { primedBurrow: { status: enrolledStatus({ pairedClients: 2 }), - accessLog: accessLogEntries(pairedDevices(2)), + accessLog: accessLogEntries(pairedDevices(2, STORY_NOW), STORY_NOW), }, docs: { story: { height: '620px' } }, }, @@ -703,7 +705,7 @@ export const OneTimeEndedNetworkNotAllowed: Story = { oneTime: { status: 'ended', reason: 'network-not-allowed', - refusal: { at: Date.now(), kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }, + refusal: { at: STORY_NOW, kind: 'path-refused', end: 'remote', address: '172.58.12.9', addressSource: 'observed' }, }, }, docs: { story: { height: '360px' } },