diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8307bf1..a17f711a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,3 +27,4 @@ jobs: run: yarn build env: CI: true + EXTENSION_KEY: ${{ secrets.EXTENSION_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 589d2f9c..8270cf0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: yarn build:extension env: CI: true + EXTENSION_KEY: ${{ secrets.EXTENSION_KEY }} VITE_MEASUREMENT_ID: ${{ secrets.VITE_MEASUREMENT_ID }} VITE_API_SECRET: ${{ secrets.VITE_API_SECRET }} VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6728359b..c1d57c68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,8 @@ jobs: e2e: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.1-jammy steps: - name: Checkout code @@ -56,26 +58,17 @@ jobs: - name: Build extension run: yarn build:e2e - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-chromium-${{ hashFiles('**/package.json', 'yarn.lock') }}-v1 - restore-keys: | - playwright-chromium- - - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium --with-deps - - - name: Install Playwright system dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium + env: + VITE_NEW_HUB_URL: ${{ secrets.VITE_NEW_HUB_URL }} + EXTENSION_KEY: ${{ secrets.EXTENSION_KEY }} - name: Run E2E tests run: yarn test:e2e + env: + VITE_NEW_HUB_URL: ${{ secrets.VITE_NEW_HUB_URL }} + CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }} + CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }} + PWDEBUG: ${{ secrets.PWDEBUG }} - name: Upload E2E test results if: failure() diff --git a/.gitignore b/.gitignore index e738af8b..ab2b75b0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ .env.development.local .env.test.local .env.production.local +.env.e2e .git tsconfig.tsbuildinfo diff --git a/packages/extension/e2e/const.ts b/packages/extension/e2e/const.ts new file mode 100644 index 00000000..5e8e5f2a --- /dev/null +++ b/packages/extension/e2e/const.ts @@ -0,0 +1,3 @@ +export const NEW_HUB_URL = + process.env.VITE_NEW_HUB_URL ?? + "https://selection-command-hub.siro-cola.workers.dev" diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 1e507beb..85ed61a3 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -7,11 +7,13 @@ import { import path from "path" import { fileURLToPath } from "url" import { attachSWConsole } from "./utils/logConsole" +import { fetchCfAuthToken } from "./utils/cfAuth" import { STORAGE_KEY, LOCAL_STORAGE_KEY, CMD_PREFIX, } from "@/services/storage/const" +import { NEW_HUB_URL } from "./const" import type { UserSettings, Command } from "@/types" import type { CommandMetadata } from "@/types/command" @@ -32,6 +34,7 @@ type Fixtures = { setUserSettings: (newSettings: Partial) => Promise getCommands: () => Promise isAllWindowsNormal: () => Promise + cfAccessCookie: string | null } /** @@ -55,12 +58,21 @@ export const test = base.extend({ ], }) + // Register the serviceworker listener BEFORE waiting for the SW so that + // the very first startup logs (e.g. initHubExternalListener) are captured. + // Without this, the SW may already be running when we call serviceWorkers() + // and its early logs are lost before attachSWConsole is called. + context.on("serviceworker", (worker) => attachSWConsole(worker)) + // Wait for the service worker to be ready before proceeding, so tests can interact with it immediately. let [sw] = context.serviceWorkers() if (!sw) { sw = await context.waitForEvent("serviceworker") + // attachSWConsole already called by the context.on listener above + } else { + // SW was already running when the listener was registered; attach manually + attachSWConsole(sw) } - attachSWConsole(sw) await use(context) await context.close() @@ -207,6 +219,33 @@ export const test = base.extend({ return result }) }, + + cfAccessCookie: async ({ context }, use) => { + const clientId = process.env.CF_ACCESS_CLIENT_ID + const clientSecret = process.env.CF_ACCESS_CLIENT_SECRET + + let token: string | null = null + + if (clientId && clientSecret) { + token = await fetchCfAuthToken(NEW_HUB_URL, clientId, clientSecret) + if (token) { + const { hostname } = new URL(NEW_HUB_URL) + await context.addCookies([ + { + name: "CF_Authorization", + value: token, + domain: hostname, + path: "/", + secure: true, + httpOnly: true, + sameSite: "None", + }, + ]) + } + } + + await use(token) + }, }) export const expect = test.expect diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index b5bcb6b6..9c44b771 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -1,36 +1,10 @@ import { test, expect } from "./fixtures" import { OptionsPage } from "./pages/OptionsPage" - -const HUB_URL = "https://ujiro99.github.io/selection-command" - -const tryGetCommandId = (commandData: string | null): string => { - if (!commandData) { - throw new Error("Hub button is missing required data-command attribute") - } - let parsedCommand: unknown - try { - parsedCommand = JSON.parse(commandData) - } catch (error) { - throw new Error( - `Failed to parse data-command JSON from Hub button: ${(error as Error).message}`, - ) - } - const commandId = - typeof parsedCommand === "object" && - parsedCommand !== null && - "id" in parsedCommand && - typeof (parsedCommand as { id: unknown }).id === "string" - ? (parsedCommand as { id: string }).id - : null - if (!commandId) { - throw new Error( - `Parsed data-command JSON does not contain a valid "id": ${commandData}`, - ) - } - return commandId -} +import { NEW_HUB_URL } from "./const" test.describe("Command Hub", () => { + test.setTimeout(60000) + /** * E2E-90: Verify that a PageAction command can be installed from the Hub. */ @@ -39,8 +13,8 @@ test.describe("Command Hub", () => { extensionId, getCommands, page, + cfAccessCookie: _cfAccessCookie, }) => { - // Reset to a clean state first const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() @@ -49,18 +23,25 @@ test.describe("Command Hub", () => { const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 - // Navigate to the Hub - await page.goto(HUB_URL) - await page.waitForLoadState("domcontentloaded") + await page.goto(NEW_HUB_URL + "?type=pageAction", { + waitUntil: "domcontentloaded", + }) + + // Wait for the extension content script to initialize. + // Without this, the Hub may not yet know the extension is installed when the + // download button is clicked, causing "Chrome extension required" dialog. + await page + .locator("button[data-testid='open-option-page-btn']") + .waitFor({ timeout: 10000 }) // Find a download button for a PageAction command on the Hub page. - // The extension injects download functionality for buttons with data-command attribute. const downloadButton = page - .locator('button[data-command*=\'"openMode":"pageAction"\']') + .locator("button[data-testid='download-btn']") .filter({ hasNot: page.locator('[data-installed="true"]') }) .first() await downloadButton.waitFor({ state: "visible", timeout: 5000 }) await downloadButton.click() + await expect .poll( async () => { @@ -73,13 +54,14 @@ test.describe("Command Hub", () => { }) /** - * E2E-91: Verify that clicking a download button on the Hub adds the command. + * E2E-91: Verify that clicking a download button on the Detail page adds the command. */ - test("E2E-91: download button on Hub adds command to settings", async ({ + test("E2E-91: download button on Detail page adds command to settings", async ({ context, extensionId, getCommands, page, + cfAccessCookie: _cfAccessCookie, }) => { const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() @@ -89,22 +71,22 @@ test.describe("Command Hub", () => { const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 - await page.goto(HUB_URL) - await page.waitForLoadState("domcontentloaded") + // Navigate to Detail page. + // - Gemini + const url = + NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785" + await page.goto(url, { waitUntil: "domcontentloaded" }) + + await page + .locator("button[data-testid='open-option-page-btn']") + .waitFor({ timeout: 10000 }) - // Find any available download button const downloadButton = page - .locator('button[data-command*=\'"openMode":"popup"\']') - .filter({ hasNot: page.locator('[data-installed="true"]') }) + .locator("button[data-testid='download-btn']") .first() - await downloadButton.waitFor({ state: "visible", timeout: 5000 }) - - // Get the command identifier for verification - const commandData = await downloadButton.getAttribute("data-command") - tryGetCommandId(commandData) - await downloadButton.click() + await expect .poll( async () => { @@ -124,6 +106,7 @@ test.describe("Command Hub", () => { extensionId, getCommands, page, + cfAccessCookie: _cfAccessCookie, }) => { const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() @@ -131,21 +114,23 @@ test.describe("Command Hub", () => { await optionsPage.close() // Step 1: Install a command from the Hub - await page.goto(HUB_URL) - await page.waitForLoadState("domcontentloaded") + const url = + NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43" + await page.goto(url, { waitUntil: "domcontentloaded" }) + + await page + .locator("button[data-testid='open-option-page-btn']") + .waitFor({ timeout: 10000 }) - // Find any available download button const downloadButton = page - .locator('button[data-command*=\'"openMode":"popup"\']') + .locator("button[data-testid='download-btn']") .filter({ hasNot: page.locator('[data-installed="true"]') }) .first() - await downloadButton.waitFor({ state: "visible", timeout: 5000 }) - const commandData = await downloadButton.getAttribute("data-command") - const commandId = tryGetCommandId(commandData) - + const commandId = await downloadButton.getAttribute("data-id") await downloadButton.click() + await expect .poll( async () => { @@ -158,19 +143,20 @@ test.describe("Command Hub", () => { // Step 2: Delete the command via settings await optionsPage.open() - // Use setUserSettings or direct storage manipulation to remove the command - // Here we just reset to simulate "deletion" for verification purposes await optionsPage.resetSettings() await optionsPage.close() // Step 3: Reload the Hub and verify the download button is restored - await page.goto(HUB_URL) - await page.waitForLoadState("domcontentloaded") + await page.goto(url, { waitUntil: "domcontentloaded" }) + + await page + .locator("button[data-testid='open-option-page-btn']") + .waitFor({ timeout: 10000 }) - // The download button for the deleted command should be available again - const restoredButton = page.locator( - `button[data-command*='"id":"${commandId}"']`, - ) + const restoredButton = page + .locator(`button[data-id='${commandId}']`) + .filter({ hasNot: page.locator('[data-installed="true"]') }) + .first() await restoredButton.waitFor({ state: "visible", timeout: 5000 }) expect(restoredButton).toBeVisible() }) diff --git a/packages/extension/e2e/page-action.spec.ts b/packages/extension/e2e/page-action.spec.ts index 5d8112f3..01a7d201 100644 --- a/packages/extension/e2e/page-action.spec.ts +++ b/packages/extension/e2e/page-action.spec.ts @@ -258,6 +258,8 @@ test.describe("PageAction Commands", () => { await page.goto("https://www.amazon.com/") await page.waitForLoadState("domcontentloaded") + await page.locator(".a-button-text").first().click() + await page.waitForLoadState("domcontentloaded") await page.locator(".a-list-item .a-link-normal").first().click() await page.waitForLoadState("domcontentloaded") diff --git a/packages/extension/e2e/utils/cfAuth.ts b/packages/extension/e2e/utils/cfAuth.ts new file mode 100644 index 00000000..a90df364 --- /dev/null +++ b/packages/extension/e2e/utils/cfAuth.ts @@ -0,0 +1,21 @@ +export async function fetchCfAuthToken( + url: string, + clientId: string, + clientSecret: string, +): Promise { + try { + const res = await fetch(url, { + headers: { + "CF-Access-Client-Id": clientId, + "CF-Access-Client-Secret": clientSecret, + }, + }) + const cfCookie = res.headers + .getSetCookie() + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("CF_Authorization=")) + return cfCookie ? cfCookie.split("=").slice(1).join("=") : null + } catch { + return null + } +} diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 6f6a48f3..83ccb867 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -35,6 +35,17 @@ "js": [ "src/new_command_hub.tsx" ] + }, + { + "matches": [ + "https://*.siro-cola.workers.dev/*" + ], + "include_globs": [ + "https://*-selection-command-hub.siro-cola.workers.dev/*" + ], + "js": [ + "src/new_command_hub.tsx" + ] } ], "background": { diff --git a/packages/extension/playwright.config.ts b/packages/extension/playwright.config.ts index 1d41e608..c4b7da9d 100644 --- a/packages/extension/playwright.config.ts +++ b/packages/extension/playwright.config.ts @@ -1,5 +1,8 @@ +import dotenv from "dotenv" import { defineConfig } from "@playwright/test" +dotenv.config({ path: ".env.e2e" }) + export default defineConfig({ testDir: "./e2e", timeout: 30000, diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index e133623b..2a74c4f4 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -1,9 +1,6 @@ import { useEffect } from "react" import { useSection } from "@/hooks/useSettings" import { CACHE_SECTIONS } from "@/services/settings/settingsCache" -import { NEW_HUB_URL } from "@/const" - -const hubOrigin = new URL(NEW_HUB_URL).origin export function useCommandHubBridge() { const { data: commands } = useSection(CACHE_SECTIONS.COMMANDS) @@ -11,12 +8,18 @@ export function useCommandHubBridge() { // Proactively push installed IDs to the Hub whenever the commands list changes. useEffect(() => { if (commands == null) return + // Use window.location.origin so this works regardless of which Hub URL is + // used (production vs. staging), avoiding silent discard from origin mismatch. + console.debug( + "useCommandHubBridge: sending installed command IDs to Hub:", + commands.map((c) => c.id), + ) window.postMessage( { action: "SyncInstalledCommand", installedIds: commands.map((c) => c.id), }, - hubOrigin, + window.location.origin, ) }, [commands]) } diff --git a/packages/extension/src/hooks/useDetectLinkCommand.ts b/packages/extension/src/hooks/useDetectLinkCommand.ts index a325ea34..9ca6965a 100644 --- a/packages/extension/src/hooks/useDetectLinkCommand.ts +++ b/packages/extension/src/hooks/useDetectLinkCommand.ts @@ -151,7 +151,7 @@ function useDetectDrag( if (activate && command) { const h = command.popupOption?.height ?? PopupOption.height const w = command.popupOption?.width ?? PopupOption.width - const screen = await getScreenSize() + const screen = await getScreenSize({ top: e.screenY, left: e.screenX }) const position = { x: e.screenX, y: e.screenY - 50 } position.x = Math.min(position.x, screen.width - w + screen.left) position.y = Math.min(position.y, screen.height - h + screen.top - 60) diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index e2d597d0..84b55401 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -446,7 +446,7 @@ export const openPopupWindow = async ( current = { id: undefined, incognito: false } as chrome.windows.Window } - const screenSize = await getScreenSize() + const screenSize = await getScreenSize({ top, left }) const type = param.type ?? POPUP_TYPE.POPUP const isFullscreen = param.windowState === WINDOW_STATE.FULLSCREEN @@ -553,7 +553,7 @@ export const openPopupWindowMultiple = async ( } const type = param.type ?? POPUP_TYPE.POPUP - const screenSize = await getScreenSize() + const screenSize = await getScreenSize({ top, left }) const windows = await Promise.all( param.urls diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index be8e6da1..20a4f95b 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -1186,8 +1186,14 @@ describe("shareCommandToHub", () => { errorCode: "DUPLICATE_COMMAND_ID", }) - // Must not send new command since local storage update failed - expect(mockPort.postMessage).toHaveBeenCalledTimes(1) // only the initial retry send + // Must not send a new command with an updated ID since local storage update failed + expect(mockPort.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ + id: expect.not.stringMatching(param.id), + }), + }), + ) expect(mockPort.onMessage.removeListener).toHaveBeenCalled() vi.useRealTimers() diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 0b8c5885..b9f481d3 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -287,6 +287,12 @@ export async function handleAddCommand( const isSearch = isSearchCommand(parsed) const isPageAction = isPageActionCommand(parsed) const isAiPrompt = isAiPromptCommand(parsed) + console.debug("[handleAddCommand] Parsed:", { + isSearch, + isPageAction, + isAiPrompt, + id: parsed?.id, + }) const sourceType = (parsed as { sourceType?: unknown }).sourceType const sourceId = (parsed as { sourceId?: unknown }).sourceId @@ -302,36 +308,36 @@ export async function handleAddCommand( const cmd = isSearch ? { + id: parsed.id, + title: parsed.title, + searchUrl: parsed.searchUrl, + iconUrl: parsed.iconUrl, + ...sourceInfo, + openMode: parsed.openMode, + openModeSecondary: parsed.openModeSecondary, + spaceEncoding: parsed.spaceEncoding, + popupOption: PopupOption, + } + : isAiPrompt + ? { id: parsed.id, title: parsed.title, - searchUrl: parsed.searchUrl, iconUrl: parsed.iconUrl, ...sourceInfo, openMode: parsed.openMode, - openModeSecondary: parsed.openModeSecondary, - spaceEncoding: parsed.spaceEncoding, + aiPromptOption: parsed.aiPromptOption, popupOption: PopupOption, } - : isAiPrompt - ? { + : isPageAction + ? { id: parsed.id, title: parsed.title, iconUrl: parsed.iconUrl, ...sourceInfo, openMode: parsed.openMode, - aiPromptOption: parsed.aiPromptOption, + pageActionOption: parsed.pageActionOption, popupOption: PopupOption, } - : isPageAction - ? { - id: parsed.id, - title: parsed.title, - iconUrl: parsed.iconUrl, - ...sourceInfo, - openMode: parsed.openMode, - pageActionOption: parsed.pageActionOption, - popupOption: PopupOption, - } : null if (!cmd) { @@ -341,6 +347,7 @@ export async function handleAddCommand( } await Settings.addCommands([cmd]) + console.debug("[handleAddCommand] Saved command id:", cmd.id) await sendEvent( ANALYTICS_EVENTS.COMMAND_ADD, { @@ -409,7 +416,7 @@ export function handleEditCommand( ackTimeout: undefined, ackListener: undefined, pendingResponse: undefined, - cancelConnectWait: () => {}, + cancelConnectWait: () => { }, } _editSession = newSession @@ -539,10 +546,27 @@ function onMessageExternal( sender: chrome.runtime.MessageSender, sendResponse: (response?: unknown) => void, ): boolean { - if (!sender.origin || sender.origin !== hubOrigin) return false - const { action, command, id } = message ?? {} console.debug("[onMessageExternal] Received message:", message) + if (!sender.origin || sender.origin !== hubOrigin) { + console.warn( + `[onMessageExternal] Origin mismatch — received: "${sender.origin}", expected: "${hubOrigin}"`, + ) + return false + } + console.debug("[onMessageExternal] Origin OK, action:", action) + + if (action === "Ping") { + sendResponse({ result: true }) + return false + } + + if (action === "OpenOptionsPage") { + chrome.runtime.openOptionsPage().finally(() => { + sendResponse({ result: true }) + }) + return true + } if (action === "AddCommand" && typeof command === "string") { handleAddCommand(command, sendResponse).catch((err) => { @@ -596,6 +620,10 @@ function onMessageExternal( } export function initHubExternalListener(): void { + console.debug( + "[initHubExternalListener] Registering onMessageExternal listener, hubOrigin:", + hubOrigin, + ) chrome.runtime.onMessageExternal.addListener(onMessageExternal) } diff --git a/packages/extension/src/services/screen.test.ts b/packages/extension/src/services/screen.test.ts new file mode 100644 index 00000000..c0c671ac --- /dev/null +++ b/packages/extension/src/services/screen.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { getScreenSize } from "./screen" + +// Mock isServiceWorker to control context +vi.mock("@/lib/utils", () => ({ + isServiceWorker: vi.fn(), +})) + +import { isServiceWorker } from "@/lib/utils" + +// Minimal display info structure for testing +const makeDisplay = ( + left: number, + top: number, + width: number, + height: number, + isPrimary = false, +): chrome.system.display.DisplayUnitInfo => + ({ + id: `display-${left}`, + name: `Display ${left}`, + isPrimary, + isEnabled: true, + bounds: { left, top, width, height }, + workArea: { left, top, width, height }, + overscan: { left: 0, top: 0, right: 0, bottom: 0 }, + rotation: 0, + dpiX: 96, + dpiY: 96, + mirroringSourceId: "", + mirroringDestinationIds: [], + isUnified: false, + activeState: "active", + displayZoomFactor: 1, + }) as unknown as chrome.system.display.DisplayUnitInfo + +describe("getScreenSize", () => { + const primaryDisplay = makeDisplay(0, 0, 1920, 1080, true) + const secondaryDisplay = makeDisplay(1920, 0, 2560, 1440, false) + + let mockGetInfo: ReturnType + let mockGetCurrent: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + mockGetInfo = vi.fn().mockResolvedValue([primaryDisplay, secondaryDisplay]) + mockGetCurrent = vi + .fn() + .mockResolvedValue({ left: 100, top: 100 } as chrome.windows.Window) + + vi.stubGlobal("chrome", { + system: { + display: { + getInfo: mockGetInfo, + }, + }, + windows: { + getCurrent: mockGetCurrent, + }, + }) + }) + + describe("In service worker context", () => { + beforeEach(() => { + vi.mocked(isServiceWorker).mockReturnValue(true) + }) + + it("GSS-01: ヒントなし - getCurrent() の結果からディスプレイを特定する", async () => { + // getCurrent returns position on primary display + mockGetCurrent.mockResolvedValue({ + left: 100, + top: 100, + } as chrome.windows.Window) + + const result = await getScreenSize() + + expect(result.left).toBe(0) + expect(result.top).toBe(0) + expect(result.width).toBe(1920) + expect(result.height).toBe(1080) + }) + + it("GSS-02: ヒントあり(プライマリディスプレイ上の座標)- プライマリディスプレイを返す", async () => { + const result = await getScreenSize({ top: 200, left: 300 }) + + // Should identify primary display (0,0,1920,1080) + expect(result.left).toBe(0) + expect(result.top).toBe(0) + expect(result.width).toBe(1920) + expect(result.height).toBe(1080) + // getCurrent should NOT be called when hint is provided + expect(mockGetCurrent).not.toHaveBeenCalled() + }) + + it("GSS-03: ヒントあり(セカンダリディスプレイ上の座標)- セカンダリディスプレイを返す", async () => { + // Position on secondary display (left=1920, right=4480) + const result = await getScreenSize({ top: 100, left: 2000 }) + + // Should identify secondary display (1920,0,2560,1440) + expect(result.left).toBe(1920) + expect(result.top).toBe(0) + expect(result.width).toBe(2560) + expect(result.height).toBe(1440) + // getCurrent should NOT be called when hint is provided + expect(mockGetCurrent).not.toHaveBeenCalled() + }) + + it("GSS-04: マルチディスプレイ環境でヒントがプライマリにない場合もセカンダリを正しく返す", async () => { + // Simulate: getCurrent returns primary window, but popup hint is on secondary display + mockGetCurrent.mockResolvedValue({ + left: 100, // on primary display + top: 100, + } as chrome.windows.Window) + + const result = await getScreenSize({ top: 500, left: 3000 }) + + // Should correctly identify secondary display, not primary + expect(result.left).toBe(1920) + expect(result.top).toBe(0) + expect(result.width).toBe(2560) + expect(result.height).toBe(1440) + }) + + it("GSS-05: ヒントがいずれのディスプレイにも含まれない場合、プライマリを返す", async () => { + // Position doesn't match any display + const result = await getScreenSize({ top: -100, left: -200 }) + + // Should fall back to primary display + expect(result.left).toBe(0) + expect(result.top).toBe(0) + expect(result.width).toBe(1920) + expect(result.height).toBe(1080) + }) + }) + + describe("In content script context (non-service-worker)", () => { + beforeEach(() => { + vi.mocked(isServiceWorker).mockReturnValue(false) + + // Mock window.screen + vi.stubGlobal("window", { + screen: { + width: 1920, + height: 1080, + availLeft: 0, + availTop: 0, + }, + }) + }) + + it("GSS-06: window.screen の値を返す(ヒントは無視)", async () => { + const result = await getScreenSize({ top: 500, left: 3000 }) + + expect(result.width).toBe(1920) + expect(result.height).toBe(1080) + }) + }) +}) + diff --git a/packages/extension/src/services/screen.ts b/packages/extension/src/services/screen.ts index 63f1bbcd..cc864239 100644 --- a/packages/extension/src/services/screen.ts +++ b/packages/extension/src/services/screen.ts @@ -37,29 +37,36 @@ export async function getWindowPosition(): Promise { } } -export async function getScreenSize(): Promise { +export async function getScreenSize(hint?: { + top: number + left: number +}): Promise { if (isServiceWorker()) { try { // For background_script.ts - const [displays, currentWindow] = await Promise.all([ - chrome.system.display.getInfo(), - chrome.windows.getCurrent(), - ]) + const displays = await chrome.system.display.getInfo() - let targetDisplay - const currentWindowLeft = currentWindow.left - const currentWindowTop = currentWindow.top + let hintTop = hint?.top + let hintLeft = hint?.left - if (currentWindowLeft != null && currentWindowTop != null) { - // Find the monitor that contains the active window's left position + if (hintTop == null || hintLeft == null) { + // Fall back to current window position if no hint provided + const currentWindow = await chrome.windows.getCurrent() + hintLeft = currentWindow.left ?? undefined + hintTop = currentWindow.top ?? undefined + } + + let targetDisplay + if (hintLeft != null && hintTop != null) { + // Find the monitor that contains the given position targetDisplay = displays.find((d) => { const a = d.workArea return ( - currentWindowLeft >= a.left && - currentWindowLeft < a.left + a.width && - currentWindowTop >= a.top && - currentWindowTop < a.top + a.height + hintLeft >= a.left && + hintLeft < a.left + a.width && + hintTop >= a.top && + hintTop < a.top + a.height ) }) ?? displays.find((d) => d.isPrimary) ?? diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index cf68f4c7..2af3a3cc 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -17,12 +17,16 @@ const shouldUploadSourcemaps = process.env.UPLOAD_SOURCEMAP_TO_SENTRY === "true" // https://vite.dev/config/ export default defineConfig(({ mode }) => { const isWatchMode = process.argv.includes("--watch") - loadEnv(mode, process.cwd(), "") + const env = loadEnv(mode, process.cwd(), "") const isProduction = mode === "production" + const isDevelopment = mode === "development" + const extensionKey = !isDevelopment ? env.EXTENSION_KEY : undefined + const activeManifest = isProduction ? { ...manifest, + ...(extensionKey ? { key: extensionKey } : {}), content_scripts: manifest.content_scripts.map((cs) => ({ ...cs, matches: cs.matches.filter((m) => !m.includes("localhost")), @@ -34,7 +38,10 @@ export default defineConfig(({ mode }) => { ), }, } - : manifest + : { + ...manifest, + ...(extensionKey ? { key: extensionKey } : {}), + } const plugins = [ react(),