fix(browser): bulk clear cookies during native import (#13966)

* fix(browser): bulk clear cookies during native import

* fix(browser): separate Google cookie import warning

* fix(browser): report encrypted Google cookie exclusions

* fix(browser): skip key lookup for excluded cookies

* test(browser): cover excluded-cookie key bypass

* perf(browser): keep plaintext key scan cheap
This commit is contained in:
Brennan Benson
2026-08-12 00:33:50 -07:00
committed by GitHub
parent a90a18d43d
commit 2249330acf
10 changed files with 295 additions and 155 deletions
@@ -89,6 +89,7 @@ describe('file import excludes the Google cookie family', () => {
totalCookies: 4,
importedCookies: 2,
skippedCookies: 2,
googleCookiesSkipped: 2,
domains: ['linear.app', 'youtube.com']
})
expect(cookiesRemoveMock.mock.calls).toEqual([['https://linear.app/', 'old-linear']])
@@ -110,6 +111,7 @@ describe('file import excludes the Google cookie family', () => {
totalCookies: 1,
importedCookies: 0,
skippedCookies: 1,
googleCookiesSkipped: 1,
domains: []
})
expect(cookiesRemoveMock).not.toHaveBeenCalled()
@@ -164,7 +166,7 @@ describe('native Chromium import excludes the Google cookie family', () => {
createChromiumCookieTestDatabase(targetCookiesPath, rows).close()
}
it('never wipes the jar wholesale and keeps the live Google cookies', async () => {
it('bulk clears once and restores the live Google cookies before importing', async () => {
const sourceCookiesPath = seedSource([
{ domain: '.google.com', name: 'SID', value: 'transplanted-sid' },
{ domain: '.example.com', name: 'session', value: 'new' }
@@ -181,11 +183,67 @@ describe('native Chromium import excludes the Google cookie family', () => {
totalCookies: 2,
importedCookies: 1,
skippedCookies: 1,
googleCookiesSkipped: 1,
domains: ['example.com']
})
expect(clearStorageDataMock).toHaveBeenCalledOnce()
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual([
'.google.com',
'.example.com'
])
})
it('reports an excluded Google row even when its encrypted value is invalid', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
createChromiumCookieTestDatabase(sourceCookiesPath, [
{
domain: '.google.com',
name: 'SID',
value: '',
encryptedValue: Buffer.from('v10-invalid')
},
{ domain: '.example.com', name: 'session', value: 'new' }
]).close()
seedTarget([])
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
expect(result.ok && result.summary).toMatchObject({
totalCookies: 2,
importedCookies: 1,
skippedCookies: 1,
googleCookiesSkipped: 1
})
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['session'])
})
it('does not request an encryption key for excluded Google rows', async () => {
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
createChromiumCookieTestDatabase(sourceCookiesPath, [
{
domain: '.google.com',
name: 'SID',
value: '',
encryptedValue: Buffer.from('v10-invalid')
}
]).close()
seedTarget([])
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
expect(result.ok && result.summary).toEqual({
totalCookies: 1,
importedCookies: 0,
skippedCookies: 1,
googleCookiesSkipped: 1,
domains: []
})
expect(execFileSyncMock).not.toHaveBeenCalled()
expect(clearStorageDataMock).not.toHaveBeenCalled()
expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'stale']])
expect(cookiesSetMock.mock.calls.map(([details]) => details.domain)).toEqual(['.example.com'])
expect(cookiesSetMock).not.toHaveBeenCalled()
})
it('keeps the live Google rows in the staged restart-fallback database', async () => {
@@ -211,6 +269,27 @@ describe('native Chromium import excludes the Google cookie family', () => {
{ host_key: '.google.com', name: 'SID', value: 'live-sid' }
])
})
it('fails the import and restores the full snapshot when the bulk clear rejects', async () => {
const sourceCookiesPath = seedSource([
{ domain: '.example.com', name: 'session', value: 'new' }
])
seedTarget([{ domain: '.example.com', name: 'stale', value: 'stale' }])
cookiesGetMock.mockResolvedValue([
existingCookie('.google.com', 'SID'),
existingCookie('.example.com', 'stale')
])
clearStorageDataMock.mockRejectedValue(new Error('cookie store unavailable'))
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
expect(result).toMatchObject({ ok: false })
expect(result.ok || result.reason).toContain('Could not clear existing cookies')
expect(clearStorageDataMock).toHaveBeenCalledOnce()
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'stale'])
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
})
})
// Why: freshly imported rows store the decrypted value as a BLOB; read both forms as text.
@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import type { Cookie } from 'electron'
import {
bulkClearCookiesExcept,
isGoogleSourceBoundCookie,
isNonTransplantableCookieDomain,
NON_TRANSPLANTABLE_HOST_KEY_SQL,
normalizeCookieDomain,
removeAllCookiesExcept,
replaceCookiesForImportedDomains
} from './browser-cookie-import-policy'
@@ -207,104 +207,87 @@ describe('NON_TRANSPLANTABLE_HOST_KEY_SQL', () => {
})
})
describe('removeAllCookiesExcept', () => {
it('removes only the cookies the predicate does not exclude', async () => {
const get = vi
.fn()
.mockResolvedValue([
cookie('.google.com', 'SID'),
cookie('.example.com', 'session'),
cookie('other.test', 'tracker', '/scoped')
])
const remove = vi.fn().mockResolvedValue(undefined)
describe('bulkClearCookiesExcept', () => {
it('bulk clears a large jar once and restores only excluded cookies', async () => {
const existing = [
cookie('.google.com', 'SID'),
cookie('accounts.google.com', 'ACCOUNT'),
...Array.from({ length: 1_000 }, (_, index) =>
cookie(`site-${index}.example`, `session-${index}`)
)
]
const get = vi.fn().mockResolvedValue(existing)
const set = vi.fn().mockResolvedValue(undefined)
const clearStorageData = vi.fn().mockResolvedValue(undefined)
await removeAllCookiesExcept({ get, remove, set }, (c) => c.domain === '.google.com')
await bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, (existingCookie) =>
isNonTransplantableCookieDomain(existingCookie.domain ?? '')
)
expect(remove.mock.calls).toEqual([
['https://example.com/', 'session'],
['https://other.test/scoped', 'tracker']
])
expect(set).not.toHaveBeenCalled()
expect(get).toHaveBeenCalledOnce()
expect(get).toHaveBeenCalledWith({})
expect(clearStorageData).toHaveBeenCalledOnce()
expect(clearStorageData).toHaveBeenCalledWith({ storages: ['cookies'] })
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'ACCOUNT'])
})
it('restores every successfully removed cookie when another removal fails', async () => {
const get = vi
.fn()
.mockResolvedValue([
cookie('.example.com', 'first', '/one'),
cookie('.example.com', 'second', '/two'),
cookie('.example.com', 'third', '/three')
])
const remove = vi.fn().mockImplementation(async (_url: string, name: string) => {
if (name === 'second') {
throw new Error('store unavailable')
it('restores the complete snapshot when the bulk clear rejects', async () => {
const existing = [
cookie('.google.com', 'SID'),
cookie('.example.com', 'first'),
cookie('.other.test', 'second')
]
const get = vi.fn().mockResolvedValue(existing)
const set = vi.fn().mockResolvedValue(undefined)
const clearStorageData = vi.fn().mockRejectedValue(new Error('store unavailable'))
await expect(
bulkClearCookiesExcept({ cookies: { get, set }, clearStorageData }, () => true)
).rejects.toThrow('Could not clear existing cookies')
expect(clearStorageData).toHaveBeenCalledOnce()
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'first', 'second'])
})
it('rolls back the complete snapshot when excluded-cookie restoration fails', async () => {
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
const get = vi.fn().mockResolvedValue(existing)
let googleAttempts = 0
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
if (name === 'SID' && googleAttempts++ === 0) {
throw new Error('transient restore failure')
}
})
const set = vi.fn().mockResolvedValue(undefined)
const clearStorageData = vi.fn().mockResolvedValue(undefined)
await expect(removeAllCookiesExcept({ get, remove, set }, () => false)).rejects.toThrow(
'Could not clear existing cookies'
)
expect(remove).toHaveBeenCalledTimes(3)
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['first', 'third'])
})
it('bounds parallel removals so large cookie jars do not clear serially or fan out', async () => {
const get = vi
.fn()
.mockResolvedValue(
Array.from({ length: 12 }, (_, index) => cookie('.example.com', `${index}`))
await expect(
bulkClearCookiesExcept(
{ cookies: { get, set }, clearStorageData },
(existingCookie) => existingCookie.domain === '.google.com'
)
let releaseRemovals: (() => void) | undefined
const removalsReleased = new Promise<void>((resolve) => {
releaseRemovals = resolve
})
let active = 0
let maxActive = 0
const remove = vi.fn().mockImplementation(async () => {
active++
maxActive = Math.max(maxActive, active)
await removalsReleased
active--
})
const set = vi.fn().mockResolvedValue(undefined)
).rejects.toThrow('Could not preserve excluded cookies')
const clearing = removeAllCookiesExcept({ get, remove, set }, () => false)
await vi.waitFor(() => expect(remove).toHaveBeenCalledTimes(8))
expect(maxActive).toBe(8)
releaseRemovals?.()
await clearing
expect(remove).toHaveBeenCalledTimes(12)
expect(set).not.toHaveBeenCalled()
expect(clearStorageData).toHaveBeenCalledOnce()
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
})
it('serializes cookies that share Electron removal coordinates', async () => {
const get = vi
.fn()
.mockResolvedValue([
cookie('.example.com', 'session'),
{ ...cookie('example.com', 'session'), hostOnly: true }
])
let releaseFirst: (() => void) | undefined
const firstReleased = new Promise<void>((resolve) => {
releaseFirst = resolve
it('reports when the complete-snapshot rollback also fails', async () => {
const existing = [cookie('.google.com', 'SID'), cookie('.example.com', 'session')]
const get = vi.fn().mockResolvedValue(existing)
const set = vi.fn().mockImplementation(async ({ name }: { name?: string }) => {
if (name === 'SID') {
throw new Error('persistent restore failure')
}
})
const remove = vi
.fn()
.mockImplementationOnce(() => firstReleased)
.mockResolvedValueOnce(undefined)
const set = vi.fn().mockResolvedValue(undefined)
const clearStorageData = vi.fn().mockResolvedValue(undefined)
const clearing = removeAllCookiesExcept({ get, remove, set }, () => false)
await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce())
releaseFirst?.()
await clearing
await expect(
bulkClearCookiesExcept(
{ cookies: { get, set }, clearStorageData },
(existingCookie) => existingCookie.domain === '.google.com'
)
).rejects.toThrow('Cookie preservation and rollback failed')
expect(remove.mock.calls).toEqual([
['https://example.com/', 'session'],
['https://example.com/', 'session']
])
expect(set.mock.calls.map(([details]) => details.name)).toEqual(['SID', 'SID', 'session'])
})
})
@@ -1,6 +1,5 @@
import type { Cookie, Cookies } from 'electron'
import type { Cookie, Cookies, Session } from 'electron'
import { parse as parseDomain } from 'psl'
import { mapSettledWithConcurrency } from '../../shared/map-with-concurrency'
const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([
'SIDCC',
@@ -64,7 +63,6 @@ export function normalizeCookieImportDomain(domain: string): string | null {
// its cookies via the accounts.youtube.com relay, so excluding it would silently drop imports
// users actually asked for.
const NON_TRANSPLANTABLE_DOMAINS = ['google.com'] as const
const COOKIE_CLEAR_CONCURRENCY = 8
export function isNonTransplantableCookieDomain(domain: string): boolean {
const normalized = normalizeCookieDomain(domain)
@@ -196,53 +194,55 @@ export async function restoreImportedDomainCookies(
}
}
// Why: clearStorageData wipes the whole jar, including the non-transplantable families an
// import is never allowed to remove; this is the clear step that can leave them in place.
export async function removeAllCookiesExcept(
store: Pick<Cookies, 'get' | 'remove' | 'set'>,
type CookieClearSession = {
cookies: Pick<Cookies, 'get' | 'set'>
clearStorageData: Session['clearStorageData']
}
async function restoreCookieClearSnapshot(
store: Pick<Cookies, 'set'>,
snapshot: readonly Cookie[],
originalError: unknown,
rollbackMessage: string
): Promise<never> {
try {
await restoreImportedDomainCookies(store, snapshot)
} catch (rollbackError) {
throw new AggregateError([originalError, rollbackError], rollbackMessage)
}
throw originalError
}
// Why: after a bulk clear starts, Electron cannot reveal whether a rejected operation mutated
// the jar, so keep the complete snapshot until excluded cookies have been restored.
export async function bulkClearCookiesExcept(
targetSession: CookieClearSession,
isExcluded: (cookie: Cookie) => boolean
): Promise<void> {
const existingCookies = await store.get({})
const removableGroups = new Map<string, { cookie: Cookie; url: string }[]>()
for (const cookie of existingCookies) {
if (isExcluded(cookie)) {
continue
}
const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null
const url = domain ? cookieRemovalUrl(cookie, domain) : null
if (!url) {
continue
}
const key = JSON.stringify([url, cookie.name])
const group = removableGroups.get(key) ?? []
group.push({ cookie, url })
removableGroups.set(key, group)
const snapshot = await targetSession.cookies.get({})
const excludedCookies = snapshot.filter(isExcluded)
try {
await targetSession.clearStorageData({ storages: ['cookies'] })
} catch (clearError) {
await restoreCookieClearSnapshot(
targetSession.cookies,
snapshot,
new AggregateError([clearError], 'Could not clear existing cookies'),
'Cookie bulk clear and rollback failed'
)
}
const removedCookies: Cookie[] = []
const results = await mapSettledWithConcurrency(
[...removableGroups.values()],
COOKIE_CLEAR_CONCURRENCY,
async (group) => {
// Why: identical remove keys must stay ordered so duplicate scoped cookies are not raced.
for (const { cookie, url } of group) {
await store.remove(url, cookie.name)
removedCookies.push(cookie)
}
}
)
const failures = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : []
)
if (failures.length === 0) {
return
}
try {
await restoreImportedDomainCookies(store, removedCookies)
} catch (restoreError) {
throw new AggregateError([...failures, restoreError], 'Cookie clearing and rollback failed')
await restoreImportedDomainCookies(targetSession.cookies, excludedCookies)
} catch (preservationError) {
await restoreCookieClearSnapshot(
targetSession.cookies,
snapshot,
new AggregateError([preservationError], 'Could not preserve excluded cookies'),
'Cookie preservation and rollback failed'
)
}
throw new AggregateError(failures, 'Could not clear existing cookies')
}
export async function replaceCookiesForImportedDomains(
@@ -79,6 +79,7 @@ describe('validated cookie replacement', () => {
totalCookies: 3,
importedCookies: 1,
skippedCookies: 2,
googleCookiesSkipped: 2,
domains: ['example.com']
})
expect(cookiesRemoveMock.mock.calls).toEqual([['https://example.com/', 'old-example']])
@@ -99,6 +100,7 @@ describe('validated cookie replacement', () => {
totalCookies: 1,
importedCookies: 0,
skippedCookies: 1,
googleCookiesSkipped: 1,
domains: []
})
expect(cookiesGetMock).not.toHaveBeenCalled()
@@ -215,6 +217,7 @@ describe('native Chromium integrity-cookie accounting', () => {
totalCookies: 3,
importedCookies: 1,
skippedCookies: 2,
googleCookiesSkipped: 2,
domains: ['example.com']
})
expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['AEC'])
@@ -246,6 +249,7 @@ describe('native Chromium integrity-cookie accounting', () => {
totalCookies: 3,
importedCookies: 0,
skippedCookies: 3,
googleCookiesSkipped: 2,
domains: []
})
expect(clearStorageDataMock).not.toHaveBeenCalled()
@@ -511,9 +511,8 @@ describe('importCookiesFromBrowser Chromium', () => {
['', '-wal', '-shm'].map((suffix) => readFileSync(sourceCookiesPath + suffix))
).toEqual(sourceFilesBefore)
expect(cookiesRemoveMock).not.toHaveBeenCalled()
// Why: STA-3811 — the pre-import clear is selective now, so a wholesale wipe would
// take the non-transplantable families with it.
expect(clearStorageDataMock).not.toHaveBeenCalled()
expect(clearStorageDataMock).toHaveBeenCalledOnce()
expect(clearStorageDataMock).toHaveBeenCalledWith({ storages: ['cookies'] })
// Why: STA-3514 — imports must never impersonate the source browser; the
// session keeps the engine UA the registry set at startup.
expect(setUserAgentMock).not.toHaveBeenCalled()
+29 -17
View File
@@ -75,12 +75,12 @@ import type {
} from '../../shared/types'
import { browserSessionRegistry } from './browser-session-registry'
import {
bulkClearCookiesExcept,
isGoogleSourceBoundCookie,
isNonTransplantableCookieDomain,
NON_TRANSPLANTABLE_HOST_KEY_SQL,
normalizeCookieDomain,
normalizeCookieImportDomain,
removeAllCookiesExcept,
replaceCookiesForImportedDomains,
restoreImportedDomainCookies,
type CookieImportMode
@@ -584,6 +584,7 @@ async function importValidatedCookies(
)
const integritySkipped = validDomainCookies.length - sourceBoundFiltered.length
const nonTransplantableSkipped = sourceBoundFiltered.length - importableCookies.length
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
const invalidDomainSkipped = cookies.length - validDomainCookies.length
diag(
`importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"`
@@ -693,6 +694,7 @@ async function importValidatedCookies(
totalCookies: totalInput,
importedCookies: importedCount,
skippedCookies: skipped,
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
domains: [...domainSet].sort()
}
@@ -1590,7 +1592,12 @@ export async function importCookiesFromBrowser(
const needsSourceKey = sourceRows.some((sourceRow) => {
const encRaw = sourceRow.encrypted_value
return encRaw instanceof Uint8Array && encRaw.length > 0
if (!(encRaw instanceof Uint8Array) || encRaw.length === 0) {
return false
}
const domain = sourceRow.host_key as string
const name = sourceRow.name as string
return !(isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain))
})
const sourceKey = needsSourceKey
? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
@@ -1653,6 +1660,20 @@ export async function importCookiesFromBrowser(
}
for (const sourceRow of sourceRows) {
const domain = sourceRow.host_key as string
const name = sourceRow.name as string
if (isGoogleSourceBoundCookie(name, domain)) {
integritySkipped++
continue
}
// Why: transplanting these replaces a working sign-in with a session the site rejects.
if (isNonTransplantableCookieDomain(domain)) {
nonTransplantableSkipped++
continue
}
const encRaw = sourceRow.encrypted_value
// Why: node:sqlite returns BLOBs as Uint8Array; treat any other type as missing, not an empty buffer that would silently blank the cookie value.
const encBuf = encRaw instanceof Uint8Array ? Buffer.from(encRaw) : null
@@ -1674,20 +1695,6 @@ export async function importCookiesFromBrowser(
decryptedValue = Buffer.alloc(0)
}
const domain = sourceRow.host_key as string
const name = sourceRow.name as string
if (isGoogleSourceBoundCookie(name, domain)) {
integritySkipped++
continue
}
// Why: transplanting these replaces a working sign-in with a session the site rejects.
if (isNonTransplantableCookieDomain(domain)) {
nonTransplantableSkipped++
continue
}
let validDomain = sourceDomainValidity.get(domain)
if (validDomain === undefined) {
validDomain = normalizeCookieImportDomain(domain) !== null
@@ -1740,6 +1747,7 @@ export async function importCookiesFromBrowser(
diag(
` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC) and ${nonTransplantableSkipped} non-transplantable-domain cookies`
)
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
if (decryptedCookies.length === 0) {
closeStagingDb()
@@ -1751,6 +1759,7 @@ export async function importCookiesFromBrowser(
totalCookies: sourceRows.length,
importedCookies: 0,
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
domains: []
}
}
@@ -1771,9 +1780,11 @@ export async function importCookiesFromBrowser(
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
// session. Non-transplantable families are exempt — nothing was imported for them, and their
// live session is the only one that works.
await removeAllCookiesExcept(targetSession.cookies, (cookie) =>
await bulkClearCookiesExcept(targetSession, (cookie) =>
isNonTransplantableCookieDomain(cookie.domain ?? '')
)
// Why: after excluded cookies survive this boundary, restart staging owns later set failures;
// restoring stale non-Google cookies would recreate the mixed jar this import must replace.
diag(
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
)
@@ -1842,6 +1853,7 @@ export async function importCookiesFromBrowser(
totalCookies: sourceRows.length,
importedCookies: imported,
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
domains: [...domainSet].sort(),
...(warning ? { warning } : {})
}
+2 -1
View File
@@ -756,7 +756,8 @@
"import": {
"toast": {
"restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.",
"restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again."
"restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.",
"googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google."
}
}
}
@@ -69,4 +69,52 @@ describe('emitBrowserCookieImportToast', () => {
expect(successToastMock).toHaveBeenCalledWith('Imported 3 cookies.')
expect(warningToastMock).not.toHaveBeenCalled()
})
it('shows a separate Google sign-in warning after the concise success toast', () => {
emitBrowserCookieImportToast(
{ ...summary, importedCookies: 2, skippedCookies: 1, googleCookiesSkipped: 1 },
'Imported 2 cookies.'
)
expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.')
expect(warningToastMock).toHaveBeenCalledWith(
'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.'
)
expect(successToastMock.mock.invocationCallOrder[0]).toBeLessThan(
warningToastMock.mock.invocationCallOrder[0]
)
})
it('does not infer a Google warning from generic skipped cookies', () => {
emitBrowserCookieImportToast(
{ ...summary, importedCookies: 2, skippedCookies: 1 },
'Imported 2 cookies.'
)
expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.')
expect(warningToastMock).not.toHaveBeenCalled()
})
it('keeps both applicable warnings when restart fallback is unavailable', () => {
emitBrowserCookieImportToast(
{
...summary,
importedCookies: 1,
skippedCookies: 2,
googleCookiesSkipped: 1,
warning: {
code: 'restart-fallback-unavailable',
loadedCookies: 1,
failedCookies: 1
}
},
'Imported 1 cookie.'
)
expect(successToastMock).not.toHaveBeenCalled()
expect(warningToastMock.mock.calls.map(([message]) => message)).toEqual([
'Imported 1 of 2 cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.',
'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.'
])
})
})
@@ -24,6 +24,18 @@ function formatCookieImportWarning(warning: CookieImportWarning): string {
}
}
function emitGoogleCookieImportWarning(summary: BrowserCookieImportSummary): void {
if (!summary.googleCookiesSkipped) {
return
}
toast.warning(
translate(
'auto.lib.browser.cookie.import.toast.googleCookiesSkipped',
'Google cookies were not imported. Open a browser in Orca with this profile, then sign into Google.'
)
)
}
// Why: a degraded import returns ok:true with a warning, so every call site must route it to a
// warning toast instead of reporting an unqualified success (#9355).
export function emitBrowserCookieImportToast(
@@ -33,7 +45,8 @@ export function emitBrowserCookieImportToast(
const warning = summary.warning
if (warning) {
toast.warning(formatCookieImportWarning(warning))
return
} else {
toast.success(successMessage)
}
toast.success(successMessage)
emitGoogleCookieImportWarning(summary)
}
+1
View File
@@ -1079,6 +1079,7 @@ export type BrowserCookieImportSummary = {
totalCookies: number
importedCookies: number
skippedCookies: number
googleCookiesSkipped?: number
domains: string[]
warning?: {
code: 'restart-fallback-unavailable'