diff --git a/.github/workflows/publish-extension.yml b/.github/workflows/publish-extension.yml index afb0e08..2dbb58b 100644 --- a/.github/workflows/publish-extension.yml +++ b/.github/workflows/publish-extension.yml @@ -27,7 +27,7 @@ jobs: run: cd dist && zip -r ../extension-chrome.zip . - name: Upload to Chrome Web Store - uses: mnao305/chrome-extension-upload@v5.0.0 + uses: mnao305/chrome-extension-upload@4008e29e13c144d0f6725462cbd49b7c291b4928 # v5.0.0 with: file-path: packages/devtools-extension/extension-chrome.zip extension-id: ${{ secrets.CHROME_EXTENSION_ID }} @@ -65,7 +65,7 @@ jobs: run: zip -r source.zip . -x 'node_modules/*' '*/node_modules/*' '*/dist/*' '.git/*' '*.zip' - name: Upload to Firefox Add-ons - uses: trmcnvn/firefox-addon@v1 + uses: trmcnvn/firefox-addon@0d05671269b82c69c3f22ed86d8e772e89d47cf4 # v1 with: uuid: oidc-devtool@wolfcola xpi: packages/devtools-extension/extension-firefox.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 746535d..b16a709 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: - name: Publish Chrome extension to testers if: inputs.extension - uses: mnao305/chrome-extension-upload@v5.0.0 + uses: mnao305/chrome-extension-upload@4008e29e13c144d0f6725462cbd49b7c291b4928 # v5.0.0 with: file-path: packages/devtools-extension/extension-chrome.zip extension-id: ${{ secrets.CHROME_EXTENSION_ID }} @@ -77,7 +77,7 @@ jobs: - name: Publish Firefox extension (unlisted) if: inputs.extension - uses: trmcnvn/firefox-addon@v1 + uses: trmcnvn/firefox-addon@0d05671269b82c69c3f22ed86d8e772e89d47cf4 # v1 with: uuid: oidc-devtool@wolfcola xpi: packages/devtools-extension/extension-firefox.zip @@ -108,7 +108,7 @@ jobs: run: pnpm build - name: Create release PR or publish - uses: changesets/action@v1 + uses: changesets/action@63a615b9cd06ba9a3e6d13796c7fbcb080a60a0b # v1.8.0 with: publish: pnpm release version: pnpm run version diff --git a/e2e/fixtures/extension.ts b/e2e/fixtures/extension.ts index 6e07901..c66db50 100644 --- a/e2e/fixtures/extension.ts +++ b/e2e/fixtures/extension.ts @@ -72,7 +72,7 @@ export const test = base.extend({ mockServer: async ({}, use) => { const result = await createMockOidcServer(0); await use(result); - result.server.close(); + await new Promise((resolve) => result.server.close(() => resolve())); }, }); diff --git a/e2e/tests/network-capture.test.ts b/e2e/tests/network-capture.test.ts index d976195..fdfe9dd 100644 --- a/e2e/tests/network-capture.test.ts +++ b/e2e/tests/network-capture.test.ts @@ -29,14 +29,13 @@ test.describe('network capture pipeline', () => { }); }, `${mockServer.baseUrl}/.well-known/openid-configuration`); - await panelPage.waitForTimeout(1000); - - await panelPage.reload(); - await panelPage.waitForSelector('.toolbar', { state: 'visible' }); - await panelPage.waitForTimeout(500); - - const eventCount = await getEventCount(panelPage); - expect(eventCount).toBeGreaterThanOrEqual(1); + // Wait for the service worker to persist the event, then reload to verify + await expect(async () => { + await panelPage.reload(); + await panelPage.waitForSelector('.toolbar', { state: 'visible' }); + const eventCount = await getEventCount(panelPage); + expect(eventCount).toBeGreaterThanOrEqual(1); + }).toPass({ timeout: 5000 }); await panelPage.close(); }); @@ -75,8 +74,6 @@ test.describe('network capture pipeline', () => { }); }, `${mockServer.baseUrl}/.well-known/openid-configuration`); - await panelPage.waitForTimeout(500); - await panelPage.evaluate((url) => { chrome.runtime.sendMessage({ type: 'NETWORK_EVENT', @@ -104,13 +101,13 @@ test.describe('network capture pipeline', () => { }); }, `${mockServer.baseUrl}/token`); - await panelPage.waitForTimeout(1000); - await panelPage.reload(); - await panelPage.waitForSelector('.toolbar', { state: 'visible' }); - await panelPage.waitForTimeout(500); - - const eventCount = await getEventCount(panelPage); - expect(eventCount).toBeGreaterThanOrEqual(2); + // Wait for both events to be persisted, then reload to verify + await expect(async () => { + await panelPage.reload(); + await panelPage.waitForSelector('.toolbar', { state: 'visible' }); + const eventCount = await getEventCount(panelPage); + expect(eventCount).toBeGreaterThanOrEqual(2); + }).toPass({ timeout: 5000 }); await panelPage.close(); }); diff --git a/e2e/tests/panel-renders-events.test.ts b/e2e/tests/panel-renders-events.test.ts index 975052f..58748c1 100644 --- a/e2e/tests/panel-renders-events.test.ts +++ b/e2e/tests/panel-renders-events.test.ts @@ -34,13 +34,13 @@ test.describe('panel renders events', () => { makeSdkEvent('test-sdk-1', 'test-flow-sdk-1'), ); - await panelPage.waitForTimeout(500); - await panelPage.reload(); - await panelPage.waitForSelector('.toolbar', { state: 'visible' }); - await panelPage.waitForTimeout(500); - - const after = await panelPage.locator('.tl-row').count(); - expect(after).toBeGreaterThan(before); + // Wait for the event to be persisted, then reload to verify + await expect(async () => { + await panelPage.reload(); + await panelPage.waitForSelector('.toolbar', { state: 'visible' }); + const after = await panelPage.locator('.tl-row').count(); + expect(after).toBeGreaterThan(before); + }).toPass({ timeout: 5000 }); await panelPage.close(); }); @@ -56,22 +56,19 @@ test.describe('panel renders events', () => { makeSdkEvent('test-sdk-clear', 'test-flow-clear'), ); - await panelPage.waitForTimeout(500); - await panelPage.reload(); - await panelPage.waitForSelector('.toolbar', { state: 'visible' }); - await panelPage.waitForTimeout(500); - - let rows = await panelPage.locator('.tl-row').count(); - expect(rows).toBeGreaterThan(0); + // Wait for event to appear + await expect(async () => { + await panelPage.reload(); + await panelPage.waitForSelector('.toolbar', { state: 'visible' }); + const rows = await panelPage.locator('.tl-row').count(); + expect(rows).toBeGreaterThan(0); + }).toPass({ timeout: 5000 }); const clearBtn = panelPage.locator('.tb-btn', { hasText: 'Clear' }); - if (await clearBtn.isVisible()) { - await clearBtn.click(); - await panelPage.waitForTimeout(500); + await expect(clearBtn).toBeVisible(); + await clearBtn.click(); - rows = await panelPage.locator('.tl-row').count(); - expect(rows).toBe(0); - } + await expect(panelPage.locator('.tl-row')).toHaveCount(0, { timeout: 3000 }); await panelPage.close(); }); diff --git a/packages/devtools-bridge/src/index.ts b/packages/devtools-bridge/src/index.ts index f63fb5f..351678f 100644 --- a/packages/devtools-bridge/src/index.ts +++ b/packages/devtools-bridge/src/index.ts @@ -1,13 +1,5 @@ -export { attachDevToolsBridge } from './lib/bridge.js'; -export type { BridgeHandle } from './lib/bridge.js'; +export { attachDaVinciBridge } from './lib/davinci-bridge.js'; export { attachJourneyBridge } from './lib/journey-bridge.js'; -export type { JourneyBridgeHandle } from './lib/journey-bridge.js'; export { attachOidcBridge } from './lib/oidc-bridge.js'; -export type { OidcBridgeHandle } from './lib/oidc-bridge.js'; -export { - DEVTOOLS_EVENT_NAME, - emitAuthEvent, - emitConfigEvent, - configureDevtools, -} from './lib/emit.js'; -export type { DevtoolsOptions } from './lib/emit.js'; +export { DEVTOOLS_EVENT_NAME, emitAuthEvent, emitConfigEvent } from './lib/emit.js'; +export type { BridgeHandle, DevtoolsOptions } from './lib/emit.js'; diff --git a/packages/devtools-bridge/src/lib/bridge.test.ts b/packages/devtools-bridge/src/lib/davinci-bridge.test.ts similarity index 72% rename from packages/devtools-bridge/src/lib/bridge.test.ts rename to packages/devtools-bridge/src/lib/davinci-bridge.test.ts index ae823dd..cbe0232 100644 --- a/packages/devtools-bridge/src/lib/bridge.test.ts +++ b/packages/devtools-bridge/src/lib/davinci-bridge.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { attachDevToolsBridge, nodeToSdkData } from './bridge.js'; +import { attachDaVinciBridge, nodeToSdkData } from './davinci-bridge.js'; import { DEVTOOLS_EVENT_NAME } from './emit.js'; import type { AuthEvent } from '@wolfcola/devtools-types'; @@ -100,13 +100,22 @@ describe('nodeToSdkData', () => { const result = nodeToSdkData({ status: 'continue', cache: null } as never, undefined); expect(result.requestId).toBeUndefined(); }); + + it('passes responseBody through to the result', () => { + const body = { access_token: 'tok-abc', token_type: 'Bearer' }; + const result = nodeToSdkData({ status: 'success' }, 'continue', body); + expect(result.responseBody).toBe(body); + }); }); // --------------------------------------------------------------------------- // Mock client factory // --------------------------------------------------------------------------- -function makeClient(initialNode: Record) { +function makeClient( + initialNode: Record, + cache?: { getCache: (key: string) => unknown }, +) { let listener: (() => void) | null = null; let node = initialNode; return { @@ -117,6 +126,7 @@ function makeClient(initialNode: Record) { }; }), getNode: vi.fn(() => node), + cache, /** Test helper: update internal node and fire the subscribed listener. */ trigger: (newNode: Record) => { node = newNode; @@ -140,7 +150,7 @@ function captureDevtoolsEvents(): { events: CustomEvent[]; stop: () = // Tests // --------------------------------------------------------------------------- -describe('attachDevToolsBridge', () => { +describe('attachDaVinciBridge', () => { beforeEach(() => { // Simulate extension presence for all tests except the no-op test. (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; @@ -154,7 +164,7 @@ describe('attachDevToolsBridge', () => { it('returns a BridgeHandle with a detach function', () => { const client = makeClient({ status: 'start' }); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); expect(handle).toHaveProperty('detach'); expect(typeof handle.detach).toBe('function'); @@ -166,7 +176,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); // Trigger a status transition. client.trigger({ status: 'continue' }); @@ -202,7 +212,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); client.trigger(continueNode); handle.detach(); @@ -228,7 +238,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); // First trigger sets previousStatus = 'start'. client.trigger({ status: 'start' }); @@ -249,7 +259,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); // Verify subscribe was wired up. expect(client.subscribe).toHaveBeenCalledTimes(1); @@ -268,7 +278,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client, { + const handle = attachDaVinciBridge(client, { clientId: 'my-app', redirectUri: 'https://app.example.com/callback', }); @@ -293,7 +303,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client, { clientId: 'my-app' }); + const handle = attachDaVinciBridge(client, { clientId: 'my-app' }); client.trigger({ status: 'continue' }); client.trigger({ status: 'success' }); @@ -309,7 +319,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); client.trigger({ status: 'continue' }); @@ -327,7 +337,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); // subscribe is called (the bridge still subscribes), but no events should be dispatched. expect(client.subscribe).toHaveBeenCalledTimes(1); @@ -346,7 +356,7 @@ describe('attachDevToolsBridge', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client, { clientId: 'my-app' }); + const handle = attachDaVinciBridge(client, { clientId: 'my-app' }); client.trigger({ status: 'continue' }); handle.detach(); @@ -360,7 +370,7 @@ describe('attachDevToolsBridge', () => { }); }); -describe('attachDevToolsBridge session tracking', () => { +describe('attachDaVinciBridge session tracking', () => { beforeEach(() => { (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; localStorage.clear(); @@ -380,7 +390,7 @@ describe('attachDevToolsBridge session tracking', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); // Trigger a node transition, then mutate storage in the same tick client.trigger({ status: 'continue' }); @@ -410,7 +420,7 @@ describe('attachDevToolsBridge session tracking', () => { const client = makeClient({ status: 'start' }); const { events, stop } = captureDevtoolsEvents(); - const handle = attachDevToolsBridge(client); + const handle = attachDaVinciBridge(client); client.trigger({ status: 'continue' }); await new Promise((r) => setTimeout(r, 10)); @@ -423,4 +433,116 @@ describe('attachDevToolsBridge session tracking', () => { ); expect(sessionEvents).toHaveLength(0); }); + + it('emits session:cookie event when document.cookie changes after a node transition', async () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDaVinciBridge(client); + + client.trigger({ status: 'continue' }); + // Mutate cookie after the transition in the same tick + document.cookie = 'sid=new-session-id'; + + await new Promise((r) => setTimeout(r, 10)); + + handle.detach(); + stop(); + + const cookieEvents = events.filter((e) => e.detail.type === 'session:cookie'); + expect(cookieEvents).toHaveLength(1); + const data = cookieEvents[0].detail.data as { + _tag: string; + key: string; + before?: string; + after?: string; + }; + expect(data._tag).toBe('session'); + expect(data.key).toBe('document.cookie'); + expect(data.before).toBeUndefined(); + expect(data.after).toBe('sid=new-session-id'); + }); + + it('emits separate session:storage events for multiple keys changing at once', async () => { + const client = makeClient({ status: 'start' }); + const { events, stop } = captureDevtoolsEvents(); + + // Pre-populate a key that will be removed + localStorage.setItem('old-key', 'old-value'); + + const handle = attachDaVinciBridge(client); + + // First transition to capture the initial snapshot (which includes old-key) + client.trigger({ status: 'continue' }); + await new Promise((r) => setTimeout(r, 10)); + + // Now mutate multiple keys and trigger another transition + localStorage.setItem('new-key', 'new-value'); + localStorage.setItem('old-key', 'changed-value'); + localStorage.setItem('another-key', 'another-value'); + + client.trigger({ status: 'success' }); + await new Promise((r) => setTimeout(r, 10)); + + handle.detach(); + stop(); + + const storageEvents = events.filter((e) => e.detail.type === 'session:storage'); + const changedKeys = storageEvents.map((e) => (e.detail.data as { key: string }).key); + + expect(changedKeys).toContain('new-key'); + expect(changedKeys).toContain('old-key'); + expect(changedKeys).toContain('another-key'); + expect(storageEvents).toHaveLength(3); + }); +}); + +describe('attachDaVinciBridge cache passthrough', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(async () => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + await new Promise((r) => setTimeout(r, 10)); + }); + + it('passes cached response body through to the emitted SdkData', () => { + const cachedResponse = { access_token: 'tok-xyz', token_type: 'Bearer' }; + const cache = { getCache: vi.fn(() => cachedResponse) }; + const client = makeClient({ status: 'start' }, cache); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDaVinciBridge(client); + client.trigger({ + status: 'continue', + cache: { key: 'req-42' }, + server: { interactionId: 'iid-1' }, + }); + + handle.detach(); + stop(); + + expect(cache.getCache).toHaveBeenCalledWith('req-42'); + expect(events).toHaveLength(1); + const data = events[0].detail.data as { responseBody?: unknown }; + expect(data.responseBody).toBe(cachedResponse); + }); + + it('does not call getCache when node has no cache key', () => { + const cache = { getCache: vi.fn() }; + const client = makeClient({ status: 'start' }, cache); + const { events, stop } = captureDevtoolsEvents(); + + const handle = attachDaVinciBridge(client); + client.trigger({ status: 'continue' }); + + handle.detach(); + stop(); + + expect(cache.getCache).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + const data = events[0].detail.data as { responseBody?: unknown }; + expect(data.responseBody).toBeUndefined(); + }); }); diff --git a/packages/devtools-bridge/src/lib/bridge.ts b/packages/devtools-bridge/src/lib/davinci-bridge.ts similarity index 76% rename from packages/devtools-bridge/src/lib/bridge.ts rename to packages/devtools-bridge/src/lib/davinci-bridge.ts index e9c20d1..3276ee5 100644 --- a/packages/devtools-bridge/src/lib/bridge.ts +++ b/packages/devtools-bridge/src/lib/davinci-bridge.ts @@ -1,8 +1,8 @@ import { Schema, Option, pipe } from 'effect'; import type { SdkData } from '@wolfcola/devtools-types'; import { SdkErrorSchema, SdkAuthorizationSchema } from '@wolfcola/devtools-types'; -import { emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; -import type { DevtoolsOptions } from './emit.js'; +import { emitAuthEvent, emitConfigEvent } from './emit.js'; +import type { BridgeHandle, DevtoolsOptions } from './emit.js'; interface Subscribable { subscribe: (listener: () => void) => () => void; @@ -12,10 +12,6 @@ interface Subscribable { }; } -export interface BridgeHandle { - detach: () => void; -} - export interface SdkConfig { clientId?: string; redirectUri?: string; @@ -95,34 +91,48 @@ interface SessionSnapshot { function snapshotSession(): SessionSnapshot { const storage: Record = {}; - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i); - if (k) storage[k] = localStorage.getItem(k) ?? ''; + try { + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k) storage[k] = localStorage.getItem(k) ?? ''; + } + } catch { + // localStorage access blocked (e.g. privacy mode, opaque origin) + } + let cookie = ''; + try { + cookie = document.cookie; + } catch { + // cookie access blocked } - return { cookie: document.cookie, storage }; + return { cookie, storage }; } function emitSessionDiffs( before: SessionSnapshot, after: SessionSnapshot, flowId: string | null, + options?: DevtoolsOptions, ): void { if (before.cookie !== after.cookie) { - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'session:cookie', - source: 'session', - flowId, - causedBy: null, - data: { - _tag: 'session', - key: 'document.cookie', - before: before.cookie || undefined, - after: after.cookie || undefined, + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'session:cookie', + source: 'session', + flowId, + causedBy: null, + data: { + _tag: 'session', + key: 'document.cookie', + before: before.cookie || undefined, + after: after.cookie || undefined, + }, + flags: { isCors: false, isError: false, isAuthRelated: true }, }, - flags: { isCors: false, isError: false, isAuthRelated: true }, - }); + options, + ); } const allKeys = new Set([...Object.keys(before.storage), ...Object.keys(after.storage)]); @@ -130,16 +140,19 @@ function emitSessionDiffs( const beforeVal = before.storage[key]; const afterVal = after.storage[key]; if (beforeVal !== afterVal) { - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'session:storage', - source: 'session', - flowId, - causedBy: null, - data: { _tag: 'session', key, before: beforeVal, after: afterVal }, - flags: { isCors: false, isError: false, isAuthRelated: true }, - }); + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'session:storage', + source: 'session', + flowId, + causedBy: null, + data: { _tag: 'session', key, before: beforeVal, after: afterVal }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }, + options, + ); } } } @@ -148,21 +161,24 @@ function emitSessionDiffs( // Event builders // --------------------------------------------------------------------------- -function emitNodeChange(data: SdkData): void { - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'sdk:node-change', - source: 'sdk', - flowId: data.interactionId ?? null, - causedBy: null, - data, - flags: { - isCors: false, - isError: data.nodeStatus === 'error' || data.nodeStatus === 'failure', - isAuthRelated: true, +function emitNodeChange(data: SdkData, options?: DevtoolsOptions): void { + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:node-change', + source: 'sdk', + flowId: data.interactionId ?? null, + causedBy: null, + data, + flags: { + isCors: false, + isError: data.nodeStatus === 'error' || data.nodeStatus === 'failure', + isAuthRelated: true, + }, }, - }); + options, + ); } // --------------------------------------------------------------------------- @@ -178,7 +194,7 @@ function emitNodeChange(data: SdkData): void { * * Returns a no-op handle when run outside a browser. Always call `detach()` on cleanup. */ -export function attachDevToolsBridge( +export function attachDaVinciBridge( client: Subscribable, config?: object, devtoolsOptions?: DevtoolsOptions, @@ -187,10 +203,6 @@ export function attachDevToolsBridge( return { detach: () => undefined }; } - if (devtoolsOptions) { - configureDevtools(devtoolsOptions); - } - let previousStatus: string | undefined; let configEmitted = false; let lastSnapshot: SessionSnapshot = snapshotSession(); @@ -212,14 +224,19 @@ export function attachDevToolsBridge( Option.map((data) => { if (config && !configEmitted) { configEmitted = true; - emitConfigEvent(config); + emitConfigEvent(config, devtoolsOptions); } - emitNodeChange(data); + emitNodeChange(data, devtoolsOptions); // Snapshot before deferring so mutations in the same call stack are captured. const snapshotBefore = lastSnapshot; setTimeout(() => { const snapshotAfter = snapshotSession(); - emitSessionDiffs(snapshotBefore, snapshotAfter, data.interactionId ?? null); + emitSessionDiffs( + snapshotBefore, + snapshotAfter, + data.interactionId ?? null, + devtoolsOptions, + ); lastSnapshot = snapshotAfter; }, 0); }), diff --git a/packages/devtools-bridge/src/lib/emit.test.ts b/packages/devtools-bridge/src/lib/emit.test.ts index da18615..ee2cb58 100644 --- a/packages/devtools-bridge/src/lib/emit.test.ts +++ b/packages/devtools-bridge/src/lib/emit.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { AuthEvent } from '@wolfcola/devtools-types'; -import { DEVTOOLS_EVENT_NAME, emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; +import { DEVTOOLS_EVENT_NAME, emitAuthEvent, emitConfigEvent } from './emit.js'; // Minimal valid AuthEvent fixture — _tag: 'sdk' satisfies the SdkDataSchema discriminant. const makeEvent = (overrides: Partial = {}): AuthEvent => ({ @@ -24,8 +24,6 @@ const makeEvent = (overrides: Partial = {}): AuthEvent => ({ describe('emitAuthEvent', () => { beforeEach(() => { - // Reset options between tests by calling configureDevtools with defaults - configureDevtools({}); delete window.__PING_DEVTOOLS_STATE__; }); @@ -83,18 +81,16 @@ describe('emitAuthEvent', () => { }); }); -describe('configureDevtools', () => { +describe('emitAuthEvent options', () => { beforeEach(() => { - configureDevtools({}); delete window.__PING_DEVTOOLS_STATE__; }); it('enables console logging when consoleLog is true', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - configureDevtools({ consoleLog: true }); const event = makeEvent(); - emitAuthEvent(event); + emitAuthEvent(event, { consoleLog: true }); expect(spy).toHaveBeenCalledOnce(); expect(spy).toHaveBeenCalledWith('[ping-devtools]', event.type, event); @@ -105,8 +101,7 @@ describe('configureDevtools', () => { it('does not console.log when consoleLog is false', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - configureDevtools({ consoleLog: false }); - emitAuthEvent(makeEvent()); + emitAuthEvent(makeEvent(), { consoleLog: false }); expect(spy).not.toHaveBeenCalled(); @@ -116,7 +111,6 @@ describe('configureDevtools', () => { it('does not console.log by default (no options)', () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - configureDevtools({}); emitAuthEvent(makeEvent()); expect(spy).not.toHaveBeenCalled(); @@ -125,9 +119,36 @@ describe('configureDevtools', () => { }); }); +describe('emitAuthEvent ring buffer', () => { + beforeEach(() => { + delete window.__PING_DEVTOOLS_STATE__; + }); + + it('caps __PING_DEVTOOLS_STATE__ at 500 entries and drops the oldest', () => { + for (let i = 0; i < 501; i++) { + emitAuthEvent(makeEvent({ id: `evt-${i}` })); + } + + expect(window.__PING_DEVTOOLS_STATE__).toHaveLength(500); + // The first event (evt-0) should have been evicted + expect(window.__PING_DEVTOOLS_STATE__![0].id).toBe('evt-1'); + // The last event should be the most recent + expect(window.__PING_DEVTOOLS_STATE__![499].id).toBe('evt-500'); + }); + + it('continues to evict as more events arrive beyond the cap', () => { + for (let i = 0; i < 510; i++) { + emitAuthEvent(makeEvent({ id: `evt-${i}` })); + } + + expect(window.__PING_DEVTOOLS_STATE__).toHaveLength(500); + expect(window.__PING_DEVTOOLS_STATE__![0].id).toBe('evt-10'); + expect(window.__PING_DEVTOOLS_STATE__![499].id).toBe('evt-509'); + }); +}); + describe('emitConfigEvent', () => { beforeEach(() => { - configureDevtools({}); delete window.__PING_DEVTOOLS_STATE__; }); diff --git a/packages/devtools-bridge/src/lib/emit.ts b/packages/devtools-bridge/src/lib/emit.ts index 79a9ae5..e6166c2 100644 --- a/packages/devtools-bridge/src/lib/emit.ts +++ b/packages/devtools-bridge/src/lib/emit.ts @@ -6,42 +6,51 @@ export interface DevtoolsOptions { consoleLog?: boolean; } +export interface BridgeHandle { + detach: () => void; +} + declare global { interface Window { __PING_DEVTOOLS_STATE__?: AuthEvent[]; } } -let options: DevtoolsOptions = {}; +const MAX_STATE_ENTRIES = 500; -export function configureDevtools(opts: DevtoolsOptions): void { - options = opts; -} - -export function emitAuthEvent(event: AuthEvent): void { +export function emitAuthEvent(event: AuthEvent, options?: DevtoolsOptions): void { if (typeof window === 'undefined') return; if (!window.__PING_DEVTOOLS_STATE__) { window.__PING_DEVTOOLS_STATE__ = []; } window.__PING_DEVTOOLS_STATE__.push(event); + if (window.__PING_DEVTOOLS_STATE__.length > MAX_STATE_ENTRIES) { + window.__PING_DEVTOOLS_STATE__.splice( + 0, + window.__PING_DEVTOOLS_STATE__.length - MAX_STATE_ENTRIES, + ); + } - if (options.consoleLog) { + if (options?.consoleLog) { console.log('[ping-devtools]', event.type, event); } window.dispatchEvent(new CustomEvent(DEVTOOLS_EVENT_NAME, { detail: event })); } -export function emitConfigEvent(config: object): void { - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'sdk:config', - source: 'sdk', - flowId: null, - causedBy: null, - data: { _tag: 'sdk-config', config }, - flags: { isCors: false, isError: false, isAuthRelated: true }, - }); +export function emitConfigEvent(config: object, options?: DevtoolsOptions): void { + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:config', + source: 'sdk', + flowId: null, + causedBy: null, + data: { _tag: 'sdk-config', config }, + flags: { isCors: false, isError: false, isAuthRelated: true }, + }, + options, + ); } diff --git a/packages/devtools-bridge/src/lib/journey-bridge.ts b/packages/devtools-bridge/src/lib/journey-bridge.ts index 7c36e1b..b15c7c1 100644 --- a/packages/devtools-bridge/src/lib/journey-bridge.ts +++ b/packages/devtools-bridge/src/lib/journey-bridge.ts @@ -1,12 +1,8 @@ import { Schema, Option, pipe } from 'effect'; -import { emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; -import type { DevtoolsOptions } from './emit.js'; +import { emitAuthEvent, emitConfigEvent } from './emit.js'; +import type { BridgeHandle, DevtoolsOptions } from './emit.js'; import type { JourneyData } from '@wolfcola/devtools-types'; -export interface JourneyBridgeHandle { - detach: () => void; -} - interface JourneySubscribable { subscribe: (listener: () => void) => () => void; getState: () => unknown; @@ -104,15 +100,11 @@ export function attachJourneyBridge( client: JourneySubscribable, config?: object, devtoolsOptions?: DevtoolsOptions, -): JourneyBridgeHandle { +): BridgeHandle { if (typeof window === 'undefined') { return { detach: () => undefined }; } - if (devtoolsOptions) { - configureDevtools(devtoolsOptions); - } - let configEmitted = false; let emittedRequests = new Set(); @@ -137,43 +129,49 @@ export function attachJourneyBridge( emittedRequests.add(requestId); if (config && !configEmitted) { - emitConfigEvent(config); + emitConfigEvent(config, devtoolsOptions); configEmitted = true; } if (entry.status === 'fulfilled') { const journeyData = stepPayloadToJourneyData(entry.data); if (!journeyData) return; - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'sdk:journey-step', - source: 'sdk', - flowId: null, - causedBy: null, - data: journeyData, - flags: { - isCors: false, - isError: journeyData.stepType === 'LoginFailure', - isAuthRelated: true, + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:journey-step', + source: 'sdk', + flowId: null, + causedBy: null, + data: journeyData, + flags: { + isCors: false, + isError: journeyData.stepType === 'LoginFailure', + isAuthRelated: true, + }, }, - }); + devtoolsOptions, + ); } else { const journeyData: JourneyData = { _tag: 'journey', stepType: 'LoginFailure', errorMessage: extractErrorMessage(entry.error), }; - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'sdk:journey-step', - source: 'sdk', - flowId: null, - causedBy: null, - data: journeyData, - flags: { isCors: false, isError: true, isAuthRelated: true }, - }); + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:journey-step', + source: 'sdk', + flowId: null, + causedBy: null, + data: journeyData, + flags: { isCors: false, isError: true, isAuthRelated: true }, + }, + devtoolsOptions, + ); } }), ); diff --git a/packages/devtools-bridge/src/lib/multi-bridge.test.ts b/packages/devtools-bridge/src/lib/multi-bridge.test.ts new file mode 100644 index 0000000..bff6494 --- /dev/null +++ b/packages/devtools-bridge/src/lib/multi-bridge.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { attachDaVinciBridge } from './davinci-bridge.js'; +import { attachJourneyBridge } from './journey-bridge.js'; +import { attachOidcBridge } from './oidc-bridge.js'; +import { DEVTOOLS_EVENT_NAME } from './emit.js'; +import type { AuthEvent } from '@wolfcola/devtools-types'; + +// --------------------------------------------------------------------------- +// Mock client factories +// --------------------------------------------------------------------------- + +function makeDaVinciClient(initialNode: Record) { + let listener: (() => void) | null = null; + let node = initialNode; + return { + subscribe: vi.fn((cb: () => void) => { + listener = cb; + return () => { + listener = null; + }; + }), + getNode: vi.fn(() => node), + trigger: (newNode: Record) => { + node = newNode; + listener?.(); + }, + }; +} + +type JourneyState = { + journeyReducer: { + mutations: Record< + string, + { status: string; endpointName?: string; data?: unknown; error?: unknown } + >; + }; +}; + +function makeJourneyClient(initialState: JourneyState) { + let listener: (() => void) | null = null; + let state = initialState; + return { + subscribe: vi.fn((cb: () => void) => { + listener = cb; + return () => { + listener = null; + }; + }), + getState: vi.fn(() => state), + trigger: (newState: JourneyState) => { + state = newState; + listener?.(); + }, + }; +} + +type OidcState = { + oidc: { + mutations: Record< + string, + { status: string; endpointName?: string; data?: unknown; error?: unknown } + >; + }; +}; + +function makeOidcClient(initialState: OidcState) { + let listener: (() => void) | null = null; + let state = initialState; + return { + subscribe: vi.fn((cb: () => void) => { + listener = cb; + return () => { + listener = null; + }; + }), + getState: vi.fn(() => state), + trigger: (newState: OidcState) => { + state = newState; + listener?.(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function captureDevtoolsEvents(): { events: CustomEvent[]; stop: () => void } { + const events: CustomEvent[] = []; + const handler = (e: Event) => events.push(e as CustomEvent); + window.addEventListener(DEVTOOLS_EVENT_NAME, handler); + return { events, stop: () => window.removeEventListener(DEVTOOLS_EVENT_NAME, handler) }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('multi-bridge coexistence', () => { + beforeEach(() => { + (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__'] = true; + }); + + afterEach(async () => { + delete (window as unknown as Record)['__PING_DEVTOOLS_EXTENSION__']; + await new Promise((r) => setTimeout(r, 10)); + }); + + it('all three bridges emit events independently on the same event bus', () => { + const davinciClient = makeDaVinciClient({ status: 'start' }); + const journeyClient = makeJourneyClient({ journeyReducer: { mutations: {} } }); + const oidcClient = makeOidcClient({ oidc: { mutations: {} } }); + const { events, stop } = captureDevtoolsEvents(); + + const h1 = attachDaVinciBridge(davinciClient); + const h2 = attachJourneyBridge(journeyClient); + const h3 = attachOidcBridge(oidcClient); + + davinciClient.trigger({ status: 'continue' }); + journeyClient.trigger({ + journeyReducer: { + mutations: { + 'j-1': { status: 'fulfilled', data: { authId: 'abc' } }, + }, + }, + }); + oidcClient.trigger({ + oidc: { + mutations: { + 'o-1': { status: 'fulfilled', endpointName: 'exchange' }, + }, + }, + }); + + h1.detach(); + h2.detach(); + h3.detach(); + stop(); + + const types = events.map((e) => e.detail.type); + expect(types).toContain('sdk:node-change'); + expect(types).toContain('sdk:journey-step'); + expect(types).toContain('sdk:oidc-state'); + expect(events).toHaveLength(3); + }); + + it('detaching one bridge does not affect the others', () => { + const davinciClient = makeDaVinciClient({ status: 'start' }); + const journeyClient = makeJourneyClient({ journeyReducer: { mutations: {} } }); + const { events, stop } = captureDevtoolsEvents(); + + const h1 = attachDaVinciBridge(davinciClient); + const h2 = attachJourneyBridge(journeyClient); + + // Detach DaVinci bridge + h1.detach(); + + // DaVinci events should stop + davinciClient.trigger({ status: 'continue' }); + + // Journey events should still work + journeyClient.trigger({ + journeyReducer: { + mutations: { + 'j-1': { status: 'fulfilled', data: { authId: 'abc' } }, + }, + }, + }); + + h2.detach(); + stop(); + + expect(events).toHaveLength(1); + expect(events[0].detail.type).toBe('sdk:journey-step'); + }); + + it('each bridge emits its own sdk:config independently', () => { + const davinciClient = makeDaVinciClient({ status: 'start' }); + const journeyClient = makeJourneyClient({ journeyReducer: { mutations: {} } }); + const oidcClient = makeOidcClient({ oidc: { mutations: {} } }); + const { events, stop } = captureDevtoolsEvents(); + + const h1 = attachDaVinciBridge(davinciClient, { clientId: 'davinci-app' }); + const h2 = attachJourneyBridge(journeyClient, { realm: '/alpha' }); + const h3 = attachOidcBridge(oidcClient, { clientId: 'oidc-app' }); + + davinciClient.trigger({ status: 'continue' }); + journeyClient.trigger({ + journeyReducer: { + mutations: { + 'j-1': { status: 'fulfilled', data: { authId: 'abc' } }, + }, + }, + }); + oidcClient.trigger({ + oidc: { + mutations: { + 'o-1': { status: 'fulfilled', endpointName: 'exchange' }, + }, + }, + }); + + h1.detach(); + h2.detach(); + h3.detach(); + stop(); + + const configEvents = events.filter((e) => e.detail.type === 'sdk:config'); + expect(configEvents).toHaveLength(3); + + const configs = configEvents.map( + (e) => (e.detail.data as { _tag: string; config: Record }).config, + ); + expect(configs).toContainEqual({ clientId: 'davinci-app' }); + expect(configs).toContainEqual({ realm: '/alpha' }); + expect(configs).toContainEqual({ clientId: 'oidc-app' }); + }); +}); diff --git a/packages/devtools-bridge/src/lib/oidc-bridge.ts b/packages/devtools-bridge/src/lib/oidc-bridge.ts index 5d3cbf7..27e00b9 100644 --- a/packages/devtools-bridge/src/lib/oidc-bridge.ts +++ b/packages/devtools-bridge/src/lib/oidc-bridge.ts @@ -1,12 +1,8 @@ import { Schema, Option, pipe } from 'effect'; -import { emitAuthEvent, emitConfigEvent, configureDevtools } from './emit.js'; -import type { DevtoolsOptions } from './emit.js'; +import { emitAuthEvent, emitConfigEvent } from './emit.js'; +import type { BridgeHandle, DevtoolsOptions } from './emit.js'; import type { OidcData } from '@wolfcola/devtools-types'; -export interface OidcBridgeHandle { - detach: () => void; -} - interface OidcSubscribable { subscribe: (listener: () => void) => () => void; getState: () => unknown; @@ -99,15 +95,11 @@ export function attachOidcBridge( client: OidcSubscribable, config?: { clientId?: string } & object, devtoolsOptions?: DevtoolsOptions, -): OidcBridgeHandle { +): BridgeHandle { if (typeof window === 'undefined') { return { detach: () => undefined }; } - if (devtoolsOptions) { - configureDevtools(devtoolsOptions); - } - let configEmitted = false; let emittedRequests = new Set(); @@ -135,7 +127,7 @@ export function attachOidcBridge( emittedRequests.add(requestId); if (config && !configEmitted) { - emitConfigEvent(config); + emitConfigEvent(config, devtoolsOptions); configEmitted = true; } @@ -147,20 +139,23 @@ export function attachOidcBridge( ); if (!oidcData) return; - emitAuthEvent({ - id: crypto.randomUUID(), - timestamp: performance.now(), - type: 'sdk:oidc-state', - source: 'sdk', - flowId: null, - causedBy: null, - data: oidcData, - flags: { - isCors: false, - isError: oidcData.status === 'error', - isAuthRelated: true, + emitAuthEvent( + { + id: crypto.randomUUID(), + timestamp: performance.now(), + type: 'sdk:oidc-state', + source: 'sdk', + flowId: null, + causedBy: null, + data: oidcData, + flags: { + isCors: false, + isError: oidcData.status === 'error', + isAuthRelated: true, + }, }, - }); + devtoolsOptions, + ); }), ); } diff --git a/packages/devtools-core/src/annotators/cors-detector.test.ts b/packages/devtools-core/src/annotators/cors-detector.test.ts index ea610e5..073beae 100644 --- a/packages/devtools-core/src/annotators/cors-detector.test.ts +++ b/packages/devtools-core/src/annotators/cors-detector.test.ts @@ -69,9 +69,12 @@ describe('detectCorsFlags', () => { expect(flags.some((f: CorsFlag) => f.reason === 'wildcard-with-credentials')).toBe(true); }); - it('flags credentials mismatch when allow-credentials is false', () => { + it('flags credentials mismatch when request sends credentials and allow-credentials is false', () => { const entry = makeEntry({ - requestHeaders: { origin: 'https://app.example.com' }, + requestHeaders: { + origin: 'https://app.example.com', + cookie: 'session=abc', + }, responseHeaders: { 'access-control-allow-origin': 'https://app.example.com', 'access-control-allow-credentials': 'false', @@ -81,9 +84,12 @@ describe('detectCorsFlags', () => { expect(flags.some((f: CorsFlag) => f.reason === 'credentials-mismatch')).toBe(true); }); - it('flags credentials mismatch when allow-credentials header is absent', () => { + it('flags credentials mismatch when request sends authorization and allow-credentials is absent', () => { const entry = makeEntry({ - requestHeaders: { origin: 'https://app.example.com' }, + requestHeaders: { + origin: 'https://app.example.com', + authorization: 'Bearer token', + }, responseHeaders: { 'access-control-allow-origin': 'https://app.example.com', // no access-control-allow-credentials header @@ -92,4 +98,15 @@ describe('detectCorsFlags', () => { const flags = detectCorsFlags(entry); expect(flags.some((f: CorsFlag) => f.reason === 'credentials-mismatch')).toBe(true); }); + + it('does NOT flag credentials mismatch when request sends no credentials', () => { + const entry = makeEntry({ + requestHeaders: { origin: 'https://app.example.com' }, + responseHeaders: { + 'access-control-allow-origin': 'https://app.example.com', + }, + }); + const flags = detectCorsFlags(entry); + expect(flags.some((f: CorsFlag) => f.reason === 'credentials-mismatch')).toBe(false); + }); }); diff --git a/packages/devtools-core/src/annotators/cors-detector.ts b/packages/devtools-core/src/annotators/cors-detector.ts index ad3c1f0..bc15751 100644 --- a/packages/devtools-core/src/annotators/cors-detector.ts +++ b/packages/devtools-core/src/annotators/cors-detector.ts @@ -26,9 +26,11 @@ export function detectCorsFlags(entry: HarEntry): CorsFlag[] { flags.push({ url, method, reason: 'wildcard-with-credentials', allowOrigin, allowCredentials }); } + const requestSentCredentials = + !!headerValue(reqHeaders, 'cookie') || !!headerValue(reqHeaders, 'authorization'); const credentialsDenied = allowCredentials === 'false' || allowCredentials === undefined; - if (origin && allowOrigin && allowOrigin !== '*' && credentialsDenied) { + if (origin && allowOrigin && allowOrigin !== '*' && requestSentCredentials && credentialsDenied) { flags.push({ url, method, reason: 'credentials-mismatch', allowOrigin, allowCredentials }); } diff --git a/packages/devtools-core/src/annotators/oidc-annotator.test.ts b/packages/devtools-core/src/annotators/oidc-annotator.test.ts index b2890ab..4e65df0 100644 --- a/packages/devtools-core/src/annotators/oidc-annotator.test.ts +++ b/packages/devtools-core/src/annotators/oidc-annotator.test.ts @@ -92,7 +92,7 @@ describe('annotateOidc', () => { expect(result!.oidcPhase).toBe('token'); expect(result!.grantType).toBe('authorization_code'); expect(result!.clientId).toBe('app1'); - expect(result!.pkce).toEqual({ challengeMethod: 'S256', hasVerifier: true }); + expect(result!.pkce).toEqual({ hasVerifier: true }); expect(result!.tokens).toEqual({ accessToken: true, refreshToken: true, @@ -192,7 +192,7 @@ describe('annotateOidc', () => { expect(result).not.toBeNull(); expect(result!.grantType).toBe('authorization_code'); expect(result!.clientId).toBe('myapp'); - expect(result!.pkce).toEqual({ challengeMethod: 'S256', hasVerifier: true }); + expect(result!.pkce).toEqual({ hasVerifier: true }); }); it('handles authorize with no query params', () => { diff --git a/packages/devtools-core/src/annotators/oidc-annotator.ts b/packages/devtools-core/src/annotators/oidc-annotator.ts index 2d05dc7..5af05a7 100644 --- a/packages/devtools-core/src/annotators/oidc-annotator.ts +++ b/packages/devtools-core/src/annotators/oidc-annotator.ts @@ -142,8 +142,10 @@ function annotateToken(data: NetworkData, result: MutableOidcSemantics): void { const hasVerifier = !!formBody['code_verifier']; if (hasVerifier) { + // Only the authorize request knows the challenge method — the token + // request just sends the verifier. Leave challengeMethod undefined so + // downstream consumers don't see a misleading hard-coded value. result.pkce = { - challengeMethod: 'S256', hasVerifier: true, }; } diff --git a/packages/devtools-extension/src/background/event-store-chrome.ts b/packages/devtools-extension/src/background/event-store-chrome.ts index 36b15b8..3b3010b 100644 --- a/packages/devtools-extension/src/background/event-store-chrome.ts +++ b/packages/devtools-extension/src/background/event-store-chrome.ts @@ -25,8 +25,21 @@ export const EventStoreChromeLive = Layer.effect( Effect.tryPromise(() => chrome.storage.local.get('ping:auth-flow')), Effect.orDie, Effect.flatMap((result) => { - const stored = result['ping:auth-flow'] as ExtendedFlowState | undefined; - return stored ? Ref.set(stateRef, stored) : Effect.void; + const stored = result['ping:auth-flow']; + if ( + stored && + typeof stored === 'object' && + Array.isArray((stored as Record).events) + ) { + // Ensure required fields exist (handles schema evolution across versions) + const state = stored as Record; + const hydrated: ExtendedFlowState = { + ...makeEmptyFlowState(), + ...(state as unknown as ExtendedFlowState), + }; + return Ref.set(stateRef, hydrated); + } + return Effect.void; }), ), setOidcConfig: (config: OidcConfig) => diff --git a/packages/devtools-extension/src/background/service-worker.ts b/packages/devtools-extension/src/background/service-worker.ts index 7ba4ca4..9c3b9b9 100644 --- a/packages/devtools-extension/src/background/service-worker.ts +++ b/packages/devtools-extension/src/background/service-worker.ts @@ -9,19 +9,18 @@ import { import type { SerializableDiagnosisResult } from '@wolfcola/devtools-core'; const AppLayer = EventStoreChromeLive; -let runtime = ManagedRuntime.make(AppLayer); +const runtime = ManagedRuntime.make(AppLayer); -self.addEventListener('activate', () => { - runtime = ManagedRuntime.make(AppLayer); - runtime - .runPromise( - Effect.gen(function* () { - const store = yield* EventStoreService; - yield* store.rehydrate(); - }), - ) - .catch(console.error); -}); +// Rehydrate on every SW start-up (module evaluation runs each time +// Chrome wakes the service worker, unlike `activate` which fires once). +runtime + .runPromise( + Effect.gen(function* () { + const store = yield* EventStoreService; + yield* store.rehydrate(); + }), + ) + .catch(console.error); function broadcastToPanel(event: unknown, diagnosis: SerializableDiagnosisResult): void { chrome.runtime.sendMessage({ type: 'PANEL_EVENT', payload: event, diagnosis }).catch(() => { @@ -71,6 +70,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { }), ) .then(sendResponse) - .catch(console.error); + .catch((err) => { + console.error(err); + sendResponse(null); + }); return true; // keep channel open for async response }); diff --git a/packages/devtools-extension/src/content/relay.ts b/packages/devtools-extension/src/content/relay.ts index d2a4e81..dcb1124 100644 --- a/packages/devtools-extension/src/content/relay.ts +++ b/packages/devtools-extension/src/content/relay.ts @@ -1,7 +1,9 @@ // Runs in the isolated world — relays postMessage events to the service worker // via chrome.runtime, which is not available in the main world. window.addEventListener('message', (e) => { - if (e.source !== window || !(e.data as { __pingDevtools?: boolean })?.__pingDevtools) return; + if (e.source !== window) return; + if (e.origin !== window.location.origin) return; + if (!(e.data as { __pingDevtools?: boolean })?.__pingDevtools) return; chrome.runtime.sendMessage({ type: 'SDK_EVENT', payload: (e.data as { payload: unknown }).payload, diff --git a/packages/devtools-extension/src/panel/panel.ts b/packages/devtools-extension/src/panel/panel.ts index 98b585f..7cc325e 100644 --- a/packages/devtools-extension/src/panel/panel.ts +++ b/packages/devtools-extension/src/panel/panel.ts @@ -230,6 +230,8 @@ app.ports.loadSnapshot?.subscribe((snapshotId: string) => { chrome.runtime.sendMessage({ type: 'CLEAR' }); const state = snapshot.flowState; + if (!state || !Array.isArray(state.events)) return; + for (const event of state.events) { app.ports.receiveEvent.send(event); } diff --git a/packages/devtools-types/src/lib/auth-event.schema.ts b/packages/devtools-types/src/lib/auth-event.schema.ts index 61e23e6..f7be7ba 100644 --- a/packages/devtools-types/src/lib/auth-event.schema.ts +++ b/packages/devtools-types/src/lib/auth-event.schema.ts @@ -136,7 +136,7 @@ export const OidcDataSchema = Schema.Struct({ }); export const OidcPkceSchema = Schema.Struct({ - challengeMethod: Schema.String, + challengeMethod: Schema.optional(Schema.String), hasVerifier: Schema.Boolean, }); diff --git a/packages/devtools-ui/package.json b/packages/devtools-ui/package.json index 2803a3a..dd0afbf 100644 --- a/packages/devtools-ui/package.json +++ b/packages/devtools-ui/package.json @@ -10,9 +10,9 @@ "./panel.css": "./dist/panel.css", "./panel.html": "./dist/panel.html", "./ports": { - "types": "./src/ports.d.ts", - "import": "./src/ports.js", - "default": "./src/ports.js" + "types": "./src/ports.ts", + "import": "./src/ports.ts", + "default": "./src/ports.ts" }, "./package.json": "./package.json" }, diff --git a/packages/vscode-extension/src/cdp/cdp-client.ts b/packages/vscode-extension/src/cdp/cdp-client.ts index 5bb9b8d..cd75ee0 100644 --- a/packages/vscode-extension/src/cdp/cdp-client.ts +++ b/packages/vscode-extension/src/cdp/cdp-client.ts @@ -80,9 +80,15 @@ export class CdpClient extends EventEmitter { disconnect(): void { this.ws?.close(); this.ws = null; + // Reject any pending RPC calls so their promises don't leak + for (const [id, cb] of this.pendingCalls) { + cb(undefined, 'Disconnected'); + this.pendingCalls.delete(id); + } + this.pendingRequests.clear(); } - private send(method: string, params?: Record): Promise { + send(method: string, params?: Record): Promise { return new Promise((resolve, reject) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { reject(new Error('WebSocket is not connected')); diff --git a/packages/vscode-extension/src/extension.ts b/packages/vscode-extension/src/extension.ts index 9008d66..8e1d97a 100644 --- a/packages/vscode-extension/src/extension.ts +++ b/packages/vscode-extension/src/extension.ts @@ -19,11 +19,13 @@ import type { HarEntry } from '@wolfcola/devtools-core'; import type { AuthEvent, FlowState } from '@wolfcola/devtools-types'; let cdpClient: CdpClient | null = null; +let activeRuntime: { dispose: () => Promise } | null = null; export function activate(context: vscode.ExtensionContext): void { const timeline = new TimelineTreeProvider(); const statusBar = new StatusBar(); const runtime = ManagedRuntime.make(EventStoreInMemory); + activeRuntime = runtime; vscode.window.registerTreeDataProvider('oidc-devtools.timeline', timeline); @@ -186,4 +188,9 @@ export function activate(context: vscode.ExtensionContext): void { export function deactivate(): void { cdpClient?.disconnect(); + cdpClient = null; + if (activeRuntime) { + void activeRuntime.dispose(); + activeRuntime = null; + } } diff --git a/packages/vscode-extension/tsconfig.lib.json b/packages/vscode-extension/tsconfig.lib.json index 3dbf966..ceaec26 100644 --- a/packages/vscode-extension/tsconfig.lib.json +++ b/packages/vscode-extension/tsconfig.lib.json @@ -15,7 +15,7 @@ "lib": ["es2022"] }, "include": ["src/**/*.ts"], - "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/webview/**/*.ts"], "references": [ { "path": "../devtools-types/tsconfig.lib.json" }, { "path": "../devtools-core/tsconfig.lib.json" } diff --git a/tsconfig.json b/tsconfig.json index a253ab2..7af400e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,8 @@ "references": [ { "path": "packages/devtools-types" }, { "path": "packages/devtools-bridge" }, - { "path": "packages/devtools-extension" } + { "path": "packages/devtools-core" }, + { "path": "packages/devtools-extension" }, + { "path": "packages/vscode-extension" } ] }