Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
abd28bf
Initial plan
Copilot May 26, 2026
2d9d7cd
fix: use popup position hint to identify display in multi-monitor setup
Copilot May 26, 2026
d8b65b0
fix: pass screen position hint to getScreenSize in drag handler
ujiro99 Jun 17, 2026
ebfd1be
ci: free disk space before Playwright install to fix e2e hang
ujiro99 Jun 17, 2026
7e50ca0
fix(test): make SH-10 assertion robust against CI timer timing
ujiro99 Jun 17, 2026
2919715
ci: split Playwright deps and browser install into separate steps
ujiro99 Jun 18, 2026
8e81610
ci: switch e2e job to official Playwright Docker image
ujiro99 Jun 18, 2026
2a52ab2
feat(e2e): add Cloudflare Access auth cookie fixture for Hub tests
ujiro99 Jun 22, 2026
49bfd06
ci: inject Hub secrets into e2e build and test steps
ujiro99 Jun 22, 2026
72aca68
Update: Fix e2e testing.
ujiro99 Jun 22, 2026
e61f921
fix(e2e): resolve race condition and origin mismatch in Hub extension…
ujiro99 Jun 24, 2026
77121d1
debug(e2e): add logging to diagnose E2E-90 hub command install failure
ujiro99 Jun 24, 2026
c55ecba
debug(e2e): improve Hub test logging for easier diagnosis
ujiro99 Jun 24, 2026
2b5b563
debug(e2e): capture all SW restarts and log hubOrigin on startup
ujiro99 Jun 24, 2026
ae2f49a
fix(hub): include extensionId in SyncInstalledCommand postMessage
ujiro99 Jun 24, 2026
69f8753
debug(e2e): register serviceworker listener before waiting for SW
ujiro99 Jun 24, 2026
562b851
feat(build): inject EXTENSION_KEY from env into manifest at build time
ujiro99 Jun 24, 2026
1590c82
Revert "fix(hub): include extensionId in SyncInstalledCommand postMes…
ujiro99 Jun 24, 2026
19b6524
Fix: Set EXTENSION_KEY to build variable on e2e test.
ujiro99 Jun 24, 2026
bcbbd7a
Update: Decrease logs.
ujiro99 Jun 24, 2026
59cc833
fix(e2e): address review findings — cfAuth, test assertion, SW double…
ujiro99 Jun 24, 2026
8d9d613
fix(test): remove incorrect toHaveBeenCalled() from SH-10
ujiro99 Jun 24, 2026
cbb656c
Fix: Wait for communication with the Hub to complete before proceedin…
ujiro99 Jun 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ jobs:
run: yarn build
env:
CI: true
EXTENSION_KEY: ${{ secrets.EXTENSION_KEY }}
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
27 changes: 10 additions & 17 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:

e2e:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-jammy

steps:
- name: Checkout code
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
.env.development.local
.env.test.local
.env.production.local
.env.e2e
.git
tsconfig.tsbuildinfo

Expand Down
3 changes: 3 additions & 0 deletions packages/extension/e2e/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const NEW_HUB_URL =
process.env.VITE_NEW_HUB_URL ??
"https://selection-command-hub.siro-cola.workers.dev"
41 changes: 40 additions & 1 deletion packages/extension/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -32,6 +34,7 @@ type Fixtures = {
setUserSettings: (newSettings: Partial<UserSettings>) => Promise<UserSettings>
getCommands: () => Promise<UserSettings["commands"]>
isAllWindowsNormal: () => Promise<boolean>
cfAccessCookie: string | null
}

/**
Expand All @@ -55,12 +58,21 @@ export const test = base.extend<Fixtures>({
],
})

// 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()
Expand Down Expand Up @@ -207,6 +219,33 @@ export const test = base.extend<Fixtures>({
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
114 changes: 50 additions & 64 deletions packages/extension/e2e/hub.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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()
Expand All @@ -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 () => {
Expand All @@ -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()
Expand All @@ -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 () => {
Expand All @@ -124,28 +106,31 @@ test.describe("Command Hub", () => {
extensionId,
getCommands,
page,
cfAccessCookie: _cfAccessCookie,
}) => {
const optionsPage = new OptionsPage(context, extensionId, getCommands)
await optionsPage.open()
await optionsPage.resetSettings()
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 () => {
Expand All @@ -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()
})
Expand Down
2 changes: 2 additions & 0 deletions packages/extension/e2e/page-action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
21 changes: 21 additions & 0 deletions packages/extension/e2e/utils/cfAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export async function fetchCfAuthToken(
url: string,
clientId: string,
clientSecret: string,
): Promise<string | null> {
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
}
}
11 changes: 11 additions & 0 deletions packages/extension/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading
Loading