diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5a2001ba..6c68408f 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -82,6 +82,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit - `createUiBreadcrumbSource` / `createUiBreadcrumbProvider` (**UI breadcrumbs**, 2026-08-31): the state-change half of the DOM interaction split — capture-phase/passive listeners for `change`/`submit`/`focusin` → `client.addBreadcrumb` (so the app's `breadcrumbFilter` runs) in Android `BreadcrumbInputGesture`'s shape: `{type:'user', category:'ui.change'|'ui.submit'|'ui.focus', level:'info', data:{'view.id','view.class','view.tag'}, timestamp: the DOM event's own moment (`performance.timeOrigin + Event.timeStamp`, clamped to now)}`. `type:'user'` is Android's literal for a user-ORIGINATED breadcrumb — **not** the `*.user` stream (these are `breadcrumbs` entries). Why not `input`: `InputEvent.type` is Android's `InputEventStage` (`unknown|begin|move|end|scroll|keydown|keyup`), which a DOM-only value cannot join, and Android already splits its input dispatcher (→ `input.json`) from its gesture dispatcher (→ breadcrumbs). Reuses `describeTarget`; a sensitive/app-masked target **drops the breadcrumb outright** (the focus/change rhythm on a masked field is itself a side channel). Gated by `captureInteractions` (the option means "do not watch what I click and type", whatever stream it lands on). Wired into `@bugsee/browser` **and** `@bugsee/webview` (which also now declares `breadcrumbs` in `hello.caps`). - `createDomSnapshot` / `createViewtreeSnapshotSource` (view hierarchy: an at-report DOM-tree snapshot → `viewtree`, via the core `reportSnapshots` pull-seam) — reuses `describeTarget` per node + rounded `getBoundingClientRect`; masked subtrees collapse to `{tag, masked, rect}`; bounded (maxNodes/maxDepth) + per-node throw-isolated. Gated by `captureViewHierarchy`. See §7. - **`client.showReportDialog(options?)`** (2026-10-11 → `docs/design/report-dialog.md`): the web counterpart of the mobile `showReportDialog()` — a built-in bug-report form shown as a modal over a shaded page, sent as a `code_dialog` report through the new core **`client.upload(options)`** (Android `Bugsee.upload`: not deduped or rate-limited, still runs the report handler). Same fields/order/strings/colours/validation as mobile (summary, description, severity, labels, email, `v` footer); the reporting-UI launch options are Android's canonical `com.bugsee.option.reporting.ui.*` keys with the same defaults (`reportSummaryRequired`, `reportDescriptionRequired`, `reportEmailRequired`, `reportLabelsEnabled`, `reportLabelsRequired`, `reportPrioritySelectorEnabled`, `reportDefaultBugPriority`). Built with DOM APIs only inside ONE shadow root on ONE host element (page CSS cannot reach it; user text is never parsed as HTML) and shown with `.showModal()` so it sits in the TOP LAYER — above any z-index and above a modal the page already has open (verified in real Chromium); a fixed max-z-index overlay is the fallback. Esc / a click on the shading is a cancel with a discard prompt for an edited form; focus and scroll are restored on close; a changed email is remembered as the user identifier. **No screenshot in v1** (the report already carries the DOM snapshot). Resolves `null` on cancel / already open / not running / no DOM. Tested under jsdom (`report-dialog.test.ts`, mutation-checked) and end to end through a real `launch()` (`report-dialog.launch.test.ts`). +- **User identifier persistence (T9, browser tier only):** iOS persists `setUserIdentifier` across launches (Android does not); the browser now does too. `setUserIdentifier` writes to `localStorage` (`bugsee.user_identifier.`), `clearUserIdentifier` removes it, and launch restores it SYNCHRONOUSLY right after the client is created and before any provider, service manifest or report, so the first report already carries it and anything the app sets afterwards simply overwrites it (`user-identifier-store.ts`, wired in `launch.ts`; the report dialog uses the same persisting setters). Deliberately NOT the multi-store device-id machinery: an email is PII, so exactly one first-party store, never a cookie (sent to the server on every request), and "clear site data" forgets it. All storage access is try/catch: failures go to `onError` and the in-memory Environment stays authoritative. Cross-tab is last-writer-wins, picked up at next launch (no live `storage`-event sync). Opt out with `persistUserIdentifier: false` (canonical `com.bugsee.option.user.persist-identifier`, default true as on iOS), which also ignores an already-stored value. Node/Bun/Deno/server tiers keep the identifier in memory by design (a server process's identifier tied to disk would be wrong). Tests: `user-identifier-store.test.ts`, `user-identifier.launch.test.ts`, real-Chromium `instrumentation-tests/test/user-identifier.e2e.ts` (set -> reload, clear -> reload, opt-out). - **Mobile-parity options (T6, `docs/design/mobile-api-alignment.md`):** `captureLogsLevel`, `captureBreadcrumbs` (JS default stays `true`), `detectHttpErrors` (`createHttpErrorDetectionProvider` in `@bugsee/capture`, mechanism `http-error`, own rate budget), `captureNetworkDefaultSanitizer`, `reportDefaultCrashPriority` / `reportDefaultErrorPriority` (applied in the client before the report handler, only to severities that were derived), and `getDefaultNetworkSanitizer()` for composing with a custom network filter. - **`launch(appToken, options)`** — the browser composition root (fetch/DOM analog of node's): fetch transport (internal-tagged), api/uploader/upload pipeline, browser env, in-memory store (or IndexedDB-backed when `persist:true`), gated capture providers (console→log; network umbrella, NO `node:http`; system traces; system events; user-interaction input) + detection providers, `client.launch()`. **No `process.exit` path** (the browser flushes via the pipeline/`pagehide`; `stop()` only clears the carrier). `persist:true` builds an IndexedDB durable bundle queue (crash recovery across reload — `recover()` deferred to the store's `whenReady`) + the durable IndexedDB chunk capture store (`createIdbChunkCaptureStore` in its own `bugsee-capture` db). `maxDataSize` defaults to 10 MB. **Session replay is ON by default** (2026-08-27) — parity with the iOS/Android SDKs, which record by default; `replay: false` is the opt-out and is the ONLY value on which `import('@bugsee/replay')` is never evaluated, so an errors-only integration still pays nothing for rrweb (the lazy chunk + the opt-out carry the bundle-size budget, not the default). Masking stays fail-closed on the default path (mask all text/inputs, block all media); `replay.canvas` stays opt-in. Returns the started `BugseeClient`. diff --git a/docs/design/report-dialog.md b/docs/design/report-dialog.md index 28241274..925ba6e9 100644 --- a/docs/design/report-dialog.md +++ b/docs/design/report-dialog.md @@ -74,8 +74,10 @@ Canonical `com.bugsee.option.reporting.*` keys, defaults equal to mobile (all of presses Enter after the summary does not send a half-written report. An IME's Enter is left alone. - **No email format check.** The "email" is the app's user identifier, which is often not an address; neither mobile SDK validates it. Erasing it clears the identifier (iOS `clearEmail`), a changed value is - remembered for the session. (The mobile SDKs also persist it across launches; the JS identifier is held - in memory only today, so it is not persisted across page loads.) + remembered as the user identifier. The identifier is persisted across page loads like the mobile SDKs + (browser tier, `localStorage`, per app token; see PROGRESS.md "User identifier persistence"), so the + next page load pre-fills the field. The dialog goes through the same persisting setters. It is PII at + rest: `persistUserIdentifier: false` keeps it in memory only. - **A browser-initiated close is a cancel.** Chrome makes a second Escape non-cancellable; the dialog's `close` listener tears everything down (host, scroll lock, launcher state) instead of leaving the page locked. diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index ef7ee8f6..6ee6b066 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -83,6 +83,7 @@ import { parseStack } from './stack'; import { createBrowserSystemEventsSource } from './system-events'; import { createBrowserSystemTracesSampler } from './system-metrics'; import { createUiBreadcrumbProvider, createUiBreadcrumbSource } from './ui-breadcrumb-source'; +import { createUserIdentifierStore } from './user-identifier-store'; import { createBrowserViewportSource } from './viewport-source'; import { createViewtreeSnapshotSource } from './viewtree'; @@ -142,6 +143,9 @@ const BROWSER_OPTION_DEFINITIONS = [ key: BugseeOption.ReportingDefaultBugPriority, default: 'high', }, + // iOS persists the user identifier across launches; the browser does too (localStorage, per app token). + // Browser-only: server tiers keep it in memory. Opt out when the identifier is PII not to be stored. + { friendly: 'persistUserIdentifier', key: BugseeOption.PersistUserIdentifier, default: true }, ]; /** Session-replay options (a structural subset of `@bugsee/replay`'s options — no runtime dep here; replay @@ -234,6 +238,13 @@ export interface BugseeLaunchOptions { reportPrioritySelectorEnabled?: boolean; /** Severity a dialog report starts with: `low | medium | high | critical | blocker`. Default `high`. */ reportDefaultBugPriority?: SeverityName; + /** + * Persist the user identifier (`setUserIdentifier`) in `localStorage` — one entry per app token — and + * restore it on the next page load, before any report, as the iOS SDK does across app launches. + * `clearUserIdentifier()` removes it. Default true; pass `false` to keep the identifier (often an email, + * i.e. PII) in memory only. Storage failures never break the SDK; they go to `onError`. + */ + persistUserIdentifier?: boolean; /** Detect window errors + unhandled rejections. Default true. */ detectCrashes?: boolean; /** Report failed fetch/XHR exchanges (status >= 400, or no response) as `error` reports. Default false. */ @@ -605,6 +616,20 @@ async function buildLaunch( clientRef = client; + // Restore the persisted user identifier NOW — synchronously, before any provider starts, any service + // manifest runs or the client is handed back — so the first report already carries it and an identifier + // the app (or a manifest) sets afterwards simply overwrites it. Skipped entirely when opted out. + const identifierStore = resolved.options.get(BugseeOption.PersistUserIdentifier, true) + ? createUserIdentifierStore(appToken, { + storage: options.deviceIdEnv?.localStorage, + ...(options.onError !== undefined ? { onError: options.onError } : {}), + }) + : undefined; + const restoredIdentifier = identifierStore?.read(); + if (restoredIdentifier !== null && restoredIdentifier !== undefined) { + client.setUserIdentifier(restoredIdentifier); + } + // Run every contributed service manifest against the now-populated container. for (const manifest of getServiceManifests(carrier)) { manifest(client); @@ -818,14 +843,24 @@ async function buildLaunch( ), ); } + // The public setters: update the Environment (authoritative, in memory), then persist best-effort. The + // report dialog goes through these too, so what the reporter types survives a reload. + const setUserIdentifier = (id: string): void => { + client.setUserIdentifier(id); + identifierStore?.write(id); + }; + const clearUserIdentifier = (): void => { + client.clearUserIdentifier(); + identifierStore?.clear(); + }; const showReportDialog = createReportDialogLauncher({ // No DOM (a worker, SSR) → there is nothing to draw on; the launcher then resolves null. document: domDocument as Document, prepare: () => asInternalClient(client).prepareUpload(), isLaunched: () => domDocument !== undefined && client.isLaunched(), getUserIdentifier: () => client.getUserIdentifier(), - setUserIdentifier: (email) => client.setUserIdentifier(email), - clearUserIdentifier: () => client.clearUserIdentifier(), + setUserIdentifier: (email) => setUserIdentifier(email), + clearUserIdentifier: () => clearUserIdentifier(), sdkVersion: SDK_VERSION, emitLifecycleEvent: (eventType, data) => client.emitLifecycleEvent(eventType, data), ui: { @@ -840,6 +875,8 @@ async function buildLaunch( }); const publicClient: Bugsee = { ...client, + setUserIdentifier, + clearUserIdentifier, showReportDialog, // Android applies `ReportingDefaultBugPriority` to every bug report, not only the dialog's. upload: (uploadOptions) => diff --git a/packages/browser/src/user-identifier-store.test.ts b/packages/browser/src/user-identifier-store.test.ts new file mode 100644 index 00000000..0d771384 --- /dev/null +++ b/packages/browser/src/user-identifier-store.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createUserIdentifierStore, + USER_IDENTIFIER_KEY_PREFIX, + type UserIdentifierStorage, +} from './user-identifier-store'; + +function memoryStorage(initial: Record = {}): UserIdentifierStorage & { + data: Map; +} { + const data = new Map(Object.entries(initial)); + return { + data, + getItem: (k) => data.get(k) ?? null, + setItem: (k, v) => { + data.set(k, v); + }, + removeItem: (k) => { + data.delete(k); + }, + }; +} + +const boom = (): never => { + throw new Error('SecurityError'); +}; +const throwingStorage: UserIdentifierStorage = { + getItem: boom, + setItem: boom, + removeItem: boom, +}; + +describe('createUserIdentifierStore', () => { + it('round-trips an identifier under a per-token key', () => { + const storage = memoryStorage(); + const store = createUserIdentifierStore('tok-a', { storage }); + store.write('a@b.c'); + expect([...storage.data.entries()]).toEqual([[`${USER_IDENTIFIER_KEY_PREFIX}tok-a`, 'a@b.c']]); + expect(store.read()).toBe('a@b.c'); + }); + + it('isolates app tokens from each other', () => { + const storage = memoryStorage(); + createUserIdentifierStore('tok-a', { storage }).write('one'); + expect(createUserIdentifierStore('tok-b', { storage }).read()).toBeNull(); + }); + + it('clear removes the stored value', () => { + const storage = memoryStorage(); + const store = createUserIdentifierStore('t', { storage }); + store.write('x'); + store.clear(); + expect(storage.data.size).toBe(0); + expect(store.read()).toBeNull(); + }); + + it('treats an empty stored value as absent', () => { + const storage = memoryStorage({ [`${USER_IDENTIFIER_KEY_PREFIX}t`]: '' }); + expect(createUserIdentifierStore('t', { storage }).read()).toBeNull(); + }); + + it('never throws on a blocked storage; each failure goes to onError', () => { + const onError = vi.fn(); + const store = createUserIdentifierStore('t', { storage: throwingStorage, onError }); + expect(store.read()).toBeNull(); + expect(() => store.write('x')).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(3); + }); + + it('tolerates failures with no onError configured', () => { + const store = createUserIdentifierStore('t', { storage: throwingStorage }); + expect(store.read()).toBeNull(); + expect(() => store.write('x')).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + }); + + it('is inert when there is no storage at all', () => { + const store = createUserIdentifierStore('t', { storage: null }); + expect(store.read()).toBeNull(); + expect(() => store.write('x')).not.toThrow(); + expect(() => store.clear()).not.toThrow(); + }); + + it('falls back to globalThis.localStorage by default', () => { + const storage = memoryStorage(); + vi.stubGlobal('localStorage', storage); + try { + createUserIdentifierStore('t').write('g'); + expect(storage.data.get(`${USER_IDENTIFIER_KEY_PREFIX}t`)).toBe('g'); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('is inert when the host has no localStorage global', () => { + vi.stubGlobal('localStorage', undefined); + try { + expect(createUserIdentifierStore('t').read()).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('survives a throwing localStorage getter (blocked cookies)', () => { + const onError = vi.fn(); + const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + Object.defineProperty(globalThis, 'localStorage', { get: boom, configurable: true }); + try { + const store = createUserIdentifierStore('t', { onError }); + expect(store.read()).toBeNull(); + expect(onError).toHaveBeenCalledTimes(1); + } finally { + if (original !== undefined) Object.defineProperty(globalThis, 'localStorage', original); + else Reflect.deleteProperty(globalThis, 'localStorage'); + } + }); +}); diff --git a/packages/browser/src/user-identifier-store.ts b/packages/browser/src/user-identifier-store.ts new file mode 100644 index 00000000..1d2ca7ea --- /dev/null +++ b/packages/browser/src/user-identifier-store.ts @@ -0,0 +1,73 @@ +// Durable storage for the user identifier (iOS persists `setUserIdentifier` across launches; web parity). +// +// Deliberately NARROWER than the device-id machinery (device-id.ts), which mirrors one value across +// localStorage + cookie + Cookie Store + IndexedDB + Cache API so it survives a "clear site data". An +// identifier is the app's PII (often an email): it must live in exactly ONE first-party store, never in a +// cookie (cookies are sent to the server on every request), and clearing site data SHOULD forget it. So: +// per-app-token localStorage only, last-writer-wins across tabs, read once at launch. +// +// Every access is wrapped. A blocked/throwing storage (private mode, quota, disabled cookies) reports +// through `onError` and degrades to the in-memory Environment value, which is always authoritative. + +export const USER_IDENTIFIER_KEY_PREFIX = 'bugsee.user_identifier.'; + +/** The localStorage subset used. Injectable for tests. */ +export interface UserIdentifierStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export interface UserIdentifierStore { + /** The persisted identifier, or null when absent / unreadable. Never throws. */ + read(): string | null; + /** Persist `id`. Never throws. */ + write(id: string): void; + /** Remove the persisted identifier. Never throws. */ + clear(): void; +} + +export interface UserIdentifierStoreOptions { + /** Storage seam. `undefined` → `globalThis.localStorage`; `null` → no storage (inert store). */ + storage?: UserIdentifierStorage | null; + onError?: (error: unknown) => void; +} + +export function createUserIdentifierStore( + appToken: string, + options: UserIdentifierStoreOptions = {}, +): UserIdentifierStore { + const key = `${USER_IDENTIFIER_KEY_PREFIX}${appToken}`; + const report = (error: unknown): void => options.onError?.(error); + // Resolved lazily and inside try: merely touching `globalThis.localStorage` throws SecurityError when + // storage is blocked. + const resolve = (): UserIdentifierStorage | null => + options.storage === undefined + ? ((globalThis as { localStorage?: UserIdentifierStorage }).localStorage ?? null) + : options.storage; + return { + read() { + try { + const value = resolve()?.getItem(key); + return typeof value === 'string' && value !== '' ? value : null; + } catch (error) { + report(error); + return null; + } + }, + write(id) { + try { + resolve()?.setItem(key, id); + } catch (error) { + report(error); + } + }, + clear() { + try { + resolve()?.removeItem(key); + } catch (error) { + report(error); + } + }, + }; +} diff --git a/packages/browser/src/user-identifier.launch.test.ts b/packages/browser/src/user-identifier.launch.test.ts new file mode 100644 index 00000000..0dfa7aec --- /dev/null +++ b/packages/browser/src/user-identifier.launch.test.ts @@ -0,0 +1,249 @@ +// @vitest-environment jsdom +import 'fake-indexeddb/auto'; +import { + createMemoryCaptureStore, + type HttpRequestOptions, + type HttpResponse, + type HttpTransport, +} from '@bugsee/core'; +import { strFromU8, unzipSync } from '@bugsee/util'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resetBrowserDeviceIdCache } from './device-id'; +import type { BrowserProbe } from './environment'; +import { type Bugsee, type BugseeLaunchOptions, launch } from './launch'; +import { USER_IDENTIFIER_KEY_PREFIX } from './user-identifier-store'; + +// The user identifier survives a page load (iOS parity): launch -> set -> "reload" (stop + relaunch on the +// same storage) -> the first report already carries it. Real browser composition root, fake localStorage. + +const probe: BrowserProbe = { + userAgent: () => + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', + uaDataPlatform: () => 'macOS', + locale: () => 'en-US', + utcOffsetMinutes: () => 0, + screenWidth: () => 1280, + screenHeight: () => 720, + pixelRatio: () => 1, + deviceMemoryBytes: () => undefined, + cpuCount: () => undefined, +}; + +const json = (value: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(value)); +const transport = (): ReturnType> => + vi.fn(async (url: string): Promise => { + if (url.endsWith('/v2/sessions')) { + return { status: 200, headers: {}, body: json({ access_token: 'access' }) }; + } + if (url.endsWith('/v2/issues')) { + return { + status: 200, + headers: {}, + body: json({ endpoint: 'https://s3.test/put', issueId: 'i1', recordingId: 'r1' }), + }; + } + return { status: 200, headers: {}, body: new Uint8Array() }; + }); + +function fakeStorage() { + const data = new Map(); + return { + data, + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => { + data.set(k, v); + }, + removeItem: (k: string) => { + data.delete(k); + }, + }; +} + +const clients: Bugsee[] = []; +afterEach(async () => { + await Promise.all(clients.splice(0).map((c) => c.stop())); + resetBrowserDeviceIdCache(); + delete (globalThis as { __BUGSEE__?: unknown }).__BUGSEE__; +}); + +type Storage = ReturnType; + +const start = async (storage: Storage, over: Partial = {}) => { + const http = transport(); + const client = await launch('tok', { + window, + document, + transport: http, + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + replay: false, + captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), + deviceIdEnv: { + localStorage: storage, + randomUUID: () => 'a1b2c3d4-e5f6-4789-a012-3456789abcde', + }, + ...over, + }); + clients.push(client); + return { client, http }; +}; + +const emailOfFirstReport = async (client: Bugsee, http: ReturnType) => { + await client.upload({ summary: 'x' }); + const call = http.mock.calls.find(([url]) => url === 'https://s3.test/put'); + const files = unzipSync((call?.[1] as HttpRequestOptions).body as Uint8Array); + return (JSON.parse(strFromU8(files['request.json'] as Uint8Array)) as { email?: string }).email; +}; + +const reload = async (client: Bugsee): Promise => { + await client.stop(); + clients.splice(0); + resetBrowserDeviceIdCache(); +}; + +describe('persistUserIdentifier', () => { + it('restores the identifier on the next launch, before the first report', async () => { + const storage = fakeStorage(); + const first = await start(storage); + first.client.setUserIdentifier('ann@example.com'); + expect(storage.data.get(`${USER_IDENTIFIER_KEY_PREFIX}tok`)).toBe('ann@example.com'); + await reload(first.client); + + const second = await start(storage); + expect(second.client.getUserIdentifier()).toBe('ann@example.com'); + expect(await emailOfFirstReport(second.client, second.http)).toBe('ann@example.com'); + }); + + it('forgets a cleared identifier across a reload', async () => { + const storage = fakeStorage(); + const first = await start(storage); + first.client.setUserIdentifier('ann@example.com'); + first.client.clearUserIdentifier(); + expect(storage.data.size).toBe(1); // only the device id remains + await reload(first.client); + + const second = await start(storage); + expect(second.client.getUserIdentifier()).toBeNull(); + expect(await emailOfFirstReport(second.client, second.http)).toBeUndefined(); + }); + + it('keeps the last write when the app overwrites the identifier', async () => { + const storage = fakeStorage(); + const first = await start(storage); + first.client.setUserIdentifier('one'); + first.client.setUserIdentifier('two'); + await reload(first.client); + expect((await start(storage)).client.getUserIdentifier()).toBe('two'); + }); + + it('persists what the report dialog sets, via the same public setters', async () => { + const storage = fakeStorage(); + const first = await start(storage); + // the dialog's deps are the wrapped public setters; exercising the public surface is equivalent + first.client.setUserIdentifier('dialog@example.com'); + await reload(first.client); + expect((await start(storage)).client.getUserIdentifier()).toBe('dialog@example.com'); + }); + + it('does not touch storage with persistUserIdentifier: false (and ignores a stored value)', async () => { + const storage = fakeStorage(); + storage.data.set(`${USER_IDENTIFIER_KEY_PREFIX}tok`, 'stale@example.com'); + const { client } = await start(storage, { persistUserIdentifier: false }); + expect(client.getUserIdentifier()).toBeNull(); + client.setUserIdentifier('mem@example.com'); + expect(client.getUserIdentifier()).toBe('mem@example.com'); + client.clearUserIdentifier(); + // neither written nor removed + expect(storage.data.get(`${USER_IDENTIFIER_KEY_PREFIX}tok`)).toBe('stale@example.com'); + }); + + it('is scoped per app token', async () => { + const storage = fakeStorage(); + const first = await start(storage); + first.client.setUserIdentifier('ann'); + await reload(first.client); + // a different token never sees it + const other = await launch('other-tok', { + window, + document, + transport: transport(), + systemProbe: probe, + systemMetricsSampler: () => [], + captureNetwork: false, + replay: false, + deviceIdEnv: { + localStorage: storage, + randomUUID: () => 'a1b2c3d4-e5f6-4789-a012-3456789abcde', + }, + }); + clients.push(other); + expect(other.getUserIdentifier()).toBeNull(); + }); + + it('degrades to in-memory with onError when storage throws', async () => { + const onError = vi.fn(); + const throwing = { + ...fakeStorage(), + getItem: (k: string): string | null => { + if (k.startsWith(USER_IDENTIFIER_KEY_PREFIX)) throw new Error('blocked'); + return null; + }, + setItem: (k: string): void => { + if (k.startsWith(USER_IDENTIFIER_KEY_PREFIX)) throw new Error('quota'); + }, + removeItem: (k: string): void => { + if (k.startsWith(USER_IDENTIFIER_KEY_PREFIX)) throw new Error('blocked'); + }, + }; + const { client, http } = await start(throwing as unknown as Storage, { onError }); + client.setUserIdentifier('mem@example.com'); + client.clearUserIdentifier(); + client.setUserIdentifier('mem2@example.com'); + expect(client.getUserIdentifier()).toBe('mem2@example.com'); + expect(onError).toHaveBeenCalledTimes(4); // read + write + remove + write + expect(await emailOfFirstReport(client, http)).toBe('mem2@example.com'); + }); + + describe('through the report dialog', () => { + const dialogRoot = (): ShadowRoot | null => + document.querySelector('[data-bugsee-report-dialog]')?.shadowRoot ?? null; + const sendWithEmail = async (client: Bugsee, email: string): Promise => { + const pending = client.showReportDialog(); + const field = dialogRoot()?.querySelector('[data-field="email"]'); + if (!field) throw new Error('no email field'); + field.value = email; + field.dispatchEvent(new Event('input', { bubbles: true })); + dialogRoot()?.querySelector('[data-action="send"]')?.click(); + await pending; + }; + + afterEach(() => { + document.querySelector('[data-bugsee-report-dialog]')?.remove(); + }); + + it('persists the email the reporter typed, so the next page load pre-fills it', async () => { + const storage = fakeStorage(); + const first = await start(storage); + await sendWithEmail(first.client, 'jane@example.com'); + expect(storage.data.get(`${USER_IDENTIFIER_KEY_PREFIX}tok`)).toBe('jane@example.com'); + await reload(first.client); + + const second = await start(storage); + void second.client.showReportDialog(); + expect(dialogRoot()?.querySelector('[data-field="email"]')?.value).toBe( + 'jane@example.com', + ); + }); + + it('removes the persisted email when the reporter erases it', async () => { + const storage = fakeStorage(); + const first = await start(storage); + first.client.setUserIdentifier('jane@example.com'); + await sendWithEmail(first.client, ''); + expect(storage.data.has(`${USER_IDENTIFIER_KEY_PREFIX}tok`)).toBe(false); + await reload(first.client); + expect((await start(storage)).client.getUserIdentifier()).toBeNull(); + }); + }); +}); diff --git a/packages/instrumentation-tests/test/user-identifier.e2e.ts b/packages/instrumentation-tests/test/user-identifier.e2e.ts new file mode 100644 index 00000000..d28c3020 --- /dev/null +++ b/packages/instrumentation-tests/test/user-identifier.e2e.ts @@ -0,0 +1,186 @@ +// The REAL-BROWSER e2e for user-identifier persistence: `setUserIdentifier` survives a page reload (iOS +// parity), `clearUserIdentifier` does not, and the `persistUserIdentifier: false` opt-out keeps the PII out +// of localStorage entirely. jsdom's localStorage is a stand-in; this asserts it against a real Chromium. +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { type MockCollector, parseBundles, readJson, startMockCollector } from '@bugsee/e2e-kit'; +import { build } from 'esbuild'; +import { type Browser, type BrowserContext, chromium, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// Same page script as the report-dialog e2e: it launches the REAL `@bugsee/browser` and exposes the client. +const scenarioPath = fileURLToPath(new URL('../app/report-dialog-scenario.ts', import.meta.url)); + +async function bundleScenario(): Promise { + const result = await build({ + entryPoints: [scenarioPath], + bundle: true, + format: 'iife', + platform: 'browser', + target: 'chrome110', + write: false, + define: { 'process.env.NODE_ENV': '"production"' }, + }); + const out = result.outputFiles[0]; + if (out === undefined) throw new Error('esbuild produced no output'); + return out.text; +} + +const PAGE = `identifier e2e +`; + +async function startPageServer( + script: string, +): Promise<{ url: string; close: () => Promise }> { + const server: Server = createServer((req, res) => { + if ((req.url ?? '/').startsWith('/scenario.js')) { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); + res.end(script); + return; + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(PAGE); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }), + }; +} + +type Api = { + setUserIdentifier(id: string): void; + clearUserIdentifier(): void; + getUserIdentifier(): string | null; + upload(o: { summary: string }): Promise; +}; + +describe('user identifier persistence — REAL Chromium end to end', () => { + let browser: Browser; + let script: string; + + beforeAll(async () => { + script = await bundleScenario(); + browser = await chromium.launch({ headless: true }); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + async function open( + context: BrowserContext, + url: string, + collector: MockCollector, + options: Record = {}, + ): Promise { + const page = await context.newPage(); + await page.addInitScript( + ([collectorUrl, opts]) => { + const w = window as unknown as Record; + w.__E2E_COLLECTOR__ = collectorUrl; + w.__E2E_OPTIONS__ = opts; + }, + [collector.url, options] as const, + ); + await page.goto(url); + await page.waitForFunction(() => (window as unknown as { __ready?: boolean }).__ready === true); + return page; + } + + /** Upload a report from `page` and return the `email` of the bundle the collector received. */ + async function reportEmail(page: Page, collector: MockCollector): Promise { + const before = collector.uploads.length; + await page.evaluate(() => + (window as unknown as { __bugsee: Api }).__bugsee.upload({ summary: 'identifier probe' }), + ); + const deadline = Date.now() + 20_000; + while (collector.uploads.length <= before && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + const bundle = parseBundles(collector).at(-1); + expect(bundle).toBeDefined(); + return readJson<{ email?: string }>(bundle as NonNullable, 'request.json').email; + } + + async function scenario( + run: (ctx: { context: BrowserContext; url: string; collector: MockCollector }) => Promise, + ): Promise { + const collector = await startMockCollector(); + const server = await startPageServer(script); + const context = await browser.newContext(); + try { + await run({ context, url: server.url, collector }); + } finally { + await context.close(); + await server.close(); + await collector.close(); + } + } + + it('set -> reload: the next report already carries the identifier', async () => { + await scenario(async ({ context, url, collector }) => { + const first = await open(context, url, collector); + await first.evaluate(() => + (window as unknown as { __bugsee: Api }).__bugsee.setUserIdentifier('ann@example.com'), + ); + await first.close(); + + const second = await open(context, url, collector); + expect( + await second.evaluate(() => + (window as unknown as { __bugsee: Api }).__bugsee.getUserIdentifier(), + ), + ).toBe('ann@example.com'); + expect(await reportEmail(second, collector)).toBe('ann@example.com'); + expect( + await second.evaluate(() => (window as unknown as { __sdkErrors: string[] }).__sdkErrors), + ).toEqual([]); + }); + }); + + it('set -> clear -> reload: the identifier is gone', async () => { + await scenario(async ({ context, url, collector }) => { + const first = await open(context, url, collector); + await first.evaluate(() => { + (window as unknown as { __bugsee: Api }).__bugsee.setUserIdentifier('ann@example.com'); + (window as unknown as { __bugsee: Api }).__bugsee.clearUserIdentifier(); + }); + await first.close(); + + const second = await open(context, url, collector); + expect( + await second.evaluate(() => + (window as unknown as { __bugsee: Api }).__bugsee.getUserIdentifier(), + ), + ).toBeNull(); + expect(await reportEmail(second, collector)).toBeUndefined(); + }); + }); + + it('persistUserIdentifier: false never writes the identifier to localStorage', async () => { + await scenario(async ({ context, url, collector }) => { + const first = await open(context, url, collector, { persistUserIdentifier: false }); + await first.evaluate(() => + (window as unknown as { __bugsee: Api }).__bugsee.setUserIdentifier('ann@example.com'), + ); + const stored = await first.evaluate(() => + Object.keys(localStorage).filter((k) => k.startsWith('bugsee.user_identifier.')), + ); + expect(stored).toEqual([]); + await first.close(); + + const second = await open(context, url, collector); + expect( + await second.evaluate(() => + (window as unknown as { __bugsee: Api }).__bugsee.getUserIdentifier(), + ), + ).toBeNull(); + }); + }); +}); diff --git a/packages/protocol/src/options.test.ts b/packages/protocol/src/options.test.ts index 96f2fc1d..f611a12d 100644 --- a/packages/protocol/src/options.test.ts +++ b/packages/protocol/src/options.test.ts @@ -107,6 +107,10 @@ describe('BugseeOption canonical identifiers', () => { ); }); + it('names the user-identifier persistence option', () => { + expect(BugseeOption.PersistUserIdentifier).toBe('com.bugsee.option.user.persist-identifier'); + }); + it('are all namespaced under com.bugsee.option. and unique', () => { const keys = Object.values(BugseeOption); expect(keys.every((k) => k.startsWith('com.bugsee.option.'))).toBe(true); diff --git a/packages/protocol/src/options.ts b/packages/protocol/src/options.ts index 7eaeafbd..6ebda3b3 100644 --- a/packages/protocol/src/options.ts +++ b/packages/protocol/src/options.ts @@ -91,6 +91,12 @@ export const BugseeOption = { ReportingUiLabelsRequired: 'com.bugsee.option.reporting.ui.labels-required', /** Report dialog: show the severity picker (off by default, as on mobile). */ ReportingUiPrioritySelectorEnabled: 'com.bugsee.option.reporting.ui.priority-selector-enabled', + /** + * Persist the user identifier across launches (iOS persists it; the browser tier honours this — it + * keeps the identifier in localStorage). Default true. Set false when the identifier is PII you do + * not want at rest on the device. Node/server tiers never persist it. + */ + PersistUserIdentifier: 'com.bugsee.option.user.persist-identifier', /** Severity a bug report starts with: `low | medium | high | critical | blocker`. Default `high`. */ ReportingDefaultBugPriority: 'com.bugsee.option.reporting.defaults.bug-priority', /** Severity a crash report starts with. Default `blocker`. */ @@ -134,6 +140,7 @@ export interface BugseeOptionTypes { 'com.bugsee.option.reporting.ui.labels-enabled': boolean; 'com.bugsee.option.reporting.ui.labels-required': boolean; 'com.bugsee.option.reporting.ui.priority-selector-enabled': boolean; + 'com.bugsee.option.user.persist-identifier': boolean; 'com.bugsee.option.reporting.defaults.bug-priority': string; 'com.bugsee.option.reporting.defaults.crash-priority': string; 'com.bugsee.option.reporting.defaults.error-priority': string;