From 192710440b9f52bdd3a63ca4b0bad125c93c0a53 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:46:49 +0000 Subject: [PATCH 1/2] Log a one-time connection's end when it was replaced or disposed #onOneTimeChanged dropped every move of a runtime that was no longer the current one before it reached the access log. Opening a new link while a phone was confirming the old one therefore left no one-time-denied entry, and a connection the service ended as it was disposed (a VS Code window closing, a quit) left a one-time-connected entry with no one-time-ended. Log first; only publishing state stays limited to the current runtime. --- lib/src/host/remote/service.test.ts | 39 +++++++++++++++++++++++++++++ lib/src/host/remote/service.ts | 4 ++- 2 files changed, 42 insertions(+), 1 deletion(-) 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); From 7a1c721d43d8418cf645f809489d1bc43080fc73 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 31 Oct 2026 09:25:06 -0700 Subject: [PATCH 2/2] Pin the access-log story's timestamps to the story clock `PairedPhonesAndAccessLog` built its log in a `parameters` literal, which runs at module load, before the meta's `beforeEach` pins `Date.now` to `STORY_NOW`. The log therefore carried the CI runner's wall-clock time, and every PR showed an Argos diff on `modals-remotecontrolsection--paired-phones-and-access-log`. `pairedDevices` and `accessLogEntries` now take an optional `now`, and the story passes `STORY_NOW`; `OneTimeEndedNetworkNotAllowed`'s refusal time gets the same treatment. Co-Authored-By: Claude Opus 5.5 --- lib/src/host/remote/test-burrow-link.ts | 11 +++++++---- lib/src/stories/RemoteControlSection.stories.tsx | 8 +++++--- 2 files changed, 12 insertions(+), 7 deletions(-) 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' } },