Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/extension/src/__tests__/service_worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { enhancedSettings } from "@/services/settings/enhancedSettings"
import { Settings } from "@/services/settings/settings"
import { ServiceWorkerCommand } from "@/services/ipc"
import { openOptionPage } from "@/services/chrome"
import { NEW_HUB_URL, VERSION, SHORTCUT_NO_SELECTION_BEHAVIOR } from "@/const"
// Imported before any vi.doMock, so this is the real event name table.
import { ANALYTICS_EVENTS } from "@/services/analytics"
Expand Down Expand Up @@ -138,12 +139,8 @@ describe("Service Worker Migration", () => {

expect(mockEnhancedSettings.get).toHaveBeenCalled()
expect(mockSettings.set).not.toHaveBeenCalled()
expect(chrome.tabs.create).toHaveBeenCalledWith(
expect.objectContaining({
url: expect.stringContaining(
`addPageRule=${encodeURIComponent(newUrl)}`,
),
}),
expect(openOptionPage).toHaveBeenCalledWith(
expect.stringContaining(`addPageRule=${encodeURIComponent(newUrl)}`),
)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,8 +277,9 @@ export const CommandList = ({ control }: CommandListProps) => {
}
}

// location.search is fixed at page load (set via chrome.tabs.create), so it is
// safe to read it without adding it to the dependency array.
// location.search is fixed at page load (the service worker opens or navigates
// the tab with these params via openOptionPage), so it is safe to read it
// without adding it to the dependency array.
useEffect(() => {
if (editParamHandledRef.current) return
const params = new URLSearchParams(location.search)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ export const PageRuleList = ({
})

// Auto-open the dialog when addPageRule or editPageRule URL param is set.
// location.search is fixed at page load (set via chrome.tabs.create), so it is
// safe to read it without adding it to the dependency array.
// location.search is fixed at page load (the service worker opens or navigates
// the tab with these params via openOptionPage), so it is safe to read it
// without adding it to the dependency array.
useEffect(() => {
if (editParamHandledRef.current) return
const params = new URLSearchParams(location.search)
Expand Down
35 changes: 20 additions & 15 deletions packages/extension/src/service_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { CACHE_SECTIONS } from "@/services/settings/settingsCache"
import * as PageActionServiceWorker from "@/services/pageAction/serviceWorker"
import { ServiceWorkerData } from "@/services/serviceWorkerData"
import { ContextMenu } from "@/services/contextMenus"
import { closeWindow, windowExists, getCurrentTab } from "@/services/chrome"
import {
closeWindow,
windowExists,
getCurrentTab,
openOptionPage,
} from "@/services/chrome"
import { WindowStackManager } from "@/services/windowStackManager"
import { PopupAutoClose } from "@/services/popupAutoClose"
import { findMatchingPageRule, isEmpty } from "@/lib/utils"
Expand Down Expand Up @@ -155,9 +160,9 @@ const commandFuncs = {
[ServiceWorkerCommand.execApi]: ActionHelper.execApi,

[ServiceWorkerCommand.openOption]: (): boolean => {
chrome.tabs.create({
url: OPTION_PAGE_PATH,
})
openOptionPage().catch((e) =>
console.error("Failed to open option page", e),
)
return false
},

Expand All @@ -174,16 +179,16 @@ const commandFuncs = {
const pageRules = settings.pageRules ?? []
const matchingRule = findMatchingPageRule(pageRules, param.url)
if (matchingRule == null) {
chrome.tabs.create({
url: `${OPTION_PAGE_PATH}?addPageRule=${encodeURIComponent(param.url)}#pageRules`,
})
await openOptionPage(
`${OPTION_PAGE_PATH}?addPageRule=${encodeURIComponent(param.url)}#pageRules`,
)
} else {
chrome.tabs.create({
url: `${OPTION_PAGE_PATH}?editPageRule=${encodeURIComponent(matchingRule.urlPattern)}#pageRules`,
})
await openOptionPage(
`${OPTION_PAGE_PATH}?editPageRule=${encodeURIComponent(matchingRule.urlPattern)}#pageRules`,
)
}
}
add()
add().catch((e) => console.error("Failed to open option page", e))
return false
},

Expand Down Expand Up @@ -391,9 +396,7 @@ const updateActiveTabId = async (activeTabId?: number) => {
}

chrome.action.onClicked.addListener(() => {
chrome.tabs.create({
url: OPTION_PAGE_PATH,
})
openOptionPage().catch((e) => console.error("Failed to open option page", e))
})

chrome.windows.onFocusChanged.addListener(async (windowId: number) => {
Expand Down Expand Up @@ -766,7 +769,9 @@ chrome.commands.onCommand.addListener(handleShortcutCommand)

// Expose only in the E2E worker for Playwright evaluation, without a message endpoint.
if (import.meta.env.MODE === "e2e") {
Object.assign(globalThis, { __e2eHandleShortcutCommand: handleShortcutCommand })
Object.assign(globalThis, {
__e2eHandleShortcutCommand: handleShortcutCommand,
})
}

// Side panel is an optional capability (e.g. not available on Opera), so
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { openOptionPage } from "../chrome"

vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() }))

const BASE_URL = "chrome-extension://test-extension-id/"
const OPTION_URL = `${BASE_URL}src/options_page.html`

/**
* Mock chrome.tabs.query to return all tabs, or the active tab of the last
* focused window when queried with `active: true`.
*/
const mockTabs = (tabs: Partial<chrome.tabs.Tab>[], currentWindowId = 1) => {
vi.mocked(chrome.tabs.query).mockImplementation(((
info: chrome.tabs.QueryInfo,
) =>
Promise.resolve(
info.active
? [{ id: 100, windowId: currentWindowId, url: "https://example.com/" }]
: tabs,
)) as typeof chrome.tabs.query)
}

describe("openOptionPage", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(chrome.runtime.getURL).mockImplementation(
(path: string) => `${BASE_URL}${path}`,
)
vi.mocked(chrome.tabs.create).mockImplementation(() =>
Promise.resolve({ id: 20 } as chrome.tabs.Tab),
)
vi.mocked(chrome.tabs.update).mockImplementation(
async (tabId) => ({ id: tabId }) as chrome.tabs.Tab,
)
;(chrome.windows as any).update = vi.fn().mockResolvedValue({})
})

it("COP-01: creates a new tab when no options page is open", async () => {
mockTabs([{ id: 1, windowId: 1, url: "https://example.com/" }])

const result = await openOptionPage()

expect(chrome.tabs.create).toHaveBeenCalledWith({
url: "src/options_page.html",
})
expect(chrome.tabs.update).not.toHaveBeenCalled()
expect(result).toEqual({ tab: { id: 20 }, created: true })
})

it("COP-02: activates the existing tab without navigating", async () => {
mockTabs([{ id: 5, windowId: 2, url: `${OPTION_URL}#commands` }])

const result = await openOptionPage()

expect(chrome.tabs.create).not.toHaveBeenCalled()
expect(chrome.tabs.update).toHaveBeenCalledWith(5, { active: true })
expect(chrome.windows.update).toHaveBeenCalledWith(2, { focused: true })
expect(result).toEqual({ tab: { id: 5 }, created: false })
})

it("COP-03: navigates the existing tab when a query is given", async () => {
mockTabs([{ id: 5, windowId: 2, url: OPTION_URL }])

await openOptionPage("src/options_page.html?addPageRule=x#pageRules")

expect(chrome.tabs.create).not.toHaveBeenCalled()
expect(chrome.tabs.update).toHaveBeenCalledWith(5, {
active: true,
url: `${OPTION_URL}?addPageRule=x#pageRules`,
})
expect(chrome.windows.update).toHaveBeenCalledWith(2, { focused: true })
})

it("COP-04: prefers the tab in the last focused window", async () => {
mockTabs(
[
{ id: 5, windowId: 2, url: OPTION_URL },
{ id: 6, windowId: 3, url: OPTION_URL },
],
3,
)

await openOptionPage()

expect(chrome.tabs.update).toHaveBeenCalledWith(6, { active: true })
expect(chrome.windows.update).toHaveBeenCalledWith(3, { focused: true })
})

it("COP-05: reuses a tab that is still loading the options page", async () => {
mockTabs([{ id: 5, windowId: 1, url: "", pendingUrl: OPTION_URL }])

await openOptionPage()

expect(chrome.tabs.create).not.toHaveBeenCalled()
expect(chrome.tabs.update).toHaveBeenCalledWith(5, { active: true })
})

it("COP-06: does not reuse the excluded tab", async () => {
mockTabs([{ id: 5, windowId: 1, url: OPTION_URL }])

const result = await openOptionPage(undefined, 5)

expect(chrome.tabs.update).not.toHaveBeenCalled()
expect(result).toEqual({ tab: { id: 20 }, created: true })
})
})
43 changes: 42 additions & 1 deletion packages/extension/src/services/chrome.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { sleep, toUrl, isOverBytes, needsClipboard } from "@/lib/utils"
import type { ScreenSize } from "@/services/dom"
import type { ShowToastParam, UrlParam, WindowLayer } from "@/types"
import { POPUP_OFFSET, POPUP_TYPE, WINDOW_STATE } from "@/const"
import {
OPTION_PAGE_PATH,
POPUP_OFFSET,
POPUP_TYPE,
WINDOW_STATE,
} from "@/const"
import { PopupOption } from "@/services/option/defaultSettings"
import { ServiceWorkerData } from "@/services/serviceWorkerData"
import { WindowStackManager } from "@/services/windowStackManager"
Expand Down Expand Up @@ -507,6 +512,42 @@ export async function getCurrentTab(): Promise<chrome.tabs.Tab | undefined> {
return tab
}

/**
* Open the options page, reusing an existing options page tab if any.
* A tab in the last focused window is preferred over tabs in other windows.
* @param path - The options page path. It may include a query and a hash.
* @param excludeTabId - A tab not to reuse, e.g. one that is being closed.
* @returns The tab showing the options page, and whether it was newly created.
*/
export async function openOptionPage(
path: string = OPTION_PAGE_PATH,
excludeTabId?: number,
): Promise<{ tab: chrome.tabs.Tab | undefined; created: boolean }> {
const optionPageUrl = chrome.runtime.getURL(OPTION_PAGE_PATH)
const [tabs, currentTab] = await Promise.all([
chrome.tabs.query({}),
getCurrentTab(),
])
// A tab that is still loading has its destination only in pendingUrl.
const candidates = tabs.filter(
(t) =>
t.id !== excludeTabId &&
(t.pendingUrl || t.url)?.startsWith(optionPageUrl),
)
const existing =
candidates.find((t) => t.windowId === currentTab?.windowId) ?? candidates[0]
if (existing?.id == null) {
const tab = await chrome.tabs.create({ url: path })
return { tab, created: true }
}
// Navigate only when parameters are given, to keep the current page state.
const props: chrome.tabs.UpdateProperties = { active: true }
if (path !== OPTION_PAGE_PATH) props.url = chrome.runtime.getURL(path)
const tab = await chrome.tabs.update(existing.id, props)
await chrome.windows.update(existing.windowId, { focused: true })
return { tab: tab ?? existing, created: false }
}

/**
* Close a window.
* @param windowId - The ID of the window to close.
Expand Down
Loading
Loading