From b6611212586395ad893b620d43e14341a8522fe6 Mon Sep 17 00:00:00 2001 From: WebDevNerdStuff Date: Sat, 17 Oct 2026 18:32:15 -0400 Subject: [PATCH] Fix save badge errors and skip the bank button's landing page - Ignore "No tab with id" when a tab closes before its badge is set - Remember where each bank's button lands and don't offer to save it - Center the badge plus with a full-width glyph and white text --- PRIVACY.md | 1 + README.md | 2 +- src/background.ts | 11 ++++- src/badge.ts | 29 +++++++++---- src/bank-landing.ts | 70 ++++++++++++++++++++++++++++++ src/open.ts | 3 ++ src/popup/popup.ts | 10 ++++- src/saved-cards.ts | 38 ++++++++++++++++- tests/background.test.ts | 35 +++++++++++++-- tests/badge.test.ts | 41 ++++++++++++++---- tests/bank-landing.test.ts | 83 ++++++++++++++++++++++++++++++++++++ tests/helpers/chrome-mock.ts | 6 ++- tests/open.test.ts | 24 +++++++++-- tests/popup.test.ts | 8 ++++ tests/saved-cards.test.ts | 32 ++++++++++++++ 15 files changed, 362 insertions(+), 31 deletions(-) create mode 100644 src/bank-landing.ts create mode 100644 tests/bank-landing.test.ts diff --git a/PRIVACY.md b/PRIVACY.md index f89771a..645dbfb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -10,6 +10,7 @@ All of this is kept in your browser's extension storage. CardHop never sends it - **Your settings:** which banks you chose, the names you gave their buttons, whether to skip the popup, where card pages open, and the checkout prompt options. Saved with your browser's extension settings, which your browser may sync to your other devices if you use browser sync. - **Saved card pages:** the links to card pages you chose to save, and the names you gave them. Kept only on this device, never synced. Delete them one at a time in Settings, or all at once with **Forget my cards**. +- **Where each bank's button lands:** the card page a bank opens when you use its main CardHop button (Capital One adds your default card to the address), so CardHop doesn't offer to save a page that button already opens. Kept only on this device, never synced, replaced each time you use the button, and removed by **Forget my cards**. - **Sites where you turned off the checkout prompt:** kept only on this device, never synced. - **The tab CardHop opened:** while you sign in to Capital One, CardHop remembers which tab it opened for up to 10 minutes, so it can send that tab back to your virtual cards. This is cleared when the browser closes. diff --git a/README.md b/README.md index 94115f6..c0e8cd7 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Citi doesn't have this setting yet. To add it for a bank, give that bank a `sign Capital One's virtual cards page opens on your first card after you sign in. To jump straight to another card, save its page: -- Open that card's virtual cards page, then open CardHop and click **Save**. The CardHop icon shows a **+** on card pages you haven't saved. (Both need returning after sign-in turned on, so CardHop can see the page address.) Or +- Open that card's virtual cards page, then open CardHop and click **Save**. The CardHop icon shows a **+** on card pages you haven't saved, except the page the bank's own button opens. (Both need returning after sign-in turned on, so CardHop can see the page address.) Or - Paste the page's link under **Settings → Card buttons**. Until you save one, the popup shows a short tip explaining this; **Got it** hides it. diff --git a/src/background.ts b/src/background.ts index 85e2b94..1adab18 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,9 +1,10 @@ import { refreshSaveBadges, updateSaveBadge } from './badge.ts'; +import { forgetBankLanding, handleBankLandingUpdate } from './bank-landing.ts'; import { syncCheckoutScript } from './checkout-access.ts'; import { buildCheckoutButtons, isCheckoutButtonsRequest, isCheckoutFrameMessage, isOpenOptionsRequest } from './checkout-messages.ts'; import { ext } from './ext.ts'; import { isOpenBankMessage, openSavedCard, openVirtualCards } from './open.ts'; -import { getSavedCards, SAVED_CARDS_KEY } from './saved-cards.ts'; +import { BANK_LANDINGS_KEY, getSavedCards, SAVED_CARDS_KEY } from './saved-cards.ts'; import { applyActionMode, BANK_IDS, bankLabel, canOpenDirectly, cardMenuId, getSettings, menuId } from './shared.ts'; import { forgetTab, handleTabUpdated } from './sign-in-return.ts'; @@ -96,6 +97,8 @@ ext.storage.onChanged.addListener((changes, area) => { if (banksChanged || cardsChanged) { void syncMenus(); void refreshSaveBadges(); + } else if (area === 'local' && changes[BANK_LANDINGS_KEY] !== undefined) { + void refreshSaveBadges(); } if (banksChanged || cardsChanged || (area === 'sync' && changes.openDirectly !== undefined)) { @@ -129,10 +132,14 @@ ext.contextMenus.onClicked.addListener(async (info, tab) => { ext.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { void handleTabUpdated(tabId, changeInfo, tab); if (changeInfo.url !== undefined || changeInfo.status === 'complete') { - void updateSaveBadge(tabId, tab.url); + // Record a bank button's landing page first, so the badge already knows to skip it. + void handleBankLandingUpdate(tabId, tab.url) + .catch(() => undefined) + .then(() => updateSaveBadge(tabId, tab.url)); } }); ext.tabs.onRemoved.addListener((tabId) => { void forgetTab(tabId); + void forgetBankLanding(tabId); }); diff --git a/src/badge.ts b/src/badge.ts index d3220b2..7148c10 100644 --- a/src/badge.ts +++ b/src/badge.ts @@ -1,27 +1,38 @@ import { ext } from './ext.ts'; -import { bankForCardUrl, getSavedCards, isSaved } from './saved-cards.ts'; +import { bankForCardUrl, getBankLandings, getSavedCards, isBankPage, isSaved } from './saved-cards.ts'; import { getSettings } from './shared.ts'; -export const SAVE_BADGE_TEXT = '+'; +// The full-width plus is drawn in the middle of its box; a plain "+" sits off-centre in the badge. +export const SAVE_BADGE_TEXT = '\uFF0B'; export const SAVE_BADGE_TITLE = 'CardHop: save this card page'; +// The brand blue. const BADGE_COLOR = '#0b3b60'; +const BADGE_TEXT_COLOR = '#ffffff'; // Shows a "+" on the toolbar icon while a tab is on a card page CardHop could save. The browser only // reveals addresses on sites CardHop has permission for, so other tabs report no address and get no badge. +// The pages a bank's own button opens are skipped: that button already gets there. export async function updateSaveBadge(tabId: number, url: string | undefined): Promise { const bankId = url === undefined ? undefined : bankForCardUrl(url); let offer = false; if (bankId !== undefined && url !== undefined) { - const [{ banks }, cards] = await Promise.all([getSettings(), getSavedCards()]); - offer = banks.includes(bankId) && !isSaved(url, cards); + const [{ banks }, cards, landings] = await Promise.all([getSettings(), getSavedCards(), getBankLandings()]); + offer = banks.includes(bankId) && !isSaved(url, cards) && !isBankPage(url, landings); } - await ext.action.setBadgeText({ tabId, text: offer ? SAVE_BADGE_TEXT : '' }); - if (offer) { - await ext.action.setBadgeBackgroundColor({ tabId, color: BADGE_COLOR }); + // The tab can close while settings load, and the browser then rejects with "No tab with id". + // Nothing is left to badge, so the error is dropped. + try { + await ext.action.setBadgeText({ tabId, text: offer ? SAVE_BADGE_TEXT : '' }); + if (offer) { + await ext.action.setBadgeBackgroundColor({ tabId, color: BADGE_COLOR }); + await ext.action.setBadgeTextColor({ tabId, color: BADGE_TEXT_COLOR }); + } + // An empty tab title falls back to the extension's default title. + await ext.action.setTitle({ tabId, title: offer ? SAVE_BADGE_TITLE : '' }); + } catch { + // The tab is gone. } - // An empty tab title falls back to the extension's default title. - await ext.action.setTitle({ tabId, title: offer ? SAVE_BADGE_TITLE : '' }); } // After saving or deleting a card, or changing banks, recheck every tab CardHop can see. diff --git a/src/bank-landing.ts b/src/bank-landing.ts new file mode 100644 index 0000000..2610f2e --- /dev/null +++ b/src/bank-landing.ts @@ -0,0 +1,70 @@ +import { ext } from './ext.ts'; +import { bankForCardUrl, setBankLanding } from './saved-cards.ts'; +import { BANKS, isBankId } from './shared.ts'; +import type { BankId } from './shared.ts'; +import { TRACK_FOR_MS } from './sign-in-return.ts'; + +// Long enough to sign in first, including two-step verification. +export const WATCH_FOR_MS = TRACK_FOR_MS; + +const KEY_PREFIX = 'bankLanding:'; + +interface WatchedTab { + bankId: BankId; + expiresAt: number; +} + +function keyFor(tabId: number): string { + return `${KEY_PREFIX}${tabId}`; +} + +function isWatchedTab(value: unknown): value is WatchedTab { + if (typeof value !== 'object' || value === null) { + return false; + } + const watched = value as Record; + return isBankId(watched.bankId) && typeof watched.expiresAt === 'number'; +} + +async function getWatched(tabId: number): Promise { + const key = keyFor(tabId); + const stored = await ext.storage.session.get(key); + const watched = stored[key]; + return isWatchedTab(watched) ? watched : undefined; +} + +// Session storage keeps this across background restarts while the user signs in. +export async function watchBankLanding(tabId: number, bankId: BankId, now = Date.now()): Promise { + const watched: WatchedTab = { bankId, expiresAt: now + WATCH_FOR_MS }; + await ext.storage.session.set({ [keyFor(tabId)]: watched }); +} + +export async function forgetBankLanding(tabId: number): Promise { + await ext.storage.session.remove(keyFor(tabId)); +} + +// Records the card page a tab opened by a bank's button lands on, after any sign-in. +export async function handleBankLandingUpdate(tabId: number, url: string | undefined, now = Date.now()): Promise { + const watched = await getWatched(tabId); + if (!watched) { + return; + } + + if (now > watched.expiresAt) { + await forgetBankLanding(tabId); + return; + } + + // Sign-in pages are hidden from CardHop or aren't card pages: keep watching. + if (url === undefined || bankForCardUrl(url) !== watched.bankId) { + return; + } + // The bank hasn't added the card to the address yet. + if (new URL(url).href === new URL(BANKS[watched.bankId].url).href) { + return; + } + + // Only the first card page counts: anything after it is the user switching cards. + await forgetBankLanding(tabId); + await setBankLanding(watched.bankId, url); +} diff --git a/src/open.ts b/src/open.ts index 1b4d841..f58211d 100644 --- a/src/open.ts +++ b/src/open.ts @@ -1,3 +1,4 @@ +import { forgetBankLanding, watchBankLanding } from './bank-landing.ts'; import { ext } from './ext.ts'; import { findSavedCard } from './saved-cards.ts'; import { BANKS, getSettings, isBankId, OPEN_IN } from './shared.ts'; @@ -54,6 +55,8 @@ async function openPage(bankId: BankId, url: string, tabId?: number, options: Op if (tab?.id !== undefined) { await trackTab(tab.id, bankId, { url }); + // Only the bank's own button lands on a page CardHop shouldn't offer to save. + await (url === BANKS[bankId].url ? watchBankLanding(tab.id, bankId) : forgetBankLanding(tab.id)); } } diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 4cd47d9..ae69025 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -8,12 +8,14 @@ import { CardPageError, defaultLabel, forgetAllCards, + getBankLandings, getSavedCards, + isBankPage, isSaved, removeSavedCard, renameSavedCard, } from '../saved-cards.ts'; -import type { SavedCard } from '../saved-cards.ts'; +import type { BankLandings, SavedCard } from '../saved-cards.ts'; import { BANK_IDS, bankLabel, @@ -78,6 +80,7 @@ const orList = new Intl.ListFormat('en', { type: 'disjunction' }); let settings: Settings = DEFAULT_SETTINGS; let savedCards: SavedCard[] = []; +let bankLandings: BankLandings = {}; // The page in the active tab, when it is a card page CardHop could offer to save. let currentCardUrl: string | undefined; @@ -168,7 +171,8 @@ function renderSignInReturnNotices(banks: BankId[]): void { // sites CardHop has permission for, so this appears once returning after sign-in is turned on. function renderSaveCurrent(banks: BankId[]): void { const bankId = currentCardUrl === undefined ? undefined : bankForCardUrl(currentCardUrl); - const show = bankId !== undefined && banks.includes(bankId) && currentCardUrl !== undefined && !isSaved(currentCardUrl, savedCards); + const show = bankId !== undefined && banks.includes(bankId) && currentCardUrl !== undefined + && !isSaved(currentCardUrl, savedCards) && !isBankPage(currentCardUrl, bankLandings); saveCurrent.hidden = !show; if (show) { saveCurrentLabel.placeholder = defaultLabel(bankId, savedCards); @@ -330,6 +334,7 @@ function renderBankState(banks: BankId[]): void { async function reload(): Promise { settings = await getSettings(); savedCards = await getSavedCards(); + bankLandings = await getBankLandings(); renderBankState(selectedBanks()); } @@ -488,6 +493,7 @@ async function init(): Promise { settings = await getSettings(); savedCards = await getSavedCards(); + bankLandings = await getBankLandings(); openDirectlyInput.checked = settings.openDirectly; openInSelect.value = settings.openIn; diff --git a/src/saved-cards.ts b/src/saved-cards.ts index 62450cc..3e7d1d5 100644 --- a/src/saved-cards.ts +++ b/src/saved-cards.ts @@ -116,5 +116,41 @@ export async function removeSavedCard(id: string): Promise { } export async function forgetAllCards(): Promise { - await ext.storage.local.remove(SAVED_CARDS_KEY); + await ext.storage.local.remove([SAVED_CARDS_KEY, BANK_LANDINGS_KEY]); +} + +// Where each bank's button last landed. Capital One adds the default card to its general page's address, +// which then looks like any card page, but the bank button already opens it, so CardHop doesn't offer to +// save it. Kept on this device only, like saved card pages. +export const BANK_LANDINGS_KEY = 'bankLandings'; + +export type BankLandings = Partial>; + +export async function getBankLandings(): Promise { + const stored = await ext.storage.local.get(BANK_LANDINGS_KEY); + const landings: unknown = stored[BANK_LANDINGS_KEY]; + if (typeof landings !== 'object' || landings === null) { + return {}; + } + return Object.fromEntries(Object.entries(landings).filter(([bankId, url]) => ( + isBankId(bankId) && typeof url === 'string' && bankForCardUrl(url) === bankId + ))); +} + +export async function setBankLanding(bankId: BankId, url: string): Promise { + const href = new URL(url.trim()).href; + const landings = await getBankLandings(); + if (landings[bankId] !== href) { + await ext.storage.local.set({ [BANK_LANDINGS_KEY]: { ...landings, [bankId]: href } }); + } +} + +// True for the pages a bank's own button opens: its general virtual cards page, or where that last landed. +export function isBankPage(url: string, landings: BankLandings): boolean { + const bankId = bankForCardUrl(url); + if (bankId === undefined) { + return false; + } + const href = new URL(url.trim()).href; + return href === new URL(BANKS[bankId].url).href || href === landings[bankId]; } diff --git a/tests/background.test.ts b/tests/background.test.ts index adf2271..5185b8d 100644 --- a/tests/background.test.ts +++ b/tests/background.test.ts @@ -10,7 +10,8 @@ const { api } = chromeMock; // Importing registers the background script's listeners on the mock. await import('../src/background.ts'); const { BANKS } = await import('../src/shared.ts'); -const { addSavedCard, SAVED_CARDS_KEY } = await import('../src/saved-cards.ts'); +const { SAVE_BADGE_TEXT } = await import('../src/badge.ts'); +const { addSavedCard, BANK_LANDINGS_KEY, getBankLandings, SAVED_CARDS_KEY } = await import('../src/saved-cards.ts'); const SECOND_CARD = 'https://myaccounts.capitalone.com/vc/virtual-cards/home?arid=abc'; @@ -301,7 +302,7 @@ describe('open requests from the popup', () => { it('starts watching the new tab for a sign-in detour', async () => { chromeMock.grant('https://myaccounts.capitalone.com/*'); await fire(api.runtime.onMessage, { type: 'openBank', bankId: 'capitalOne' }); - assert.deepEqual(Object.keys(chromeMock.getSession()), ['signInReturn:101']); + assert.deepEqual(Object.keys(chromeMock.getSession()), ['signInReturn:101', 'bankLanding:101']); }); it('ignores other messages', async () => { @@ -393,7 +394,35 @@ describe('custom names and the save badge', () => { it('updates the badge when a tab finishes loading', async () => { chromeMock.setStored({ banks: ['capitalOne'] }); await fire(api.tabs.onUpdated, 4, { status: 'complete' }, { url: SECOND_CARD }); - assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId: 4, text: '+' }]]); + assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId: 4, text: SAVE_BADGE_TEXT }]]); + }); + + it('records where the bank button lands and shows no badge there', async () => { + const landing = `${BANKS.capitalOne.url}?arid=default`; + chromeMock.setStored({ banks: ['capitalOne'] }); + await fire(api.contextMenus.onClicked, { menuItemId: 'open-capitalOne' }); + const tabId = 101; + + await fire(api.tabs.onUpdated, tabId, { url: landing }, { url: landing }); + + assert.deepEqual(await getBankLandings(), { capitalOne: landing }); + assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId, text: '' }]]); + }); + + it('rechecks the badges when the bank button lands somewhere new', async () => { + await fire(api.storage.onChanged, { [BANK_LANDINGS_KEY]: { newValue: {} } }, 'local'); + assert.equal(api.tabs.query.mock.callCount(), 1); + assert.equal(api.contextMenus.removeAll.mock.callCount(), 0, 'menus are unaffected'); + }); + + it('stops watching for the landing page when a saved card replaces the tab', async () => { + chromeMock.setStored({ banks: ['capitalOne'], openIn: 'currentTab' }); + await fire(api.contextMenus.onClicked, { menuItemId: 'open-capitalOne' }, { id: 5 }); + const card = await addSavedCard(SECOND_CARD, 'Second'); + await fire(api.contextMenus.onClicked, { menuItemId: `open-card-${card.id}` }, { id: 5 }); + + await fire(api.tabs.onUpdated, 5, { url: SECOND_CARD }, { url: SECOND_CARD }); + assert.deepEqual(await getBankLandings(), {}); }); }); diff --git a/tests/badge.test.ts b/tests/badge.test.ts index 0d19195..7b7fa59 100644 --- a/tests/badge.test.ts +++ b/tests/badge.test.ts @@ -7,13 +7,15 @@ chromeMock.install(); const { api } = chromeMock; const { refreshSaveBadges, SAVE_BADGE_TEXT, SAVE_BADGE_TITLE, updateSaveBadge } = await import('../src/badge.ts'); -const { addSavedCard } = await import('../src/saved-cards.ts'); +const { addSavedCard, BANK_LANDINGS_KEY } = await import('../src/saved-cards.ts'); +const { BANKS } = await import('../src/shared.ts'); const CARD_PAGE = 'https://myaccounts.capitalone.com/vc/virtual-cards/home?arid=second'; const TAB = 4; -function lastBadge(): string | undefined { - return callArgs(api.action.setBadgeText).at(-1)?.[0].text; +// Whether the last badge set offers to save the page. +function offered(): boolean { + return callArgs(api.action.setBadgeText).at(-1)?.[0].text === SAVE_BADGE_TEXT; } beforeEach(() => { @@ -25,31 +27,54 @@ describe('save badge', () => { it('shows a + on an unsaved card page, with a helpful tooltip', async () => { await updateSaveBadge(TAB, CARD_PAGE); assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId: TAB, text: SAVE_BADGE_TEXT }]]); + assert.deepEqual(callArgs(api.action.setBadgeTextColor), [[{ tabId: TAB, color: '#ffffff' }]]); assert.deepEqual(callArgs(api.action.setTitle), [[{ tabId: TAB, title: SAVE_BADGE_TITLE }]]); }); it('clears it once the page is saved', async () => { await addSavedCard(CARD_PAGE, 'Second'); await updateSaveBadge(TAB, CARD_PAGE); - assert.equal(lastBadge(), ''); + assert.deepEqual(callArgs(api.action.setBadgeText), [[{ tabId: TAB, text: '' }]]); + assert.deepEqual(callArgs(api.action.setTitle), [[{ tabId: TAB, title: '' }]]); }); it('clears it on pages that are not card pages, or hidden from CardHop', async () => { await updateSaveBadge(TAB, 'https://myaccounts.capitalone.com/accountSummary'); - assert.equal(lastBadge(), ''); + assert.equal(offered(), false); await updateSaveBadge(TAB, undefined); - assert.equal(lastBadge(), ''); + assert.equal(offered(), false); }); it('does not offer card pages for banks that are not selected', async () => { chromeMock.setStored({ banks: ['citi'] }); await updateSaveBadge(TAB, CARD_PAGE); - assert.equal(lastBadge(), ''); + assert.equal(offered(), false); + }); + + it('skips the pages the bank button opens', async () => { + await updateSaveBadge(TAB, BANKS.capitalOne.url); + assert.equal(offered(), false); + + chromeMock.setLocal({ [BANK_LANDINGS_KEY]: { capitalOne: CARD_PAGE } }); + await updateSaveBadge(TAB, CARD_PAGE); + assert.equal(offered(), false); + }); + + it('ignores tabs that close before the badge is set', async () => { + const closed = async () => { + throw new Error(`No tab with id: ${TAB}.`); + }; + api.action.setBadgeText.mock.mockImplementationOnce(closed); + await updateSaveBadge(TAB, CARD_PAGE); + + chromeMock.setActiveTabUrl(CARD_PAGE); + api.action.setTitle.mock.mockImplementationOnce(closed); + await refreshSaveBadges(); }); it('refreshes every tab CardHop can see', async () => { chromeMock.setActiveTabUrl(CARD_PAGE); await refreshSaveBadges(); - assert.equal(lastBadge(), SAVE_BADGE_TEXT); + assert.equal(offered(), true); }); }); diff --git a/tests/bank-landing.test.ts b/tests/bank-landing.test.ts new file mode 100644 index 0000000..52bcf0c --- /dev/null +++ b/tests/bank-landing.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { beforeEach, describe, it } from 'node:test'; +import { createChromeMock } from './helpers/chrome-mock.ts'; + +const chromeMock = createChromeMock(); +chromeMock.install(); + +const { + forgetBankLanding, + handleBankLandingUpdate, + watchBankLanding, + WATCH_FOR_MS, +} = await import('../src/bank-landing.ts'); +const { BANK_LANDINGS_KEY, getBankLandings } = await import('../src/saved-cards.ts'); +const { BANKS } = await import('../src/shared.ts'); + +const TAB = 7; +const NOW = 1_000_000; +// Capital One adds the default card to the general page's address. +const LANDING = `${BANKS.capitalOne.url}?arid=default`; +const OTHER_CARD = `${BANKS.capitalOne.url}?arid=other`; + +beforeEach(() => chromeMock.reset()); + +describe('where the bank button lands', () => { + it('records the card page the bank adds to its general page', async () => { + await watchBankLanding(TAB, 'capitalOne', NOW); + await handleBankLandingUpdate(TAB, BANKS.capitalOne.url, NOW); + assert.deepEqual(await getBankLandings(), {}, 'nothing to record until the card is in the address'); + + await handleBankLandingUpdate(TAB, LANDING, NOW + 1); + assert.deepEqual(await getBankLandings(), { capitalOne: LANDING }); + }); + + it('keeps watching through sign-in', async () => { + await watchBankLanding(TAB, 'capitalOne', NOW); + await handleBankLandingUpdate(TAB, undefined, NOW + 1); + await handleBankLandingUpdate(TAB, 'https://myaccounts.capitalone.com/accountSummary', NOW + 2); + assert.deepEqual(await getBankLandings(), {}); + + await handleBankLandingUpdate(TAB, LANDING, NOW + 3); + assert.deepEqual(await getBankLandings(), { capitalOne: LANDING }); + }); + + it('replaces the page recorded last time', async () => { + chromeMock.setLocal({ [BANK_LANDINGS_KEY]: { capitalOne: OTHER_CARD } }); + await watchBankLanding(TAB, 'capitalOne', NOW); + await handleBankLandingUpdate(TAB, LANDING, NOW); + assert.deepEqual(await getBankLandings(), { capitalOne: LANDING }); + }); + + it('ignores tabs the bank button did not open', async () => { + await handleBankLandingUpdate(TAB, LANDING, NOW); + assert.deepEqual(await getBankLandings(), {}); + }); + + it('ignores another bank’s pages', async () => { + await watchBankLanding(TAB, 'citi', NOW); + await handleBankLandingUpdate(TAB, LANDING, NOW); + assert.deepEqual(await getBankLandings(), {}); + }); + + it('stops watching once it lands, so switching cards changes nothing', async () => { + await watchBankLanding(TAB, 'capitalOne', NOW); + await handleBankLandingUpdate(TAB, LANDING, NOW); + await handleBankLandingUpdate(TAB, OTHER_CARD, NOW + 1); + assert.deepEqual(await getBankLandings(), { capitalOne: LANDING }); + assert.deepEqual(chromeMock.getSession(), {}); + }); + + it('stops watching a tab that never lands', async () => { + await watchBankLanding(TAB, 'capitalOne', NOW); + await handleBankLandingUpdate(TAB, LANDING, NOW + WATCH_FOR_MS + 1); + assert.deepEqual(await getBankLandings(), {}); + assert.deepEqual(chromeMock.getSession(), {}); + }); + + it('forgets a tab on request', async () => { + await watchBankLanding(TAB, 'capitalOne', NOW); + await forgetBankLanding(TAB); + assert.deepEqual(chromeMock.getSession(), {}); + }); +}); diff --git a/tests/helpers/chrome-mock.ts b/tests/helpers/chrome-mock.ts index c31ca58..063a1f9 100644 --- a/tests/helpers/chrome-mock.ts +++ b/tests/helpers/chrome-mock.ts @@ -84,8 +84,8 @@ export function createChromeMock() { set: mock.fn(async (items: Stored) => { localStore = { ...localStore, ...structuredClone(items) }; }), - remove: mock.fn(async (key: string) => { - delete localStore[key]; + remove: mock.fn(async (keys: string | string[]) => { + [keys].flat().forEach((key) => delete localStore[key]); }), }, onChanged: createEvent<[changes: Record, area: string]>(), @@ -125,6 +125,7 @@ export function createChromeMock() { setPopup: mock.fn(async (_details: { popup: string }) => {}), setBadgeText: mock.fn(async (_details: { tabId?: number; text: string }) => {}), setBadgeBackgroundColor: mock.fn(async (_details: { tabId?: number; color: string }) => {}), + setBadgeTextColor: mock.fn(async (_details: { tabId?: number; color: string }) => {}), setTitle: mock.fn(async (_details: { tabId?: number; title: string }) => {}), onClicked: createEvent<[tab: Tab]>(), }, @@ -181,6 +182,7 @@ export function createChromeMock() { api.action.setPopup, api.action.setBadgeText, api.action.setBadgeBackgroundColor, + api.action.setBadgeTextColor, api.action.setTitle, api.contextMenus.create, api.contextMenus.removeAll, diff --git a/tests/open.test.ts b/tests/open.test.ts index 8cb9a2f..b4e9430 100644 --- a/tests/open.test.ts +++ b/tests/open.test.ts @@ -16,6 +16,11 @@ const CAPITAL_ONE_SITE = 'https://myaccounts.capitalone.com/*'; beforeEach(() => chromeMock.reset()); +// Tabs being watched for a sign-in detour (session storage also holds bank-button landing watches). +function signInWatches(): string[] { + return Object.keys(chromeMock.getSession()).filter((key) => key.startsWith('signInReturn:')); +} + describe('openVirtualCards', () => { it('opens the bank in a new tab by default', async () => { await openVirtualCards('citi', 7); @@ -74,24 +79,37 @@ describe('watching for a sign-in detour', () => { it('watches the new tab when return-after-sign-in is enabled', async () => { chromeMock.grant(CAPITAL_ONE_SITE); await openVirtualCards('capitalOne'); - assert.deepEqual(Object.keys(chromeMock.getSession()), [`signInReturn:${NEW_TAB_ID}`]); + assert.deepEqual(signInWatches(), [`signInReturn:${NEW_TAB_ID}`]); }); it('watches the replaced tab when opening in the current tab', async () => { chromeMock.grant(CAPITAL_ONE_SITE); chromeMock.setStored({ openIn: 'currentTab' }); await openVirtualCards('capitalOne'); - assert.deepEqual(Object.keys(chromeMock.getSession()), [`signInReturn:${ACTIVE_TAB_ID}`]); + assert.deepEqual(signInWatches(), [`signInReturn:${ACTIVE_TAB_ID}`]); }); it('watches nothing when the permission has not been granted', async () => { await openVirtualCards('capitalOne'); - assert.deepEqual(chromeMock.getSession(), {}); + assert.deepEqual(signInWatches(), []); }); it('watches nothing for banks that don’t need it', async () => { chromeMock.grant(CAPITAL_ONE_SITE); await openVirtualCards('citi'); + assert.deepEqual(signInWatches(), []); + }); +}); + +describe('watching where the bank button lands', () => { + it('watches the tab the bank button opens', async () => { + await openVirtualCards('citi'); + assert.deepEqual(Object.keys(chromeMock.getSession()), [`bankLanding:${NEW_TAB_ID}`]); + }); + + it('does not watch a saved card page', async () => { + const card = await addSavedCard(SECOND_CARD, 'Second'); + await openSavedCard(card.id); assert.deepEqual(chromeMock.getSession(), {}); }); }); diff --git a/tests/popup.test.ts b/tests/popup.test.ts index 96921c8..6526e34 100644 --- a/tests/popup.test.ts +++ b/tests/popup.test.ts @@ -437,6 +437,14 @@ describe('saved card pages', () => { assert.equal(element(doc, '#save-current').hidden, true); }); + it('does not offer the card page the bank button landed on', async () => { + const { BANK_LANDINGS_KEY } = await import('../src/saved-cards.ts'); + chromeMock.setLocal({ [BANK_LANDINGS_KEY]: { capitalOne: SECOND_CARD } }); + chromeMock.setActiveTabUrl(SECOND_CARD); + const doc = await openPopup({ stored: { banks: ['capitalOne'] } }); + assert.equal(element(doc, '#save-current').hidden, true); + }); + it('does not offer a card page when its bank is not selected', async () => { chromeMock.setActiveTabUrl(SECOND_CARD); const doc = await openPopup({ stored: { banks: ['citi'] } }); diff --git a/tests/saved-cards.test.ts b/tests/saved-cards.test.ts index 59048bb..d3eecd4 100644 --- a/tests/saved-cards.test.ts +++ b/tests/saved-cards.test.ts @@ -7,12 +7,15 @@ chromeMock.install(); const { addSavedCard, + BANK_LANDINGS_KEY, bankForCardUrl, CardPageError, defaultLabel, findSavedCard, forgetAllCards, + getBankLandings, getSavedCards, + isBankPage, isSaved, MAX_LABEL_LENGTH, removeSavedCard, @@ -23,6 +26,7 @@ const { const SECOND_CARD = 'https://myaccounts.capitalone.com/vc/virtual-cards/home?analyticsTag=from_digital_card&arid=abc%3D'; const THIRD_CARD = 'https://myaccounts.capitalone.com/vc/virtual-cards/home?arid=xyz'; const CITI_PAGE = 'https://online.citi.com/US/ag/repcard/van2?card=1'; +const { BANKS } = await import('../src/shared.ts'); beforeEach(() => chromeMock.reset()); @@ -139,6 +143,12 @@ describe('deleting card pages', () => { assert.deepEqual(await getSavedCards(), []); assert.equal(SAVED_CARDS_KEY in chromeMock.getLocal(), false); }); + + it('forgets where the bank buttons landed too', async () => { + chromeMock.setLocal({ [BANK_LANDINGS_KEY]: { capitalOne: THIRD_CARD } }); + await forgetAllCards(); + assert.equal(BANK_LANDINGS_KEY in chromeMock.getLocal(), false); + }); }); describe('reading stored cards', () => { @@ -160,3 +170,25 @@ describe('reading stored cards', () => { assert.deepEqual(await getSavedCards(), []); }); }); + +describe('bank pages', () => { + it('counts the bank’s general page and the card page its button last landed on', () => { + const landings = { capitalOne: THIRD_CARD }; + assert.equal(isBankPage(BANKS.capitalOne.url, landings), true); + assert.equal(isBankPage(BANKS.citi.url, landings), true); + assert.equal(isBankPage(THIRD_CARD, landings), true); + assert.equal(isBankPage(SECOND_CARD, landings), false); + assert.equal(isBankPage(CITI_PAGE, landings), false); + assert.equal(isBankPage('https://example.com', landings), false); + }); + + it('ignores malformed or tampered landing pages', async () => { + chromeMock.setLocal({ + [BANK_LANDINGS_KEY]: { capitalOne: THIRD_CARD, citi: 'https://example.com/US/', other: THIRD_CARD }, + }); + assert.deepEqual(await getBankLandings(), { capitalOne: THIRD_CARD }); + + chromeMock.setLocal({ [BANK_LANDINGS_KEY]: 'nope' }); + assert.deepEqual(await getBankLandings(), {}); + }); +});