From abd28bffc6b77f3bba4d220e160137f224f6ffe8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 03:58:46 +0000 Subject: [PATCH 01/23] Initial plan From 2d9d7cd7943f582df9f0254c8011762d0e672a31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 04:07:32 +0000 Subject: [PATCH 02/23] fix: use popup position hint to identify display in multi-monitor setup In a multi-display environment, getScreenSize() was calling chrome.windows.getCurrent() from the service worker context to determine which display to use for bounds-checking. This could return a window on the primary/main display rather than the display where the user was actually working, causing adjustWindowPosition() to reposition the popup to the main display. Fix: add an optional `hint` parameter to getScreenSize(). When a hint (top, left) is provided, use those coordinates directly to find the correct display. Callers openPopupWindow() and openPopupWindowMultiple() now pass the popup target coordinates as the hint, so the display where the user is working is always correctly identified. Add screen.test.ts with 6 tests covering single/multi-display scenarios. Agent-Logs-Url: https://github.com/ujiro99/selection-command/sessions/c383e197-1956-435f-902e-69f98e6282bf Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com> --- packages/extension/src/services/chrome.ts | 4 +- .../extension/src/services/screen.test.ts | 160 ++++++++++++++++++ packages/extension/src/services/screen.ts | 35 ++-- 3 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 packages/extension/src/services/screen.test.ts 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/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) ?? From d8b65b03b1121820cc2b712b9d48d6d347a37223 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 17 Jun 2026 13:00:44 +0900 Subject: [PATCH 03/23] fix: pass screen position hint to getScreenSize in drag handler Drag events produce screen-absolute coordinates (screenX/screenY), so the same hint mechanism used in chrome.ts now applies here to correctly identify the display when clamping the popup position. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/src/hooks/useDetectLinkCommand.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From ebfd1be7e59b080e3cab0427e94f969f10f853dc Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 17 Jun 2026 22:17:51 +0900 Subject: [PATCH 04/23] ci: free disk space before Playwright install to fix e2e hang Add jlumbroso/free-disk-space@main before the Playwright browser install step to prevent extraction from hanging due to insufficient disk space. Also add timeout-minutes: 10 to the install step to fail fast if it hangs. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6728359b..fc08c413 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,6 +45,17 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Free disk space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -68,6 +79,7 @@ jobs: - name: Install Playwright browsers if: steps.playwright-cache.outputs.cache-hit != 'true' + timeout-minutes: 10 run: npx playwright install chromium --with-deps - name: Install Playwright system dependencies From 7e50ca0e0c8e5a46c343ce8c4eb20f07ba4eb278 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 18 Jun 2026 08:31:07 +0900 Subject: [PATCH 05/23] fix(test): make SH-10 assertion robust against CI timer timing The original assertion `toHaveBeenCalledTimes(1)` depended on `setInterval(fn, 100)` firing when advanced by exactly 100ms, which is a boundary condition that is flaky on Ubuntu/CI environments. Replace with a behavior-focused assertion that verifies postMessage was never called with a new command ID, which is the actual invariant the test needs to protect. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/src/services/hub/background.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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() From 2919715769921edd402a5e42ed1bdab11fdcfa1a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 18 Jun 2026 09:17:44 +0900 Subject: [PATCH 06/23] ci: split Playwright deps and browser install into separate steps Separates system dependency installation (always runs) from Chromium binary download (cache miss only), removing --with-deps conflation and the timeout that caused the e2e job to hang. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc08c413..dcd8aa03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,15 +77,13 @@ jobs: restore-keys: | playwright-chromium- - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - timeout-minutes: 10 - 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 + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npx playwright install chromium + - name: Run E2E tests run: yarn test:e2e From 8e8161056949aa4adab99574bb58fc4129590907 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 18 Jun 2026 09:42:46 +0900 Subject: [PATCH 07/23] ci: switch e2e job to official Playwright Docker image Replaces the browser install steps (which hang on Node.js 24.16.0 due to an extract-zip bug) with the pre-built mcr.microsoft.com/playwright container image. Also removes the now-unnecessary free-disk-space and cache steps. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 29 ++--------------------------- 1 file changed, 2 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcd8aa03..47f8d793 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,22 +40,13 @@ jobs: e2e: runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.1-jammy steps: - name: Checkout code uses: actions/checkout@v4 - - name: Free disk space - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -68,22 +59,6 @@ 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 system dependencies - run: npx playwright install-deps chromium - - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: npx playwright install chromium - - name: Run E2E tests run: yarn test:e2e From 2a52ab22c93ed061b035ff6243d85b4bae87e14a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Mon, 22 Jun 2026 17:46:41 +0900 Subject: [PATCH 08/23] feat(e2e): add Cloudflare Access auth cookie fixture for Hub tests Acquire a CF_Authorization cookie via Service Token headers before navigating to the Hub URL, so hub.spec.ts tests pass through the Cloudflare Access authentication wall. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/const.ts | 3 + packages/extension/e2e/fixtures.ts | 32 ++++++++- packages/extension/e2e/hub.spec.ts | 76 ++++++++-------------- packages/extension/e2e/page-action.spec.ts | 2 + packages/extension/e2e/utils/cfAuth.ts | 36 ++++++++++ packages/extension/playwright.config.ts | 3 + 6 files changed, 101 insertions(+), 51 deletions(-) create mode 100644 packages/extension/e2e/const.ts create mode 100644 packages/extension/e2e/utils/cfAuth.ts 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..05a9cbd8 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 } /** @@ -39,7 +42,7 @@ type Fixtures = { */ export const test = base.extend({ // eslint-disable-next-line no-empty-pattern - context: async ({}, use) => { + context: async ({ }, use) => { // When running with --debug, PWDEBUG is set; show the browser window in that case. const isDebug = !!process.env.PWDEBUG const context = await chromium.launchPersistentContext("", { @@ -207,6 +210,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..3858916f 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -1,34 +1,6 @@ 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", () => { /** @@ -39,6 +11,7 @@ test.describe("Command Hub", () => { extensionId, getCommands, page, + cfAccessCookie: _cfAccessCookie, }) => { // Reset to a clean state first const optionsPage = new OptionsPage(context, extensionId, getCommands) @@ -50,13 +23,13 @@ test.describe("Command Hub", () => { const countBefore = commandsBefore?.length ?? 0 // Navigate to the Hub - await page.goto(HUB_URL) + await page.goto(NEW_HUB_URL + "?type=pageAction") await page.waitForLoadState("domcontentloaded") // 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 }) @@ -73,13 +46,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,21 +63,18 @@ test.describe("Command Hub", () => { const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 - await page.goto(HUB_URL) + // Navigate to Detail page. + // - Gemini + await page.goto( + NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785", + ) await page.waitForLoadState("domcontentloaded") // 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( @@ -124,6 +95,7 @@ test.describe("Command Hub", () => { extensionId, getCommands, page, + cfAccessCookie: _cfAccessCookie, }) => { const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() @@ -131,19 +103,20 @@ test.describe("Command Hub", () => { await optionsPage.close() // Step 1: Install a command from the Hub - await page.goto(HUB_URL) + await page.goto( + NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", + ) await page.waitForLoadState("domcontentloaded") // 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 @@ -164,13 +137,16 @@ test.describe("Command Hub", () => { await optionsPage.close() // Step 3: Reload the Hub and verify the download button is restored - await page.goto(HUB_URL) + await page.goto( + NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", + ) await page.waitForLoadState("domcontentloaded") // 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..5792fb33 --- /dev/null +++ b/packages/extension/e2e/utils/cfAuth.ts @@ -0,0 +1,36 @@ +import https from "node:https" + +export async function fetchCfAuthToken( + url: string, + clientId: string, + clientSecret: string, +): Promise { + return new Promise((resolve) => { + const req = https.request( + url, + { + headers: { + "CF-Access-Client-Id": clientId, + "CF-Access-Client-Secret": clientSecret, + }, + }, + (res) => { + const rawCookies = res.headers["set-cookie"] ?? [] + const arr = Array.isArray(rawCookies) ? rawCookies : [rawCookies] + const cfCookie = arr + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("CF_Authorization=")) + if (cfCookie) { + resolve(cfCookie.split("=").slice(1).join("=")) + res.resume() + return + } + const cfHeader = res.headers["cf-access-token"] + resolve(typeof cfHeader === "string" ? cfHeader : null) + res.resume() + }, + ) + req.on("error", () => resolve(null)) + req.end() + }) +} 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, From 49bfd06c194c3f86f47d3d3924df3a3deb980d95 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 23 Jun 2026 08:13:10 +0900 Subject: [PATCH 09/23] ci: inject Hub secrets into e2e build and test steps Pass VITE_NEW_HUB_URL, CF_ACCESS_CLIENT_ID, and CF_ACCESS_CLIENT_SECRET from repository secrets so the e2e job can build the extension with the correct Hub URL and authenticate through Cloudflare Access at test time. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 47f8d793..55bbe089 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,9 +58,15 @@ jobs: - name: Build extension run: yarn build:e2e + env: + VITE_NEW_HUB_URL: ${{ secrets.VITE_NEW_HUB_URL }} - 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 }} - name: Upload E2E test results if: failure() From 72aca687289f2f14204276b454076ac1041e13ac Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Tue, 23 Jun 2026 08:51:17 +0900 Subject: [PATCH 10/23] Update: Fix e2e testing. --- packages/extension/e2e/hub.spec.ts | 13 ++++++++----- packages/extension/src/services/hub/background.ts | 3 +-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index 3858916f..6494a16d 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -3,6 +3,8 @@ import { OptionsPage } from "./pages/OptionsPage" 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. */ @@ -23,8 +25,9 @@ test.describe("Command Hub", () => { const countBefore = commandsBefore?.length ?? 0 // Navigate to the Hub - await page.goto(NEW_HUB_URL + "?type=pageAction") - await page.waitForLoadState("domcontentloaded") + await page.goto(NEW_HUB_URL + "?type=pageAction", { + waitUntil: "domcontentloaded", + }) // Find a download button for a PageAction command on the Hub page. // The extension injects download functionality for buttons with data-command attribute. @@ -67,8 +70,8 @@ test.describe("Command Hub", () => { // - Gemini await page.goto( NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785", + { waitUntil: "domcontentloaded" }, ) - await page.waitForLoadState("domcontentloaded") // Find any available download button const downloadButton = page @@ -105,8 +108,8 @@ test.describe("Command Hub", () => { // Step 1: Install a command from the Hub await page.goto( NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", + { waitUntil: "domcontentloaded" }, ) - await page.waitForLoadState("domcontentloaded") // Find any available download button const downloadButton = page @@ -139,8 +142,8 @@ test.describe("Command Hub", () => { // Step 3: Reload the Hub and verify the download button is restored await page.goto( NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", + { waitUntil: "domcontentloaded" }, ) - await page.waitForLoadState("domcontentloaded") // The download button for the deleted command should be available again const restoredButton = page diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 0b8c5885..68912557 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -539,10 +539,9 @@ 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) return false if (action === "AddCommand" && typeof command === "string") { handleAddCommand(command, sendResponse).catch((err) => { From e61f92180aba62f7551571a23e1add47d83194a2 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 11:02:46 +0900 Subject: [PATCH 11/23] fix(e2e): resolve race condition and origin mismatch in Hub extension detection - useCommandHubBridge: use window.location.origin for postMessage to avoid silent discard when VITE_NEW_HUB_URL differs from the actual Hub URL (e.g. staging vs prod) - useCommandHubBridge: set data-extension-installed="true" on after commands load, giving tests a reliable DOM signal that the content script is ready - hub.spec.ts: wait for html[data-extension-installed='true'] before clicking any download button so the Hub has received SyncInstalledCommand first; prevents the "Chrome extension required" dialog caused by clicking before the content script finishes its async chrome.storage read Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/hub.spec.ts | 15 +++++++++++++++ packages/extension/manifest.json | 11 +++++++++++ .../extension/src/hooks/useCommandHubBridge.ts | 9 +++++---- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index 6494a16d..44c2c3e9 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -28,6 +28,12 @@ test.describe("Command Hub", () => { await page.goto(NEW_HUB_URL + "?type=pageAction", { waitUntil: "domcontentloaded", }) + // Wait for the extension content script to initialize and push SyncInstalledCommand. + // 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("html[data-extension-installed='true']") + .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. @@ -72,6 +78,9 @@ test.describe("Command Hub", () => { NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785", { waitUntil: "domcontentloaded" }, ) + await page + .locator("html[data-extension-installed='true']") + .waitFor({ timeout: 10000 }) // Find any available download button const downloadButton = page @@ -110,6 +119,9 @@ test.describe("Command Hub", () => { NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", { waitUntil: "domcontentloaded" }, ) + await page + .locator("html[data-extension-installed='true']") + .waitFor({ timeout: 10000 }) // Find any available download button const downloadButton = page @@ -144,6 +156,9 @@ test.describe("Command Hub", () => { NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", { waitUntil: "domcontentloaded" }, ) + await page + .locator("html[data-extension-installed='true']") + .waitFor({ timeout: 10000 }) // The download button for the deleted command should be available again const restoredButton = page 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/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index e133623b..6af6c654 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,16 @@ 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. window.postMessage( { action: "SyncInstalledCommand", installedIds: commands.map((c) => c.id), }, - hubOrigin, + window.location.origin, ) + // Signal that the extension content script is ready so tests can wait for it. + document.documentElement.dataset.extensionInstalled = "true" }, [commands]) } From 77121d142357e1d42d70a4bcec2290166b001460 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 12:25:05 +0900 Subject: [PATCH 12/23] debug(e2e): add logging to diagnose E2E-90 hub command install failure - Always emit SW/page console logs in e2e (remove PWDEBUG guard) - Capture Hub page console in E2E-90 test - Log command count before/after download button click - Warn on origin mismatch in onMessageExternal (was silently dropped) - Debug-log command type and save result in handleAddCommand - Debug-log installed IDs sent from useCommandHubBridge Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/hub.spec.ts | 14 ++++++++++++++ packages/extension/e2e/utils/logConsole.ts | 1 - .../extension/src/hooks/useCommandHubBridge.ts | 4 ++++ .../extension/src/services/hub/background.ts | 18 +++++++++++++++++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index 44c2c3e9..0880b870 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -23,6 +23,12 @@ test.describe("Command Hub", () => { const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 + console.log("[E2E-90] commands before:", countBefore) + + // Capture Hub page console for debugging + page.on("console", (msg) => + console.log(`[Hub page][${msg.type()}]`, msg.text()), + ) // Navigate to the Hub await page.goto(NEW_HUB_URL + "?type=pageAction", { @@ -43,6 +49,14 @@ test.describe("Command Hub", () => { .first() await downloadButton.waitFor({ state: "visible", timeout: 5000 }) await downloadButton.click() + const commandsAfterClick = await getCommands() + console.log( + "[E2E-90] commands after click:", + commandsAfterClick?.length ?? 0, + "(before:", + countBefore, + ")", + ) await expect .poll( async () => { diff --git a/packages/extension/e2e/utils/logConsole.ts b/packages/extension/e2e/utils/logConsole.ts index 35b3f931..279b7e3d 100644 --- a/packages/extension/e2e/utils/logConsole.ts +++ b/packages/extension/e2e/utils/logConsole.ts @@ -2,7 +2,6 @@ import type { ConsoleMessage, Page, Worker } from "@playwright/test" function attachConsoleListener(target: Page | Worker, prefix: string): void { target.on("console", async (msg: ConsoleMessage) => { - if (!process.env.PWDEBUG) return try { const type = msg.type().charAt(0).toUpperCase() const location = msg.location() diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index 6af6c654..a83f243f 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -10,6 +10,10 @@ export function useCommandHubBridge() { 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", diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 68912557..3b62ac4d 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 @@ -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, { @@ -541,7 +548,13 @@ function onMessageExternal( ): boolean { const { action, command, id } = message ?? {} console.debug("[onMessageExternal] Received message:", message) - if (!sender.origin || sender.origin !== hubOrigin) return false + 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 === "AddCommand" && typeof command === "string") { handleAddCommand(command, sendResponse).catch((err) => { @@ -595,6 +608,9 @@ function onMessageExternal( } export function initHubExternalListener(): void { + console.debug( + "[initHubExternalListener] Registering onMessageExternal listener", + ) chrome.runtime.onMessageExternal.addListener(onMessageExternal) } From c55ecba7eface83cd8cac8d30ae5fcc2a7a3fc48 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 12:43:50 +0900 Subject: [PATCH 13/23] debug(e2e): improve Hub test logging for easier diagnosis Add test boundary markers, Hub page console listeners for all tests, and per-step logging (URL, commands count, button attributes, poll state) so failures can be correlated with SW logs by test ID. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/hub.spec.ts | 148 +++++++++++++++++++++++------ 1 file changed, 117 insertions(+), 31 deletions(-) diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index 0880b870..ff47eb81 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -2,6 +2,34 @@ import { test, expect } from "./fixtures" import { OptionsPage } from "./pages/OptionsPage" import { NEW_HUB_URL } from "./const" +/** Attach the Hub page console to Playwright output, prefixed with the test ID. */ +function attachHubPageConsole( + page: import("@playwright/test").Page, + testId: string, +): void { + page.on("console", (msg) => + console.log(`[${testId}][Hub][${msg.type()}]`, msg.text()), + ) +} + +/** Log the attributes of each download button for debugging. */ +async function logDownloadButtons( + page: import("@playwright/test").Page, + testId: string, +): Promise { + const buttons = page.locator("button[data-testid='download-btn']") + const count = await buttons.count() + console.log(`[${testId}] download buttons found: ${count}`) + for (let i = 0; i < Math.min(count, 5); i++) { + const btn = buttons.nth(i) + const dataId = await btn.getAttribute("data-id") + const dataInstalled = await btn.getAttribute("data-installed") + console.log( + `[${testId}] btn[${i}] data-id=${dataId} data-installed=${dataInstalled}`, + ) + } +} + test.describe("Command Hub", () => { test.setTimeout(60000) @@ -15,31 +43,37 @@ test.describe("Command Hub", () => { page, cfAccessCookie: _cfAccessCookie, }) => { + const T = "E2E-90" + console.log(`[${T}] ===== TEST START =====`) + // Reset to a clean state first const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() + console.log(`[${T}] settings reset done`) const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 - console.log("[E2E-90] commands before:", countBefore) + console.log(`[${T}] commands before: ${countBefore}`) - // Capture Hub page console for debugging - page.on("console", (msg) => - console.log(`[Hub page][${msg.type()}]`, msg.text()), - ) + attachHubPageConsole(page, T) // Navigate to the Hub - await page.goto(NEW_HUB_URL + "?type=pageAction", { - waitUntil: "domcontentloaded", - }) + const url = NEW_HUB_URL + "?type=pageAction" + console.log(`[${T}] navigating to: ${url}`) + await page.goto(url, { waitUntil: "domcontentloaded" }) + // Wait for the extension content script to initialize and push SyncInstalledCommand. // Without this, the Hub may not yet know the extension is installed when the // download button is clicked, causing "Chrome extension required" dialog. + console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) + console.log(`[${T}] extension detected by Hub page`) + + await logDownloadButtons(page, T) // Find a download button for a PageAction command on the Hub page. // The extension injects download functionality for buttons with data-command attribute. @@ -48,24 +82,30 @@ test.describe("Command Hub", () => { .filter({ hasNot: page.locator('[data-installed="true"]') }) .first() await downloadButton.waitFor({ state: "visible", timeout: 5000 }) + + const btnId = await downloadButton.getAttribute("data-id") + console.log(`[${T}] clicking download button data-id=${btnId}`) await downloadButton.click() + const commandsAfterClick = await getCommands() + const countAfterClick = commandsAfterClick?.length ?? 0 console.log( - "[E2E-90] commands after click:", - commandsAfterClick?.length ?? 0, - "(before:", - countBefore, - ")", + `[${T}] commands after click: ${countAfterClick} (before: ${countBefore})`, ) + await expect .poll( async () => { const commands = await getCommands() - return (commands?.length ?? 0) > countBefore + const count = commands?.length ?? 0 + console.log(`[${T}] polling commands count: ${count}`) + return count > countBefore }, { timeout: 5000 }, ) .toBe(true) + + console.log(`[${T}] ===== TEST END (PASSED) =====`) }) /** @@ -78,39 +118,62 @@ test.describe("Command Hub", () => { page, cfAccessCookie: _cfAccessCookie, }) => { + const T = "E2E-91" + console.log(`[${T}] ===== TEST START =====`) + const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() + console.log(`[${T}] settings reset done`) const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 + console.log(`[${T}] commands before: ${countBefore}`) + + attachHubPageConsole(page, T) // Navigate to Detail page. // - Gemini - await page.goto( - NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785", - { waitUntil: "domcontentloaded" }, - ) + const url = + NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785" + console.log(`[${T}] navigating to: ${url}`) + await page.goto(url, { waitUntil: "domcontentloaded" }) + + console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) + console.log(`[${T}] extension detected by Hub page`) + + await logDownloadButtons(page, T) // Find any available download button const downloadButton = page .locator("button[data-testid='download-btn']") .first() await downloadButton.waitFor({ state: "visible", timeout: 5000 }) + + const btnId = await downloadButton.getAttribute("data-id") + const btnInstalled = await downloadButton.getAttribute("data-installed") + console.log( + `[${T}] clicking download button data-id=${btnId} data-installed=${btnInstalled}`, + ) await downloadButton.click() + await expect .poll( async () => { const commands = await getCommands() - return (commands?.length ?? 0) > countBefore + const count = commands?.length ?? 0 + console.log(`[${T}] polling commands count: ${count}`) + return count > countBefore }, { timeout: 5000 }, ) .toBe(true) + + console.log(`[${T}] ===== TEST END (PASSED) =====`) }) /** @@ -123,19 +186,30 @@ test.describe("Command Hub", () => { page, cfAccessCookie: _cfAccessCookie, }) => { + const T = "E2E-92" + console.log(`[${T}] ===== TEST START =====`) + const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() + console.log(`[${T}] settings reset done`) + + attachHubPageConsole(page, T) // Step 1: Install a command from the Hub - await page.goto( - NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", - { waitUntil: "domcontentloaded" }, - ) + const url = + NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43" + console.log(`[${T}] step1: navigating to: ${url}`) + await page.goto(url, { waitUntil: "domcontentloaded" }) + + console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) + console.log(`[${T}] extension detected by Hub page`) + + await logDownloadButtons(page, T) // Find any available download button const downloadButton = page @@ -146,33 +220,43 @@ test.describe("Command Hub", () => { await downloadButton.waitFor({ state: "visible", timeout: 5000 }) const commandId = await downloadButton.getAttribute("data-id") - + console.log(`[${T}] step1: clicking download button data-id=${commandId}`) await downloadButton.click() + await expect .poll( async () => { const commands = await getCommands() - return commands?.find((cmd) => cmd.id === commandId) !== undefined + const found = + commands?.find((cmd) => cmd.id === commandId) !== undefined + console.log( + `[${T}] polling for commandId=${commandId}: found=${found}`, + ) + return found }, { timeout: 5000 }, ) .toBe(true) + console.log(`[${T}] step1: command installed`) // Step 2: Delete the command via settings + console.log(`[${T}] step2: resetting settings (simulating delete)`) 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() + console.log(`[${T}] step2: settings reset done`) // Step 3: Reload the Hub and verify the download button is restored - await page.goto( - NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43", - { waitUntil: "domcontentloaded" }, - ) + console.log(`[${T}] step3: reloading Hub page`) + await page.goto(url, { waitUntil: "domcontentloaded" }) + + console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) + console.log(`[${T}] extension detected by Hub page`) + + await logDownloadButtons(page, T) // The download button for the deleted command should be available again const restoredButton = page @@ -181,5 +265,7 @@ test.describe("Command Hub", () => { .first() await restoredButton.waitFor({ state: "visible", timeout: 5000 }) expect(restoredButton).toBeVisible() + + console.log(`[${T}] ===== TEST END (PASSED) =====`) }) }) From 2b5b5632724b4b7cbb9a1fbc52f23291ae67e19a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 13:02:58 +0900 Subject: [PATCH 14/23] debug(e2e): capture all SW restarts and log hubOrigin on startup - fixtures.ts: attach SW console logging to every new service worker instance via context.on("serviceworker"), not just the initial one. MV3 SWs can be killed and restarted at any time; without this, all logs from a restarted SW are silently dropped. - background.ts: include hubOrigin value in initHubExternalListener startup log so the next CI run shows whether the expected origin matches the Hub page's actual origin. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/fixtures.ts | 5 ++++- packages/extension/src/services/hub/background.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 05a9cbd8..1391a035 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -42,7 +42,7 @@ type Fixtures = { */ export const test = base.extend({ // eslint-disable-next-line no-empty-pattern - context: async ({ }, use) => { + context: async ({}, use) => { // When running with --debug, PWDEBUG is set; show the browser window in that case. const isDebug = !!process.env.PWDEBUG const context = await chromium.launchPersistentContext("", { @@ -64,6 +64,9 @@ export const test = base.extend({ sw = await context.waitForEvent("serviceworker") } attachSWConsole(sw) + // MV3 service workers can be killed and restarted at any time. + // Attach console logging to every new SW instance so logs aren't lost on restart. + context.on("serviceworker", (worker) => attachSWConsole(worker)) await use(context) await context.close() diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 3b62ac4d..3b6adf4a 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -609,7 +609,8 @@ function onMessageExternal( export function initHubExternalListener(): void { console.debug( - "[initHubExternalListener] Registering onMessageExternal listener", + "[initHubExternalListener] Registering onMessageExternal listener, hubOrigin:", + hubOrigin, ) chrome.runtime.onMessageExternal.addListener(onMessageExternal) } From ae2f49ae18eefbcf0fb718b41bf2c946af42db8a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 13:28:04 +0900 Subject: [PATCH 15/23] fix(hub): include extensionId in SyncInstalledCommand postMessage The Hub page's sendExtensionMessage uses chrome.runtime.sendMessage(id, ...) which requires knowing the extension ID. In production the ID is fixed, but in E2E tests the loaded extension gets a different ID, so the message is silently dropped and onMessageExternal is never triggered. By including extensionId: chrome.runtime.id in the SyncInstalledCommand window.postMessage, the Hub page can dynamically discover the correct extension ID and use it for AddCommand/DeleteCommand requests. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/src/hooks/useCommandHubBridge.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index a83f243f..57d4aa81 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -18,6 +18,7 @@ export function useCommandHubBridge() { { action: "SyncInstalledCommand", installedIds: commands.map((c) => c.id), + extensionId: chrome.runtime.id, }, window.location.origin, ) From 69f8753b1930f3d3187c31d96fcd801bbb527a67 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 13:47:03 +0900 Subject: [PATCH 16/23] debug(e2e): register serviceworker listener before waiting for SW Moving context.on("serviceworker") before serviceWorkers()/waitForEvent ensures the very first SW startup logs (e.g. initHubExternalListener, BgData initialized) are captured even if the SW has already started by the time attachSWConsole is called on the direct reference. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/fixtures.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index 1391a035..bd219954 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -58,15 +58,18 @@ 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(sw) - // MV3 service workers can be killed and restarted at any time. - // Attach console logging to every new SW instance so logs aren't lost on restart. - context.on("serviceworker", (worker) => attachSWConsole(worker)) await use(context) await context.close() From 562b8510b4ec932533c8abc354feef6f7a2b076a Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 22:04:32 +0900 Subject: [PATCH 17/23] feat(build): inject EXTENSION_KEY from env into manifest at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit production/e2e ビルドでは env の EXTENSION_KEY を manifest の key に注入し、 yarn dev では key を省略することで拡張機能IDを分離する。 GitHub Actions の build/release ワークフローには EXTENSION_KEY シークレットを渡すよう追加。 Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/build.yml | 1 + .github/workflows/release.yml | 1 + .gitignore | 1 + packages/extension/vite.config.ts | 11 +++++++++-- 4 files changed, 12 insertions(+), 2 deletions(-) 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/.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/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(), From 1590c828a902864087d4a848c1ea53ee2ff7b19c Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 22:24:44 +0900 Subject: [PATCH 18/23] Revert "fix(hub): include extensionId in SyncInstalledCommand postMessage" This reverts commit ae2f49ae18eefbcf0fb718b41bf2c946af42db8a. --- packages/extension/src/hooks/useCommandHubBridge.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index 57d4aa81..a83f243f 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -18,7 +18,6 @@ export function useCommandHubBridge() { { action: "SyncInstalledCommand", installedIds: commands.map((c) => c.id), - extensionId: chrome.runtime.id, }, window.location.origin, ) From 19b6524c6a413ec84d86a2c781704f07ad2312e2 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 22:31:26 +0900 Subject: [PATCH 19/23] Fix: Set EXTENSION_KEY to build variable on e2e test. --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55bbe089..e3b72cfd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,6 +60,7 @@ jobs: run: yarn build:e2e env: VITE_NEW_HUB_URL: ${{ secrets.VITE_NEW_HUB_URL }} + EXTENSION_KEY: ${{ secrets.EXTENSION_KEY }} - name: Run E2E tests run: yarn test:e2e From bcbbd7a274f34be75ff7597c6507de8e73341378 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 22:37:24 +0900 Subject: [PATCH 20/23] Update: Decrease logs. --- packages/extension/e2e/utils/logConsole.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/extension/e2e/utils/logConsole.ts b/packages/extension/e2e/utils/logConsole.ts index 279b7e3d..35b3f931 100644 --- a/packages/extension/e2e/utils/logConsole.ts +++ b/packages/extension/e2e/utils/logConsole.ts @@ -2,6 +2,7 @@ import type { ConsoleMessage, Page, Worker } from "@playwright/test" function attachConsoleListener(target: Page | Worker, prefix: string): void { target.on("console", async (msg: ConsoleMessage) => { + if (!process.env.PWDEBUG) return try { const type = msg.type().charAt(0).toUpperCase() const location = msg.location() From 59cc833df57b071e348140fc91f4509c3c9a6db6 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Wed, 24 Jun 2026 22:54:16 +0900 Subject: [PATCH 21/23] =?UTF-8?q?fix(e2e):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20cfAuth,=20test=20assertion,=20SW=20double-attach?= =?UTF-8?q?,=20debug=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cfAuth.ts: replace https.request with fetch (follows redirects, simpler cookie parsing) - background.test.ts SH-10: add toHaveBeenCalled() to catch zero-call regression masked by the existing double-negative not.toHaveBeenCalledWith assertion - fixtures.ts: prevent double attachSWConsole when SW starts after listener registration; explicit call now only runs when SW was already running at fixture init time - hub.spec.ts: remove 36 diagnostic console.log calls added during E2E-90 debugging Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/e2e/fixtures.ts | 5 +- packages/extension/e2e/hub.spec.ts | 120 +----------------- packages/extension/e2e/utils/cfAuth.ts | 43 ++----- .../src/services/hub/background.test.ts | 1 + 4 files changed, 25 insertions(+), 144 deletions(-) diff --git a/packages/extension/e2e/fixtures.ts b/packages/extension/e2e/fixtures.ts index bd219954..85ed61a3 100644 --- a/packages/extension/e2e/fixtures.ts +++ b/packages/extension/e2e/fixtures.ts @@ -68,8 +68,11 @@ export const test = base.extend({ 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() diff --git a/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index ff47eb81..62302dbc 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -2,34 +2,6 @@ import { test, expect } from "./fixtures" import { OptionsPage } from "./pages/OptionsPage" import { NEW_HUB_URL } from "./const" -/** Attach the Hub page console to Playwright output, prefixed with the test ID. */ -function attachHubPageConsole( - page: import("@playwright/test").Page, - testId: string, -): void { - page.on("console", (msg) => - console.log(`[${testId}][Hub][${msg.type()}]`, msg.text()), - ) -} - -/** Log the attributes of each download button for debugging. */ -async function logDownloadButtons( - page: import("@playwright/test").Page, - testId: string, -): Promise { - const buttons = page.locator("button[data-testid='download-btn']") - const count = await buttons.count() - console.log(`[${testId}] download buttons found: ${count}`) - for (let i = 0; i < Math.min(count, 5); i++) { - const btn = buttons.nth(i) - const dataId = await btn.getAttribute("data-id") - const dataInstalled = await btn.getAttribute("data-installed") - console.log( - `[${testId}] btn[${i}] data-id=${dataId} data-installed=${dataInstalled}`, - ) - } -} - test.describe("Command Hub", () => { test.setTimeout(60000) @@ -43,69 +15,42 @@ test.describe("Command Hub", () => { page, cfAccessCookie: _cfAccessCookie, }) => { - const T = "E2E-90" - console.log(`[${T}] ===== TEST START =====`) - - // Reset to a clean state first const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() - console.log(`[${T}] settings reset done`) const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 - console.log(`[${T}] commands before: ${countBefore}`) - attachHubPageConsole(page, T) - - // Navigate to the Hub - const url = NEW_HUB_URL + "?type=pageAction" - console.log(`[${T}] navigating to: ${url}`) - await page.goto(url, { waitUntil: "domcontentloaded" }) + await page.goto(NEW_HUB_URL + "?type=pageAction", { + waitUntil: "domcontentloaded", + }) // Wait for the extension content script to initialize and push SyncInstalledCommand. // Without this, the Hub may not yet know the extension is installed when the // download button is clicked, causing "Chrome extension required" dialog. - console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) - console.log(`[${T}] extension detected by Hub page`) - - await logDownloadButtons(page, T) // 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-testid='download-btn']") .filter({ hasNot: page.locator('[data-installed="true"]') }) .first() await downloadButton.waitFor({ state: "visible", timeout: 5000 }) - - const btnId = await downloadButton.getAttribute("data-id") - console.log(`[${T}] clicking download button data-id=${btnId}`) await downloadButton.click() - const commandsAfterClick = await getCommands() - const countAfterClick = commandsAfterClick?.length ?? 0 - console.log( - `[${T}] commands after click: ${countAfterClick} (before: ${countBefore})`, - ) - await expect .poll( async () => { const commands = await getCommands() - const count = commands?.length ?? 0 - console.log(`[${T}] polling commands count: ${count}`) - return count > countBefore + return (commands?.length ?? 0) > countBefore }, { timeout: 5000 }, ) .toBe(true) - - console.log(`[${T}] ===== TEST END (PASSED) =====`) }) /** @@ -118,62 +63,39 @@ test.describe("Command Hub", () => { page, cfAccessCookie: _cfAccessCookie, }) => { - const T = "E2E-91" - console.log(`[${T}] ===== TEST START =====`) - const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() - console.log(`[${T}] settings reset done`) const commandsBefore = await getCommands() const countBefore = commandsBefore?.length ?? 0 - console.log(`[${T}] commands before: ${countBefore}`) - - attachHubPageConsole(page, T) // Navigate to Detail page. // - Gemini const url = NEW_HUB_URL + "/ja/commands/06964cb6-019d-511f-b16f-18c7bbd2c785" - console.log(`[${T}] navigating to: ${url}`) await page.goto(url, { waitUntil: "domcontentloaded" }) - console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) - console.log(`[${T}] extension detected by Hub page`) - - await logDownloadButtons(page, T) - // Find any available download button const downloadButton = page .locator("button[data-testid='download-btn']") .first() await downloadButton.waitFor({ state: "visible", timeout: 5000 }) - - const btnId = await downloadButton.getAttribute("data-id") - const btnInstalled = await downloadButton.getAttribute("data-installed") - console.log( - `[${T}] clicking download button data-id=${btnId} data-installed=${btnInstalled}`, - ) await downloadButton.click() await expect .poll( async () => { const commands = await getCommands() - const count = commands?.length ?? 0 - console.log(`[${T}] polling commands count: ${count}`) - return count > countBefore + return (commands?.length ?? 0) > countBefore }, { timeout: 5000 }, ) .toBe(true) - - console.log(`[${T}] ===== TEST END (PASSED) =====`) }) /** @@ -186,86 +108,56 @@ test.describe("Command Hub", () => { page, cfAccessCookie: _cfAccessCookie, }) => { - const T = "E2E-92" - console.log(`[${T}] ===== TEST START =====`) - const optionsPage = new OptionsPage(context, extensionId, getCommands) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() - console.log(`[${T}] settings reset done`) - - attachHubPageConsole(page, T) // Step 1: Install a command from the Hub const url = NEW_HUB_URL + "/en/commands/019e6759-9700-75b8-bf5d-30e8f7b5aa43" - console.log(`[${T}] step1: navigating to: ${url}`) await page.goto(url, { waitUntil: "domcontentloaded" }) - console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) - console.log(`[${T}] extension detected by Hub page`) - - await logDownloadButtons(page, T) - // Find any available download button const downloadButton = page .locator("button[data-testid='download-btn']") .filter({ hasNot: page.locator('[data-installed="true"]') }) .first() - await downloadButton.waitFor({ state: "visible", timeout: 5000 }) const commandId = await downloadButton.getAttribute("data-id") - console.log(`[${T}] step1: clicking download button data-id=${commandId}`) await downloadButton.click() await expect .poll( async () => { const commands = await getCommands() - const found = - commands?.find((cmd) => cmd.id === commandId) !== undefined - console.log( - `[${T}] polling for commandId=${commandId}: found=${found}`, - ) - return found + return commands?.find((cmd) => cmd.id === commandId) !== undefined }, { timeout: 5000 }, ) .toBe(true) - console.log(`[${T}] step1: command installed`) // Step 2: Delete the command via settings - console.log(`[${T}] step2: resetting settings (simulating delete)`) await optionsPage.open() await optionsPage.resetSettings() await optionsPage.close() - console.log(`[${T}] step2: settings reset done`) // Step 3: Reload the Hub and verify the download button is restored - console.log(`[${T}] step3: reloading Hub page`) await page.goto(url, { waitUntil: "domcontentloaded" }) - console.log(`[${T}] waiting for data-extension-installed='true'...`) await page .locator("html[data-extension-installed='true']") .waitFor({ timeout: 10000 }) - console.log(`[${T}] extension detected by Hub page`) - await logDownloadButtons(page, T) - - // The download button for the deleted command should be available again 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() - - console.log(`[${T}] ===== TEST END (PASSED) =====`) }) }) diff --git a/packages/extension/e2e/utils/cfAuth.ts b/packages/extension/e2e/utils/cfAuth.ts index 5792fb33..a90df364 100644 --- a/packages/extension/e2e/utils/cfAuth.ts +++ b/packages/extension/e2e/utils/cfAuth.ts @@ -1,36 +1,21 @@ -import https from "node:https" - export async function fetchCfAuthToken( url: string, clientId: string, clientSecret: string, ): Promise { - return new Promise((resolve) => { - const req = https.request( - url, - { - headers: { - "CF-Access-Client-Id": clientId, - "CF-Access-Client-Secret": clientSecret, - }, + try { + const res = await fetch(url, { + headers: { + "CF-Access-Client-Id": clientId, + "CF-Access-Client-Secret": clientSecret, }, - (res) => { - const rawCookies = res.headers["set-cookie"] ?? [] - const arr = Array.isArray(rawCookies) ? rawCookies : [rawCookies] - const cfCookie = arr - .map((c) => c.split(";")[0].trim()) - .find((c) => c.startsWith("CF_Authorization=")) - if (cfCookie) { - resolve(cfCookie.split("=").slice(1).join("=")) - res.resume() - return - } - const cfHeader = res.headers["cf-access-token"] - resolve(typeof cfHeader === "string" ? cfHeader : null) - res.resume() - }, - ) - req.on("error", () => resolve(null)) - req.end() - }) + }) + 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/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 20a4f95b..46ead76e 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -1187,6 +1187,7 @@ describe("shareCommandToHub", () => { }) // Must not send a new command with an updated ID since local storage update failed + expect(mockPort.postMessage).toHaveBeenCalled() expect(mockPort.postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ command: expect.objectContaining({ From 8d9d61383cf0cb4bc4b6f33d28f46472d37abda9 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 25 Jun 2026 08:37:57 +0900 Subject: [PATCH 22/23] fix(test): remove incorrect toHaveBeenCalled() from SH-10 SH-10 tests the case where updateCommandId fails after DUPLICATE_COMMAND_ID. In this scenario postMessage may legitimately be called 0 times (share stops), so toHaveBeenCalled() was wrong and caused a flaky CI failure. Co-Authored-By: Claude Sonnet 4.6 --- packages/extension/src/services/hub/background.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/extension/src/services/hub/background.test.ts b/packages/extension/src/services/hub/background.test.ts index 46ead76e..20a4f95b 100644 --- a/packages/extension/src/services/hub/background.test.ts +++ b/packages/extension/src/services/hub/background.test.ts @@ -1187,7 +1187,6 @@ describe("shareCommandToHub", () => { }) // Must not send a new command with an updated ID since local storage update failed - expect(mockPort.postMessage).toHaveBeenCalled() expect(mockPort.postMessage).not.toHaveBeenCalledWith( expect.objectContaining({ command: expect.objectContaining({ From cbb656c06e65251fe2ebc9f03cf20e45aaf43897 Mon Sep 17 00:00:00 2001 From: ujiro99 Date: Thu, 25 Jun 2026 12:25:05 +0900 Subject: [PATCH 23/23] Fix: Wait for communication with the Hub to complete before proceeding with the test. --- .github/workflows/test.yml | 1 + packages/extension/e2e/hub.spec.ts | 10 ++-- .../src/hooks/useCommandHubBridge.ts | 2 - .../extension/src/services/hub/background.ts | 46 ++++++++++++------- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3b72cfd..c1d57c68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,6 +68,7 @@ jobs: 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/packages/extension/e2e/hub.spec.ts b/packages/extension/e2e/hub.spec.ts index 62302dbc..9c44b771 100644 --- a/packages/extension/e2e/hub.spec.ts +++ b/packages/extension/e2e/hub.spec.ts @@ -27,11 +27,11 @@ test.describe("Command Hub", () => { waitUntil: "domcontentloaded", }) - // Wait for the extension content script to initialize and push SyncInstalledCommand. + // 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("html[data-extension-installed='true']") + .locator("button[data-testid='open-option-page-btn']") .waitFor({ timeout: 10000 }) // Find a download button for a PageAction command on the Hub page. @@ -78,7 +78,7 @@ test.describe("Command Hub", () => { await page.goto(url, { waitUntil: "domcontentloaded" }) await page - .locator("html[data-extension-installed='true']") + .locator("button[data-testid='open-option-page-btn']") .waitFor({ timeout: 10000 }) const downloadButton = page @@ -119,7 +119,7 @@ test.describe("Command Hub", () => { await page.goto(url, { waitUntil: "domcontentloaded" }) await page - .locator("html[data-extension-installed='true']") + .locator("button[data-testid='open-option-page-btn']") .waitFor({ timeout: 10000 }) const downloadButton = page @@ -150,7 +150,7 @@ test.describe("Command Hub", () => { await page.goto(url, { waitUntil: "domcontentloaded" }) await page - .locator("html[data-extension-installed='true']") + .locator("button[data-testid='open-option-page-btn']") .waitFor({ timeout: 10000 }) const restoredButton = page diff --git a/packages/extension/src/hooks/useCommandHubBridge.ts b/packages/extension/src/hooks/useCommandHubBridge.ts index a83f243f..2a74c4f4 100644 --- a/packages/extension/src/hooks/useCommandHubBridge.ts +++ b/packages/extension/src/hooks/useCommandHubBridge.ts @@ -21,7 +21,5 @@ export function useCommandHubBridge() { }, window.location.origin, ) - // Signal that the extension content script is ready so tests can wait for it. - document.documentElement.dataset.extensionInstalled = "true" }, [commands]) } diff --git a/packages/extension/src/services/hub/background.ts b/packages/extension/src/services/hub/background.ts index 3b6adf4a..b9f481d3 100644 --- a/packages/extension/src/services/hub/background.ts +++ b/packages/extension/src/services/hub/background.ts @@ -308,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) { @@ -416,7 +416,7 @@ export function handleEditCommand( ackTimeout: undefined, ackListener: undefined, pendingResponse: undefined, - cancelConnectWait: () => {}, + cancelConnectWait: () => { }, } _editSession = newSession @@ -556,6 +556,18 @@ function onMessageExternal( } 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) => { console.error("[onMessageExternal] AddCommand failed:", err)