diff --git a/src/main/browser/browser-profile-reset.test.ts b/src/main/browser/browser-profile-reset.test.ts new file mode 100644 index 00000000000..df6db06e717 --- /dev/null +++ b/src/main/browser/browser-profile-reset.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Mocked } from 'vitest' +import type { Session } from 'electron' + +const getAllWebContentsMock = vi.fn() +vi.mock('electron', () => ({ + webContents: { getAllWebContents: () => getAllWebContentsMock() } +})) + +import { findPartitionWebContents, resetBrowserProfilePartition } from './browser-profile-reset' +import { acquireCookieMutationLock, withCookieMutationLock } from './browser-cookie-import-clear' + +type SessionMock = Session & Mocked> + +function makeSession(overrides: Partial = {}): SessionMock { + return { + clearData: vi.fn(async () => undefined), + ...overrides + } as SessionMock +} + +function makeContents(session: unknown, opts: { destroyed?: boolean; onReload?: () => void } = {}) { + return { + session, + isDestroyed: () => opts.destroyed === true, + reload: vi.fn(() => opts.onReload?.()) + } +} + +describe('resetBrowserProfilePartition', () => { + beforeEach(() => { + getAllWebContentsMock.mockReset() + getAllWebContentsMock.mockReturnValue([]) + }) + + it('clears the whole partition, not a filtered subset', async () => { + const targetSession = makeSession() + await resetBrowserProfilePartition({ + targetSession, + partition: 'persist:orca-browser', + clearPendingCookieImport: vi.fn(), + clearImportedSource: vi.fn() + }) + expect(targetSession.clearData).toHaveBeenCalledTimes(1) + expect(targetSession.clearData).toHaveBeenCalledWith() + }) + + it('forwards non-default partitions to staged-import deletion', async () => { + const partition = 'persist:orca-browser-session-imported' + const clearPendingCookieImport = vi.fn() + await resetBrowserProfilePartition({ + targetSession: makeSession(), + partition, + clearPendingCookieImport, + clearImportedSource: vi.fn() + }) + expect(clearPendingCookieImport).toHaveBeenCalledWith(partition) + }) + + it('clears the imported-source badge before the data is gone', async () => { + const order: string[] = [] + const clearImportedSource = vi.fn(() => void order.push('source')) + const targetSession = makeSession({ + clearData: vi.fn(async () => void order.push('clearData')) + }) + await resetBrowserProfilePartition({ + targetSession, + partition: 'persist:orca-browser', + clearPendingCookieImport: vi.fn(() => void order.push('staged')), + clearImportedSource + }) + expect(clearImportedSource).toHaveBeenCalledTimes(1) + expect(order).toEqual(['source', 'staged', 'clearData']) + }) + + it('reloads every live context in the partition after clearing', async () => { + const order: string[] = [] + const targetSession = makeSession({ + clearData: vi.fn(async () => void order.push('clearData')) + }) + const other = makeSession() + const mine = makeContents(targetSession, { onReload: () => void order.push('mine') }) + const popup = makeContents(targetSession, { onReload: () => void order.push('popup') }) + const offscreen = makeContents(targetSession, { onReload: () => void order.push('offscreen') }) + const foreign = makeContents(other) + const dead = makeContents(targetSession, { destroyed: true }) + getAllWebContentsMock.mockReturnValue([mine, popup, offscreen, foreign, dead]) + + await resetBrowserProfilePartition({ + targetSession, + partition: 'persist:orca-browser', + clearPendingCookieImport: vi.fn(), + clearImportedSource: vi.fn() + }) + + expect(mine.reload).toHaveBeenCalledTimes(1) + expect(popup.reload).toHaveBeenCalledTimes(1) + expect(offscreen.reload).toHaveBeenCalledTimes(1) + expect(foreign.reload).not.toHaveBeenCalled() + expect(dead.reload).not.toHaveBeenCalled() + expect(order).toEqual(['clearData', 'mine', 'popup', 'offscreen']) + }) + + it('selects contexts by session identity so popups and offscreen guests are included', () => { + const targetSession = makeSession() + const other = makeSession() + const a = makeContents(targetSession) + const b = makeContents(other) + getAllWebContentsMock.mockReturnValue([a, b]) + expect(findPartitionWebContents(targetSession)).toEqual([a]) + }) + + it('survives a context that dies mid-reset', async () => { + const targetSession = makeSession() + const exploding = { + session: targetSession, + isDestroyed: () => false, + reload: vi.fn(() => { + throw new Error('destroyed') + }) + } + const healthy = makeContents(targetSession) + getAllWebContentsMock.mockReturnValue([exploding, healthy]) + await expect( + resetBrowserProfilePartition({ + targetSession, + partition: 'persist:orca-browser', + clearPendingCookieImport: vi.fn(), + clearImportedSource: vi.fn() + }) + ).resolves.toBeUndefined() + expect(healthy.reload).toHaveBeenCalledTimes(1) + }) + + it('waits for the session mutation lock and releases it after failure', async () => { + const targetSession = makeSession({ + clearData: vi.fn(async () => { + throw new Error('clear failed') + }) + }) + const clearImportedSource = vi.fn() + const releaseImport = await acquireCookieMutationLock(targetSession) + const reset = resetBrowserProfilePartition({ + targetSession, + partition: 'persist:orca-browser', + clearPendingCookieImport: vi.fn(), + clearImportedSource + }) + + await Promise.resolve() + expect(clearImportedSource).not.toHaveBeenCalled() + releaseImport() + await expect(reset).rejects.toThrow('clear failed') + await expect(withCookieMutationLock(targetSession, async () => 'released')).resolves.toBe( + 'released' + ) + }) +}) diff --git a/src/main/browser/browser-profile-reset.ts b/src/main/browser/browser-profile-reset.ts new file mode 100644 index 00000000000..e273eb1f25f --- /dev/null +++ b/src/main/browser/browser-profile-reset.ts @@ -0,0 +1,43 @@ +import { webContents } from 'electron' +import type { Session, WebContents } from 'electron' + +import { withCookieMutationLock } from './browser-cookie-import-clear' + +export type BrowserProfileResetDependencies = { + targetSession: Session + partition: string + clearPendingCookieImport: (partition: string) => void + clearImportedSource: () => void +} + +// Electron's global list includes popups and offscreen guests omitted by Orca's tab registry. +export function findPartitionWebContents(targetSession: Session): WebContents[] { + return webContents + .getAllWebContents() + .filter((contents) => !contents.isDestroyed() && contents.session === targetSession) +} + +function reloadPartitionContexts(targetSession: Session): void { + for (const contents of findPartitionWebContents(targetSession)) { + try { + contents.reload() + } catch { + // A context that dies between enumeration and reload needs no reload. + } + } +} + +/** Resets one session partition while serializing against cookie import mutations. */ +export async function resetBrowserProfilePartition( + deps: BrowserProfileResetDependencies +): Promise { + const { targetSession, partition, clearPendingCookieImport, clearImportedSource } = deps + await withCookieMutationLock(targetSession, async () => { + // Clear metadata first so a partial reset never advertises an import whose data may be gone. + clearImportedSource() + clearPendingCookieImport(partition) + // clearStorageData bypasses trust-token, bounce-tracking, and network-history removal. + await targetSession.clearData() + reloadPartitionContexts(targetSession) + }) +}