From 48cba8153da6e06b8267ed319d0cc42d9b4c6963 Mon Sep 17 00:00:00 2001 From: Henry Zhang Date: Mon, 22 Jun 2026 17:20:18 +0800 Subject: [PATCH] Refactor extension state and UI surfaces --- .github/workflows/ci.yml | 52 +++ .github/workflows/release.yml | 36 +- .gitignore | 4 +- AGENTS.md | 46 ++ CLAUDE.md | 2 +- README.md | 39 +- _locales/en/messages.json | 4 +- _locales/zh_CN/messages.json | 4 +- bin/crxkit.mjs | 11 + biome.json | 10 +- extension.config.json | 71 +++ package.json | 55 ++- playwright.config.ts | 31 ++ pnpm-lock.yaml | 151 +++--- rspack.config.js | 80 ++-- schemas/extension-config.schema.json | 81 ++++ scripts/lib/cli.d.mts | 7 + scripts/lib/cli.mjs | 439 ++++++++++++++++++ scripts/lib/manifest.d.mts | 57 +++ scripts/lib/manifest.mjs | 159 +++++++ scripts/lib/rspack-entries.d.mts | 19 + scripts/lib/rspack-entries.mjs | 21 + .../entries/background/background.test.ts | 115 +++++ .../entries/content/ContentApp.test.tsx | 2 +- src/__tests__/unit/scripts/cli.test.ts | 91 ++++ src/__tests__/unit/scripts/manifest.test.ts | 129 +++++ .../unit/scripts/rspack-entries.test.ts | 47 ++ src/entries/background/app.ts | 180 +++++++ src/entries/background/index.ts | 152 +----- src/entries/content/ContentApp.tsx | 143 +----- src/entries/content/index.ts | 6 +- src/entries/new-tab/index.html | 2 +- src/entries/new-tab/main.tsx | 81 ++-- src/entries/options/index.html | 2 +- src/entries/options/main.tsx | 324 ++++--------- src/entries/popup/index.html | 2 +- src/entries/popup/main.tsx | 2 +- src/entries/side-panel/index.html | 2 +- src/entries/side-panel/main.tsx | 22 +- src/manifest.json | 55 --- src/shared/config/extension.ts | 34 +- src/shared/hooks/useChromeManifest.ts | 4 +- src/shared/platform/i18n.ts | 2 +- src/shared/platform/messaging.ts | 14 +- tests/e2e/extension.spec.ts | 29 ++ tests/e2e/fixtures.ts | 31 ++ tests/e2e/smoke.spec.ts | 19 + tsconfig.json | 2 +- vitest.config.ts => vitest.config.mjs | 3 + 49 files changed, 2057 insertions(+), 817 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 AGENTS.md create mode 100644 bin/crxkit.mjs create mode 100644 extension.config.json create mode 100644 playwright.config.ts create mode 100644 schemas/extension-config.schema.json create mode 100644 scripts/lib/cli.d.mts create mode 100644 scripts/lib/cli.mjs create mode 100644 scripts/lib/manifest.d.mts create mode 100644 scripts/lib/manifest.mjs create mode 100644 scripts/lib/rspack-entries.d.mts create mode 100644 scripts/lib/rspack-entries.mjs create mode 100644 src/__tests__/entries/background/background.test.ts create mode 100644 src/__tests__/unit/scripts/cli.test.ts create mode 100644 src/__tests__/unit/scripts/manifest.test.ts create mode 100644 src/__tests__/unit/scripts/rspack-entries.test.ts create mode 100644 src/entries/background/app.ts delete mode 100644 src/manifest.json create mode 100644 tests/e2e/extension.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/smoke.spec.ts rename vitest.config.ts => vitest.config.mjs (90%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f72ba18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Biome + run: pnpm check + + - name: Unit tests + run: pnpm test:unit + + - name: Build + run: pnpm build + + - name: Validate extension config + run: pnpm validate + + - name: Install Playwright browsers + run: | + pnpm exec playwright install --with-deps chromium + pnpm exec playwright install chrome msedge + + - name: E2E tests + run: xvfb-run --auto-servernum pnpm test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c4adc9..5adb393 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,20 +3,20 @@ name: Release Chrome Extension on: push: tags: - - 'v*' # 触发条件:推送以 v 开头的tag + - 'v*' jobs: build-and-publish: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 with: - fetch-depth: 0 # 获取完整的 git 历史用于版本号 - - - uses: pnpm/action-setup@v4 - name: Install pnpm + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 with: run_install: false @@ -27,24 +27,26 @@ jobs: cache: 'pnpm' - name: Get version from git tag - id: get_version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV + run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV" + + - name: Install dependencies + run: pnpm install --frozen-lockfile - - name: Update version in manifest.json + - name: Install Playwright browsers run: | - sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" src/manifest.json - sed -i "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" package.json + pnpm exec playwright install --with-deps chromium + pnpm exec playwright install chrome msedge - - name: Install dependencies - run: pnpm install + - name: Verify release build + run: xvfb-run --auto-servernum pnpm test:ci - name: Create ZIP file - run: pnpm zip + run: pnpm crx package --version "$VERSION" --out CrxKit.zip - name: Upload to Chrome Web Store uses: mnao305/chrome-extension-upload@v5.0.0 with: - file-path: Tiny-helmet.zip + file-path: CrxKit.zip extension-id: ${{ secrets.CHROME_EXTENSION_ID }} client-id: ${{ secrets.CHROME_CLIENT_ID }} client-secret: ${{ secrets.CHROME_CLIENT_SECRET }} @@ -54,11 +56,11 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v1 with: - files: Tiny-helmet.zip + files: CrxKit.zip name: Release ${{ env.VERSION }} body: | Release version ${{ env.VERSION }} - + Changes in this version: - Please check the commit history for detailed changes draft: false diff --git a/.gitignore b/.gitignore index 5651ba5..bdc4a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules dist +playwright-report +test-results +*.zip .DS_Store .antigravitycli - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..957c55c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Overview + +**CRXKit** is a Chrome Extension Manifest v3 scaffold that ships with React 19, Tailwind CSS v4, shadcn UI primitives, Zustand, TanStack React Query, and Rspack. It exposes React-based popup and side panel surfaces, a configurable background service worker, and a themed, localization-aware content script helper. + +## Development Commands + +- `pnpm dev` – Incremental build that watches all MV3 entrypoints and writes to `dist/`. +- `pnpm build` – Production build with minification and asset copying. +- `pnpm typecheck` – Run TypeScript in no-emit mode using bundler-style resolution. +- **Package manager**: `pnpm@9` (pinned in `package.json`). + +## Architecture + +- **Entries** (`src/entries/`) + - `background/` – Service worker orchestrating side panel enablement and host automation. + - `content/` – Content script that syncs theme preferences, renders a floating opener, and reacts to storage updates. + - `popup/` – React UI for managing hosts, theme, and automation flags. + - `side-panel/` – React UI rendered inside Chrome's side panel, reflecting shared state in real time. +- **Shared modules** (`src/shared/`) + - `config/extension.ts` – Central defaults, entry asset paths, host allowlists, and settings shape. + - `platform/` – Chrome wrappers (`storage.ts`, `i18n.ts`) that guard access when APIs are unavailable. + - `state/useExtensionStore.ts` – Persisted Zustand store with hydration helpers and Chrome event subscription. + - `hooks/` – React hooks for hydration (`useExtensionHydration`) and metadata (`useChromeManifest`). + - `providers/AppProviders.tsx` – Singleton React Query client for popup + side panel surfaces. + - `ui/` – shadcn-inspired primitives (`Button`, `Card`, `Input`). + - `lib/utils.ts` – Utility helpers (`cn`, runtime checks, URL parsing). +- **Styles** (`src/styles/tailwind.css`) – Tailwind 4 tokens for light/dark theming. + +## Key Behaviours + +- Background worker toggles and optionally opens the side panel based on default hosts and user-pinned domains stored in `chrome.storage`. +- Content script applies the persisted theme and exposes a localized floating action button when the active domain is allowed. +- Popup and side panel hydrate shared state via the Zustand store, backed by React Query for extension metadata and ready for future async data. +- Manifest restricts content scripts and host permissions to the default allowlist; update both alongside `extensionConfig` when adding domains. + +## Notes for Contributors + +- Add new React surfaces under `src/entries/` and register them in both `rspack.config.js` and `src/manifest.json`. +- Extend `extensionConfig` when altering storage shape, asset locations, or Chrome permissions to keep background/content logic aligned. +- Prefer shared utilities/hooks/providers to avoid duplicating storage or Chrome API access patterns. +- Use `getMessage` wrappers for user-facing strings and update `_locales/en` + `_locales/zh_CN` together. +- Tailwind tokens power shadcn components; extend `tailwind.config.ts` for new palettes or animations instead of inlining custom CSS. diff --git a/CLAUDE.md b/CLAUDE.md index ad050b0..007a03c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Tiny Helmet** is a Chrome Extension Manifest v3 scaffold that ships with React 19, Tailwind CSS v4, shadcn UI primitives, Zustand, TanStack React Query, and Rspack. It exposes React-based popup and side panel surfaces, a configurable background service worker, and a themed, localization-aware content script helper. +**CRXKit** is a Chrome Extension Manifest v3 scaffold that ships with React 19, Tailwind CSS v4, shadcn UI primitives, Zustand, TanStack React Query, and Rspack. It exposes React-based popup and side panel surfaces, a configurable background service worker, and a themed, localization-aware content script helper. ## Development Commands diff --git a/README.md b/README.md index f202f13..b0b4faf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Tiny Helmet +# CRXKit -A modern Chrome extension scaffold powered by Rspack, React 19, Tailwind CSS v4, shadcn UI primitives, and Zustand state management. Configure hosts, theme, and side panel behaviour with minimal setup and ship production-ready MV3 bundles quickly. +A lightweight Chrome MV3 extension framework powered by Rspack, React 19, Tailwind CSS v4, shadcn UI primitives, Zustand, and a small project-local CLI. Configure entrypoints, manifest metadata, permissions, hosts, and defaults from `extension.config.json`, then ship production-ready bundles from `dist/`. ## Quick start @@ -11,20 +11,23 @@ A modern Chrome extension scaffold powered by Rspack, React 19, Tailwind CSS v4, ## Project layout -- `src/entries/` — MV3 entrypoints for background, content script, popup, and side panel UIs. +- `extension.config.json` — Single source of truth for MV3 entries, permissions, hosts, defaults, and manifest metadata. +- `src/entries/` — MV3 entrypoints for background, content script, popup, side panel, options, and new tab UIs. - `src/entries/content/` — React-powered content script that mounts inside a Shadow DOM with Tailwind styling. - `src/shared/` — Reusable configuration, hooks, providers, state, and shadcn-style UI primitives. - `src/styles/` — Tailwind 4 design tokens and layer definitions. +- `scripts/lib/` & `bin/crxkit.mjs` — Manifest, Rspack, validation, scaffold, and packaging helpers. - `_locales/` & `public/` — i18n resources and static assets copied to the build. ## Tech stack highlights -- **Rspack** for fast, multi-entry bundling tailored to Chrome extensions. +- **Config-driven Rspack** for fast, multi-entry bundling tailored to Chrome extensions. - **React 19 + Tailwind 4** for ergonomics and theming inside popup and side panel surfaces. - **shadcn UI primitives** (`Button`, `Card`, `Input`) with `class-variance-authority` and `tailwind-merge`. - **Zustand + chrome.storage** store shared across background, popup, and side panel. - **React Query** provider ready for async data caching and cross-surface reuse. - **Localization ready** via `_locales`, with theme-aware content script helpers. +- **CRXKit CLI** for validation, entry generation, manifest inspection, project creation, and zip packaging. ## Useful commands @@ -32,19 +35,39 @@ A modern Chrome extension scaffold powered by Rspack, React 19, Tailwind CSS v4, - `pnpm build` — Production bundle with minified assets. - `pnpm typecheck` — Run TypeScript in no-emit mode to validate types. - `pnpm test` — Execute the Vitest suite once (CI-friendly). +- `pnpm test:unit` — Execute unit and component tests with Vitest. +- `pnpm test:e2e` — Execute Playwright E2E and browser smoke tests after `pnpm build`. +- `pnpm test:ci` — Run typecheck, Biome, unit tests, build, validation, and E2E. - `pnpm test:watch` — Re-run tests on file change during local development. - `pnpm test:coverage` — Generate HTML/LCOV coverage output under `coverage/`. - `pnpm lint` — Run Biome lint rules without mutating files. - `pnpm format` — Apply Biome formatting fixes in-place. - `pnpm check` — Run Biome’s combined lint/format/import organization checks in read-only mode. +- `pnpm validate` — Validate `extension.config.json`, entries, locales, and manifest derivation. +- `pnpm crx manifest --print` — Print the generated MV3 manifest. +- `pnpm crx entry add demo --kind page` — Add a React page entry and update `extension.config.json`. +- `pnpm crx package --out CrxKit.zip` — Build and package `dist/` for Chrome Web Store upload. + +## Framework configuration + +`extension.config.json` drives both runtime code and build output: + +- `entries` are converted into Rspack inputs, HTML outputs, service worker registration, content scripts, side panel, options, and new tab manifest fields. +- `permissions`, `hostPermissions`, `webAccessibleResources`, `minimumChromeVersion`, and localized manifest message keys are emitted into `dist/manifest.json`. +- `settings` and `sidePanel.allowedHosts` are imported by `src/shared/config/extension.ts`, so runtime defaults and manifest/build output stay aligned. + +Do not edit `dist/manifest.json` by hand. The source manifest is generated from config during `pnpm build`. ## Testing workflow - Unit and component tests live under `src/__tests__/` and use Vitest with Testing Library. -- The `vitest.config.ts` file mirrors extension aliases (e.g. `@/`) and bootstraps a happy-path Chrome API stub via `src/__tests__/setup/test-setup.ts`. +- CLI and config tests live under `src/__tests__/unit/scripts/`. +- Extension E2E tests live under `tests/e2e/` and use Playwright. +- The `vitest.config.mjs` file mirrors extension aliases (e.g. `@/`) and bootstraps a happy-path Chrome API stub via `src/__tests__/setup/test-setup.ts`. - Prefer co-locating tests near shared logic (`@/shared`) to validate hooks, stores, and shadcn primitives. -- Before opening a PR, run `pnpm typecheck`, `pnpm check`, and `pnpm test` (or `pnpm test:coverage` when you need a report for reviewers). +- Before opening a PR, run `pnpm test:ci` when browser dependencies are installed, or at minimum `pnpm typecheck`, `pnpm check`, `pnpm test:unit`, `pnpm build`, and `pnpm validate`. - When tests require additional Chrome APIs, extend the shared stub instead of mocking per file to keep behaviour consistent. +- Core extension sideload tests use Playwright bundled Chromium. Chrome and Edge projects are smoke tests for built HTML pages because current browser policies do not reliably support sideloaded extension flags there. ## Content script UI @@ -55,6 +78,6 @@ A modern Chrome extension scaffold powered by Rspack, React 19, Tailwind CSS v4, ## Next steps -- Tweak `extensionConfig` + `manifest.json` to add new hosts, permissions, or surfaces. -- Drop additional React entrypoints under `src/entries` and register them inside `rspack.config.js`. +- Tweak `extension.config.json` to add new hosts, permissions, or surfaces. +- Run `pnpm crx entry add --kind page` to generate additional React entrypoints. - Expand `_locales/` alongside UI updates so popup, side panel, and content script stay translated. diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a6dcedb..4b61f28 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,6 +1,6 @@ { "extension_name": { - "message": "Tiny Helmet", + "message": "CRXKit", "description": "The display name of the extension" }, "extension_description": { @@ -92,7 +92,7 @@ "description": "Content script button label when auto-open is enabled" }, "content_open_side_panel_aria": { - "message": "Open Tiny Helmet side panel", + "message": "Open CRXKit side panel", "description": "ARIA label for the floating button" }, "sidepanel_status_ready": { diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index ad9b4a0..24f376c 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -1,6 +1,6 @@ { "extension_name": { - "message": "Tiny Helmet", + "message": "CRXKit", "description": "扩展显示名称" }, "extension_description": { @@ -92,7 +92,7 @@ "description": "内容脚本在自动模式下的按钮文案" }, "content_open_side_panel_aria": { - "message": "打开 Tiny Helmet 侧边栏", + "message": "打开 CRXKit 侧边栏", "description": "浮动按钮的 ARIA 标签" }, "sidepanel_status_ready": { diff --git a/bin/crxkit.mjs b/bin/crxkit.mjs new file mode 100644 index 0000000..6a32628 --- /dev/null +++ b/bin/crxkit.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import { runCli } from '../scripts/lib/cli.mjs'; + +const exitCode = await runCli(process.argv.slice(2), { + cwd: process.cwd(), + stdout: (line) => console.log(line), + stderr: (line) => console.error(line), +}); + +process.exitCode = exitCode; diff --git a/biome.json b/biome.json index 905c5d4..cb89d19 100644 --- a/biome.json +++ b/biome.json @@ -2,7 +2,15 @@ "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", "root": true, "files": { - "includes": ["**", "!**/dist", "!**/node_modules", "!**/coverage"] + "includes": [ + "**", + "!**/dist", + "!**/node_modules", + "!**/coverage", + "!**/playwright-report", + "!**/test-results", + "!**/*.zip" + ] }, "formatter": { "enabled": true, diff --git a/extension.config.json b/extension.config.json new file mode 100644 index 0000000..183fbbe --- /dev/null +++ b/extension.config.json @@ -0,0 +1,71 @@ +{ + "$schema": "./schemas/extension-config.schema.json", + "namespace": "crxkit", + "minimumChromeVersion": "114", + "defaultLocale": "en", + "manifest": { + "nameMessage": "extension_name", + "descriptionMessage": "extension_description", + "version": "0.1.2", + "icons": { + "16": "public/icon16.png", + "32": "public/icon32.png", + "48": "public/icon48.png", + "128": "public/icon128.png" + } + }, + "permissions": ["storage", "activeTab", "tabs", "sidePanel"], + "hostPermissions": [""], + "webAccessibleResources": ["public/*", "contentScript.css"], + "sidePanel": { + "autoOpenDefault": true, + "allowedHosts": ["localhost", "zhanghe.dev"] + }, + "settings": { + "theme": "system", + "pinnedHosts": [], + "sidePanel": { + "autoOpen": true + } + }, + "entries": { + "popup": { + "kind": "popup", + "input": "src/entries/popup/main.tsx", + "html": "src/entries/popup/index.html", + "output": "popup.html" + }, + "sidePanel": { + "kind": "side-panel", + "input": "src/entries/side-panel/main.tsx", + "html": "src/entries/side-panel/index.html", + "output": "sidePanel.html" + }, + "options": { + "kind": "options", + "input": "src/entries/options/main.tsx", + "html": "src/entries/options/index.html", + "output": "options.html", + "openInTab": true + }, + "newTab": { + "kind": "new-tab", + "input": "src/entries/new-tab/main.tsx", + "html": "src/entries/new-tab/index.html", + "output": "newTab.html" + }, + "background": { + "kind": "background", + "input": "src/entries/background/index.ts", + "output": "background.js" + }, + "contentScript": { + "kind": "content", + "input": "src/entries/content/index.ts", + "output": "contentScript.js", + "css": "contentScript.css", + "matches": [""], + "runAt": "document_idle" + } + } +} diff --git a/package.json b/package.json index 0ac5a32..ff1564a 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,51 @@ { - "name": "tiny-helmet", + "name": "crxkit", "version": "0.1.2", - "description": "A modern Rspack-powered Chrome extension scaffold with React, Tailwind CSS, shadcn UI, Zustand, and React Query.", - "main": "index.js", + "description": "A lightweight config-driven Chrome extension framework with React, Rspack, Tailwind CSS, Zustand, and a project-local CLI.", + "main": "./bin/crxkit.mjs", + "files": [ + "bin", + "scripts", + "schemas", + "src", + "public", + "_locales", + "extension.config.json", + "rspack.config.js", + "tailwind.config.ts", + "postcss.config.js", + "tsconfig.json", + "biome.json", + "README.md" + ], + "bin": { + "crxkit": "./bin/crxkit.mjs", + "crx": "./bin/crxkit.mjs" + }, + "exports": { + ".": "./bin/crxkit.mjs", + "./manifest": "./scripts/lib/manifest.mjs", + "./rspack-entries": "./scripts/lib/rspack-entries.mjs" + }, + "engines": { + "node": ">=22" + }, "scripts": { "dev": "rspack build --watch", "build": "rspack build --mode production", "typecheck": "tsc --noEmit", - "test": "vitest run", + "test": "pnpm test:unit", + "test:unit": "vitest run --pool=forks --passWithNoTests", "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:ci": "pnpm typecheck && pnpm check && pnpm test:unit && pnpm build && pnpm validate && pnpm test:e2e", "lint": "biome lint .", "format": "biome format --write .", - "check": "biome check ." + "check": "biome check .", + "crx": "node ./bin/crxkit.mjs", + "validate": "node ./bin/crxkit.mjs validate", + "package:zip": "node ./bin/crxkit.mjs package --out CrxKit.zip" }, "keywords": [ "browser", @@ -26,9 +59,9 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/zh30/tiny-helmet.git" + "url": "https://github.com/zh30/crxkit.git" }, - "homepage": "https://github.com/zh30/tiny-helmet#readme", + "homepage": "https://github.com/zh30/crxkit#readme", "packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf", "dependencies": { "@radix-ui/react-slot": "^1.2.4", @@ -44,15 +77,18 @@ "zustand": "^5.0.13" }, "devDependencies": { + "@biomejs/biome": "^2.4.15", + "@playwright/test": "1.61.0", "@rspack/cli": "^2.0.3", "@rspack/core": "^2.0.3", "@tailwindcss/postcss": "^4.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@biomejs/biome": "^2.4.15", + "@types/node": "^22.19.21", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", + "@types/yazl": "3.3.1", "chrome-types": "^0.1.428", "css-loader": "^7.1.4", "jsdom": "^29.1.1", @@ -61,6 +97,7 @@ "tailwindcss": "^4.3.0", "tailwindcss-animate": "^1.0.7", "typescript": "^6.0.3", - "vitest": "^4.1.7" + "vitest": "^4.1.7", + "yazl": "3.3.1" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..945be53 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + workers: process.env.CI ? 1 : undefined, + expect: { + timeout: 5_000, + }, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['list']], + use: { + trace: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium-extension', + testMatch: /extension\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'chrome-smoke', + testMatch: /smoke\.spec\.ts/, + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + }, + { + name: 'edge-smoke', + testMatch: /smoke\.spec\.ts/, + use: { ...devices['Desktop Edge'], channel: 'msedge' }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2439c26..9e717ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@biomejs/biome': specifier: ^2.4.15 version: 2.4.15 + '@playwright/test': + specifier: 1.61.0 + version: 1.61.0 '@rspack/cli': specifier: ^2.0.3 version: 2.0.3(@rspack/core@2.0.3) @@ -63,12 +66,18 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/node': + specifier: ^22.19.21 + version: 22.19.21 '@types/react': specifier: ^19.2.15 version: 19.2.15 '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.15) + '@types/yazl': + specifier: 3.3.1 + version: 3.3.1 chrome-types: specifier: ^0.1.428 version: 0.1.428 @@ -95,7 +104,10 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@24.2.1)(jsdom@29.1.1)(vite@7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)) + version: 4.1.7(@types/node@22.19.21)(jsdom@29.1.1)(vite@7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)) + yazl: + specifier: 3.3.1 + version: 3.3.1 packages: @@ -155,28 +167,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.15': resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.15': resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -429,6 +437,11 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -481,67 +494,56 @@ packages: resolution: {integrity: sha512-kDWSPafToDd8LcBYd1t5jw7bD5Ojcu12S3uT372e5HKPzQt532vW+rGFFOaiR0opxePyUkHrwz8iWYEyH1IIQA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.2': resolution: {integrity: sha512-gKm7Mk9wCv6/rkzwCiUC4KnevYhlf8ztBrDRT9g/u//1fZLapSRc+eDZj2Eu2wpJ+0RzUKgtNijnVIB4ZxyL+w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.2': resolution: {integrity: sha512-66lA8vnj5mB/rtDNwPgrrKUOtCLVQypkyDa2gMfOefXK6rcZAxKLO9Fy3GkW8VkPnENv9hBkNOFfGLf6rNKGUg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.2': resolution: {integrity: sha512-s+OPucLNdJHvuZHuIz2WwncJ+SfWHFEmlC5nKMUgAelUeBUnlB4wt7rXWiyG4Zn07uY2Dd+SGyVa9oyLkVGOjA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.2': resolution: {integrity: sha512-8wTRM3+gVMDLLDdaT6tKmOE3lJyRy9NpJUS/ZRWmLCmOPIJhVyXwjBo+XbrrwtV33Em1/eCTd5TuGJm4+DmYjw==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.2': resolution: {integrity: sha512-6yqEfgJ1anIeuP2P/zhtfBlDpXUb80t8DpbYwXQ3bQd95JMvUaqiX+fKqYqUwZXqdJDd8xdilNtsHM2N0cFm6A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.2': resolution: {integrity: sha512-sshYUiYVSEI2B6dp4jMncwxbrUqRdNApF2c3bhtLAU0qA8Lrri0p0NauOsTWh3yCCCDyBOjESHMExonp7Nzc0w==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.2': resolution: {integrity: sha512-duBLgd+3pqC4MMwBrKkFxaZerUxZcYApQVC5SdbF5/e/589GwVvlRUnyqMFbM8iUSb1BaoX/3fRL7hB9m2Pj8Q==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.2': resolution: {integrity: sha512-tzhYJJidDUVGMgVyE+PmxENPHlvvqm1KILjjZhB8/xHYqAGeizh3GBGf9u6WdJpZrz1aCpIIHG0LgJgH9rVjHQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.2': resolution: {integrity: sha512-opH8GSUuVcCSSyHHcl5hELrmnk4waZoVpgn/4FDao9iyE4WpQhyWJ5ryl5M3ocp4qkRuHfyXnGqg8M9oKCEKRA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.2': resolution: {integrity: sha512-LSeBHnGli1pPKVJ79ZVJgeZWWZXkEe/5o8kcn23M8eMKCUANejchJbF/JqzM4RRjOJfNRhKJk8FuqL1GKjF5oQ==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.2': resolution: {integrity: sha512-uPj7MQ6/s+/GOpolavm6BPo+6CbhbKYyZHUDvZ/SmJM7pfDBgdGisFX3bY/CBDMg2ZO4utfhlApkSfZ92yXw7Q==} @@ -582,25 +584,21 @@ packages: resolution: {integrity: sha512-aPLDaaTtX1wqjLYAIHc2MGDQZtv1Hbjx47oaaefbWz5GbAnSA4P8jdYIeeGRyrqvQ0WqJXIWXgT0d/iXtes00A==} cpu: [arm64] os: [linux] - libc: [glibc] '@rspack/binding-linux-arm64-musl@2.0.3': resolution: {integrity: sha512-0WulUQPop6vmSDfrTxghmVlm+6crU8/XqD2f0dOWbEniZVuDZJ5/Y/cBqTRyk3rjl0vrmUv3lc87/t7UgQJQSw==} cpu: [arm64] os: [linux] - libc: [musl] '@rspack/binding-linux-x64-gnu@2.0.3': resolution: {integrity: sha512-fAhiMuV5omT53YMft+f3Y9euAFgspuyBAk9ZpeW2buL2TkuUMwP07adhhvQfKdQ5gpELfzmjQaRDGqaIT8UWiA==} cpu: [x64] os: [linux] - libc: [glibc] '@rspack/binding-linux-x64-musl@2.0.3': resolution: {integrity: sha512-0kcuFoZ8vy2iNWoISFOZt+/Ujo7LRLrzE7h07AV5r+oN/mv+/v14Sd/8NUtDIScCkrYOszYq/QS31e6t0UrVfw==} cpu: [x64] os: [linux] - libc: [musl] '@rspack/binding-wasm32-wasi@2.0.3': resolution: {integrity: sha512-x2fsw7GzNZEnw444ikj4/b8kVjM0Y0TllxmizHpYZ9gmaQrOk5OXo9RQdz+l4zzoGors0l2IZP5Cc4GJNCaSoQ==} @@ -687,28 +685,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -796,8 +790,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@24.2.1': - resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==} + '@types/node@22.19.21': + resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} @@ -807,6 +801,9 @@ packages: '@types/react@19.2.15': resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} + '@types/yazl@3.3.1': + resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} @@ -951,6 +948,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1045,10 +1046,6 @@ packages: electron-to-chromium@1.5.199: resolution: {integrity: sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ==} - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} - engines: {node: '>=10.13.0'} - enhanced-resolve@5.21.5: resolution: {integrity: sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==} engines: {node: '>=10.13.0'} @@ -1138,6 +1135,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1245,28 +1247,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1366,6 +1364,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + postcss-loader@8.2.1: resolution: {integrity: sha512-k98jtRzthjj3f76MYTs9JTpRqV1RaaMhEU0Lpw9OTmQZQdppg4B30VZ74BojuBHt3F4KyubHJoXCMUeM8Bqeow==} engines: {node: '>= 18.12.0'} @@ -1526,10 +1534,6 @@ packages: tailwindcss@4.3.0: resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} - tapable@2.2.2: - resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} - engines: {node: '>=6'} - tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -1593,8 +1597,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - undici-types@7.10.0: - resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} undici@7.25.0: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} @@ -1744,6 +1748,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yazl@3.3.1: + resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==} + zustand@5.0.13: resolution: {integrity: sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==} engines: {node: '>=12.20.0'} @@ -1989,6 +1996,10 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.15)(react@19.2.6)': dependencies: react: 19.2.6 @@ -2254,10 +2265,9 @@ snapshots: '@types/json-schema@7.0.15': optional: true - '@types/node@24.2.1': + '@types/node@22.19.21': dependencies: - undici-types: 7.10.0 - optional: true + undici-types: 6.21.0 '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: @@ -2267,6 +2277,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/yazl@3.3.1': + dependencies: + '@types/node': 22.19.21 + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -2276,13 +2290,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1))': + '@vitest/mocker@4.1.7(vite@7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1) + vite: 7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1) '@vitest/pretty-format@4.1.7': dependencies: @@ -2471,6 +2485,8 @@ snapshots: update-browserslist-db: 1.1.3(browserslist@4.25.2) optional: true + buffer-crc32@1.0.0: {} + buffer-from@1.1.2: optional: true @@ -2551,12 +2567,6 @@ snapshots: electron-to-chromium@1.5.199: optional: true - enhanced-resolve@5.18.3: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.2 - optional: true - enhanced-resolve@5.21.5: dependencies: graceful-fs: 4.2.11 @@ -2655,6 +2665,9 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2689,7 +2702,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.2.1 + '@types/node': 22.19.21 merge-stream: 2.0.0 supports-color: 8.1.1 optional: true @@ -2854,6 +2867,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + postcss-loader@8.2.1(@rspack/core@2.0.3)(postcss@8.5.15)(typescript@6.0.3)(webpack@5.95.0): dependencies: cosmiconfig: 9.0.0(typescript@6.0.3) @@ -3028,9 +3049,6 @@ snapshots: tailwindcss@4.3.0: {} - tapable@2.2.2: - optional: true - tapable@2.3.3: {} terser-webpack-plugin@5.3.14(webpack@5.95.0): @@ -3080,8 +3098,7 @@ snapshots: typescript@6.0.3: {} - undici-types@7.10.0: - optional: true + undici-types@6.21.0: {} undici@7.25.0: {} @@ -3104,7 +3121,7 @@ snapshots: util-deprecate@1.0.2: {} - vite@7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1): + vite@7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -3113,16 +3130,16 @@ snapshots: rollup: 4.52.2 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.2.1 + '@types/node': 22.19.21 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 terser: 5.43.1 - vitest@4.1.7(@types/node@24.2.1)(jsdom@29.1.1)(vite@7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)): + vitest@4.1.7(@types/node@22.19.21)(jsdom@29.1.1)(vite@7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)) + '@vitest/mocker': 4.1.7(vite@7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -3139,10 +3156,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 7.3.0(@types/node@24.2.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1) + vite: 7.3.0(@types/node@22.19.21)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.43.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.2.1 + '@types/node': 22.19.21 jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -3172,7 +3189,7 @@ snapshots: acorn-import-attributes: 1.9.5(acorn@8.15.0) browserslist: 4.25.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.3 + enhanced-resolve: 5.21.5 es-module-lexer: 1.7.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -3183,7 +3200,7 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 3.3.0 - tapable: 2.2.2 + tapable: 2.3.3 terser-webpack-plugin: 5.3.14(webpack@5.95.0) watchpack: 2.4.4 webpack-sources: 3.3.3 @@ -3212,6 +3229,10 @@ snapshots: xmlchars@2.2.0: {} + yazl@3.3.1: + dependencies: + buffer-crc32: 1.0.0 + zustand@5.0.13(@types/react@19.2.15)(react@19.2.6)(use-sync-external-store@1.5.0(react@19.2.6)): optionalDependencies: '@types/react': 19.2.15 diff --git a/rspack.config.js b/rspack.config.js index 7457c0b..6669723 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -3,26 +3,59 @@ const path = require('node:path'); const { defineConfig } = require('@rspack/cli'); const rspack = require('@rspack/core'); +const extensionConfig = require('./extension.config.json'); +const packageJson = require('./package.json'); + +class ExtensionManifestPlugin { + constructor(manifest) { + this.manifest = manifest; + } + + apply(compiler) { + compiler.hooks.thisCompilation.tap('ExtensionManifestPlugin', (compilation) => { + compilation.hooks.processAssets.tap( + { + name: 'ExtensionManifestPlugin', + stage: rspack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => { + compilation.emitAsset( + 'manifest.json', + new rspack.sources.RawSource(`${JSON.stringify(this.manifest, null, 2)}\n`) + ); + } + ); + }); + } +} + +async function loadBuildHelpers() { + const [{ generateManifest }, { createHtmlPluginOptions, createRspackEntries }] = + await Promise.all([ + import('./scripts/lib/manifest.mjs'), + import('./scripts/lib/rspack-entries.mjs'), + ]); + + return { generateManifest, createHtmlPluginOptions, createRspackEntries }; +} /** * @param {Record} _env * @param {Record} argv */ -module.exports = (_env, argv) => { +module.exports = async (_env, argv) => { + const { generateManifest, createHtmlPluginOptions, createRspackEntries } = + await loadBuildHelpers(); const mode = argv?.mode || process.env.NODE_ENV || 'development'; const isProd = mode === 'production'; const extensionEnv = process.env.EXTENSION_ENV || (isProd ? 'production' : 'development'); + const manifest = generateManifest(extensionConfig, { + version: process.env.EXTENSION_VERSION || packageJson.version, + }); return defineConfig({ mode, - entry: { - popup: path.resolve(__dirname, 'src/entries/popup/main.tsx'), - sidePanel: path.resolve(__dirname, 'src/entries/side-panel/main.tsx'), - options: path.resolve(__dirname, 'src/entries/options/main.tsx'), - newTab: path.resolve(__dirname, 'src/entries/new-tab/main.tsx'), - background: path.resolve(__dirname, 'src/entries/background/index.ts'), - contentScript: path.resolve(__dirname, 'src/entries/content/index.ts'), - }, + entry: createRspackEntries(extensionConfig, __dirname), output: { path: path.resolve(__dirname, 'dist'), filename: '[name].js', @@ -100,34 +133,13 @@ module.exports = (_env, argv) => { new rspack.CssExtractRspackPlugin({ filename: '[name].css', }), - new rspack.HtmlRspackPlugin({ - template: path.resolve(__dirname, 'src/entries/popup/index.html'), - filename: 'popup.html', - chunks: ['popup'], - minify: isProd, - }), - new rspack.HtmlRspackPlugin({ - template: path.resolve(__dirname, 'src/entries/side-panel/index.html'), - filename: 'sidePanel.html', - chunks: ['sidePanel'], - minify: isProd, - }), - new rspack.HtmlRspackPlugin({ - template: path.resolve(__dirname, 'src/entries/options/index.html'), - filename: 'options.html', - chunks: ['options'], - minify: isProd, - }), - new rspack.HtmlRspackPlugin({ - template: path.resolve(__dirname, 'src/entries/new-tab/index.html'), - filename: 'newTab.html', - chunks: ['newTab'], - minify: isProd, - }), + new ExtensionManifestPlugin(manifest), + ...createHtmlPluginOptions(extensionConfig, __dirname, isProd).map( + (options) => new rspack.HtmlRspackPlugin(options) + ), new rspack.CopyRspackPlugin({ patterns: [ { from: 'public', to: 'public' }, - { from: 'src/manifest.json', to: 'manifest.json' }, { from: '_locales', to: '_locales' }, ], }), diff --git a/schemas/extension-config.schema.json b/schemas/extension-config.schema.json new file mode 100644 index 0000000..2ab31e0 --- /dev/null +++ b/schemas/extension-config.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "CRXKit Extension Config", + "type": "object", + "required": [ + "namespace", + "minimumChromeVersion", + "defaultLocale", + "manifest", + "permissions", + "hostPermissions", + "webAccessibleResources", + "sidePanel", + "settings", + "entries" + ], + "properties": { + "namespace": { "type": "string", "minLength": 1 }, + "minimumChromeVersion": { "type": "string", "minLength": 1 }, + "defaultLocale": { "type": "string", "minLength": 2 }, + "manifest": { + "type": "object", + "required": ["nameMessage", "descriptionMessage", "version", "icons"], + "properties": { + "nameMessage": { "type": "string", "minLength": 1 }, + "descriptionMessage": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "icons": { + "type": "object", + "additionalProperties": { "type": "string" } + } + } + }, + "permissions": { + "type": "array", + "items": { "type": "string" } + }, + "hostPermissions": { + "type": "array", + "items": { "type": "string" } + }, + "webAccessibleResources": { + "type": "array", + "items": { "type": "string" } + }, + "sidePanel": { + "type": "object", + "required": ["autoOpenDefault", "allowedHosts"], + "properties": { + "autoOpenDefault": { "type": "boolean" }, + "allowedHosts": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "settings": { "type": "object" }, + "entries": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["kind", "input", "output"], + "properties": { + "kind": { + "enum": ["page", "content", "background", "popup", "side-panel", "options", "new-tab"] + }, + "input": { "type": "string" }, + "output": { "type": "string" }, + "html": { "type": "string" }, + "css": { "type": "string" }, + "matches": { + "type": "array", + "items": { "type": "string" } + }, + "runAt": { "enum": ["document_idle"] }, + "openInTab": { "type": "boolean" } + } + } + } + } +} diff --git a/scripts/lib/cli.d.mts b/scripts/lib/cli.d.mts new file mode 100644 index 0000000..85eb368 --- /dev/null +++ b/scripts/lib/cli.d.mts @@ -0,0 +1,7 @@ +export interface CliIo { + cwd?: string; + stdout?: (line: string) => void; + stderr?: (line: string) => void; +} + +export function runCli(argv: string[], io?: CliIo): Promise; diff --git a/scripts/lib/cli.mjs b/scripts/lib/cli.mjs new file mode 100644 index 0000000..212bb39 --- /dev/null +++ b/scripts/lib/cli.mjs @@ -0,0 +1,439 @@ +import { spawn } from 'node:child_process'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { access, cp, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { generateManifest, validateExtensionConfig } from './manifest.mjs'; + +const CONFIG_FILE = 'extension.config.json'; +const PAGE_KINDS = new Set(['page', 'popup', 'side-panel', 'options', 'new-tab']); +const ENTRY_KINDS = new Set([ + 'page', + 'content', + 'background', + 'popup', + 'side-panel', + 'options', + 'new-tab', +]); + +function parseFlags(args) { + const flags = {}; + const positional = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg.startsWith('--')) { + positional.push(arg); + continue; + } + + const key = arg.slice(2); + const next = args[index + 1]; + if (!next || next.startsWith('--')) { + flags[key] = true; + continue; + } + + flags[key] = next; + index += 1; + } + + return { flags, positional }; +} + +async function readJson(filePath) { + return JSON.parse(await readFile(filePath, 'utf8')); +} + +async function writeJson(filePath, value) { + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +async function pathExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function collectFiles(rootDir, dir = '.') { + const absoluteDir = path.join(rootDir, dir); + if (!(await pathExists(absoluteDir))) { + return []; + } + + const entries = await readdir(absoluteDir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const relativePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFiles(rootDir, relativePath))); + } else if (entry.isFile()) { + files.push(relativePath.replaceAll(path.sep, '/')); + } + } + + return files; +} + +async function loadLocaleMessages(cwd) { + const localesDir = path.join(cwd, '_locales'); + if (!(await pathExists(localesDir))) { + return {}; + } + + const localeDirs = await readdir(localesDir, { withFileTypes: true }); + const messages = {}; + + for (const locale of localeDirs) { + if (!locale.isDirectory()) { + continue; + } + + const messagesPath = path.join(localesDir, locale.name, 'messages.json'); + if (!(await pathExists(messagesPath))) { + messages[locale.name] = new Set(); + continue; + } + + messages[locale.name] = new Set(Object.keys(await readJson(messagesPath))); + } + + return messages; +} + +async function loadProject(cwd) { + const config = await readJson(path.join(cwd, CONFIG_FILE)); + const packageJson = await readJson(path.join(cwd, 'package.json')); + return { config, packageJson }; +} + +async function validateProject(cwd) { + const { config } = await loadProject(cwd); + const existingPaths = new Set([ + ...(await collectFiles(cwd, 'src')), + ...(await collectFiles(cwd, 'public')), + ]); + const localeMessages = await loadLocaleMessages(cwd); + + return validateExtensionConfig(config, { + existingPaths, + localeMessages, + }); +} + +function pageMainTemplate(componentName) { + return `import '@/styles/tailwind.css'; + +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { AppProviders } from '@/shared/providers/AppProviders'; + +function ${componentName}() { + return ( +
+

${componentName.replace(/App$/, '')}

+
+ ); +} + +const container = document.getElementById('root'); + +if (!container) { + throw new Error('${componentName} root element missing'); +} + +createRoot(container).render( + + + <${componentName} /> + + +); +`; +} + +function htmlTemplate(title) { + return ` + + + + + ${title} + + +
+ + +`; +} + +function contentTemplate(name) { + return `console.info('${name} content script loaded'); +`; +} + +function backgroundTemplate(name) { + return `chrome.runtime.onInstalled.addListener(() => { + console.info('${name} background worker installed'); +}); +`; +} + +function pascalCase(value) { + return value + .split(/[^a-zA-Z0-9]/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(''); +} + +async function addEntry(cwd, args, io) { + const name = args[0]; + const { flags } = parseFlags(args.slice(1)); + const kind = flags.kind; + + if (!name || !/^[a-z][a-zA-Z0-9-]*$/.test(name)) { + io.stderr( + 'Entry name must start with a lowercase letter and contain letters, numbers, or hyphens.' + ); + return 1; + } + + if (!ENTRY_KINDS.has(kind)) { + io.stderr(`Entry kind must be one of: ${Array.from(ENTRY_KINDS).join(', ')}.`); + return 1; + } + + const configPath = path.join(cwd, CONFIG_FILE); + const config = await readJson(configPath); + + if (config.entries[name]) { + io.stderr(`Entry "${name}" already exists.`); + return 1; + } + + const entryDir = path.join('src/entries', name); + const input = `${entryDir}/main.${PAGE_KINDS.has(kind) ? 'tsx' : 'ts'}`; + const output = kind === 'background' ? `${name}.js` : `${name}.js`; + const entry = { + kind, + input, + output: PAGE_KINDS.has(kind) ? `${name}.html` : output, + }; + + if (PAGE_KINDS.has(kind)) { + entry.html = `${entryDir}/index.html`; + } + + if (kind === 'content') { + entry.matches = ['']; + entry.runAt = 'document_idle'; + } + + await mkdir(path.join(cwd, entryDir), { recursive: true }); + + if (PAGE_KINDS.has(kind)) { + const componentName = `${pascalCase(name)}App`; + await writeFile(path.join(cwd, entry.input), pageMainTemplate(componentName)); + await writeFile(path.join(cwd, entry.html), htmlTemplate(name)); + } else if (kind === 'content') { + await writeFile(path.join(cwd, entry.input), contentTemplate(name)); + } else { + await writeFile(path.join(cwd, entry.input), backgroundTemplate(name)); + } + + config.entries[name] = entry; + await writeJson(configPath, config); + io.stdout(`Added ${kind} entry "${name}".`); + return 0; +} + +async function zipDirectory(sourceDir, outputFile) { + const { ZipFile } = await import('yazl'); + const zipFile = new ZipFile(); + const output = createWriteStream(outputFile); + + const addDir = async (dir, prefix = '') => { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolutePath = path.join(dir, entry.name); + const zipPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await addDir(absolutePath, zipPath); + } else if (entry.isFile()) { + zipFile.addReadStream(createReadStream(absolutePath), zipPath); + } + } + }; + + await addDir(sourceDir); + + await new Promise((resolve, reject) => { + output.on('close', resolve); + output.on('error', reject); + zipFile.outputStream.on('error', reject); + zipFile.outputStream.pipe(output); + zipFile.end(); + }); +} + +async function runCommand(command, args, options) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + child.on('error', reject); + child.on('exit', (code) => resolve(code ?? 1)); + }); +} + +async function packageExtension(cwd, args, io) { + const { flags } = parseFlags(args); + const out = flags.out || 'CrxKit.zip'; + const version = flags.version; + const env = { + ...process.env, + ...(version ? { EXTENSION_VERSION: version } : {}), + }; + + const buildCode = await runCommand('pnpm', ['build'], { cwd, env }); + if (buildCode !== 0) { + io.stderr(`Build failed with exit code ${buildCode}.`); + return buildCode; + } + + await zipDirectory(path.join(cwd, 'dist'), path.resolve(cwd, out)); + io.stdout(`Created ${out}.`); + return 0; +} + +async function createProject(cwd, args, io) { + const target = args[0]; + const { flags } = parseFlags(args.slice(1)); + if (!target) { + io.stderr('Missing target directory.'); + return 1; + } + + const targetDir = path.resolve(cwd, target); + if (await pathExists(targetDir)) { + const targetStat = await stat(targetDir); + if (targetStat.isDirectory()) { + const existing = await readdir(targetDir); + if (existing.length > 0) { + io.stderr(`Target directory "${target}" is not empty.`); + return 1; + } + } + } + + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + await cp(repoRoot, targetDir, { + recursive: true, + filter: (source) => { + const relative = path.relative(repoRoot, source); + return !['.git', 'node_modules', 'dist', 'coverage', '.DS_Store'].some( + (ignored) => relative === ignored || relative.startsWith(`${ignored}${path.sep}`) + ); + }, + }); + + const packageJsonPath = path.join(targetDir, 'package.json'); + const packageJson = await readJson(packageJsonPath); + packageJson.name = flags.package || packageJson.name; + await writeJson(packageJsonPath, packageJson); + + if (flags.name) { + for (const locale of ['en', 'zh_CN']) { + const messagesPath = path.join(targetDir, '_locales', locale, 'messages.json'); + const messages = await readJson(messagesPath); + messages.extension_name.message = flags.name; + await writeJson(messagesPath, messages); + } + } + + io.stdout(`Created CRXKit project at ${targetDir}.`); + return 0; +} + +async function printManifest(cwd, args, io) { + const { flags } = parseFlags(args); + const { config, packageJson } = await loadProject(cwd); + const manifest = generateManifest(config, packageJson); + + if (flags.print) { + io.stdout(JSON.stringify(manifest, null, 2)); + } + + if (flags.check) { + const issues = await validateProject(cwd); + if (issues.length > 0) { + issues.forEach((issue) => io.stderr(issue)); + return 1; + } + io.stdout('Manifest is valid.'); + } + + if (!flags.print && !flags.check) { + io.stdout(JSON.stringify(manifest, null, 2)); + } + + return 0; +} + +export async function runCli(argv, io = {}) { + const cwd = io.cwd ?? process.cwd(); + const stdout = io.stdout ?? (() => undefined); + const stderr = io.stderr ?? (() => undefined); + const command = argv[0]; + const args = argv.slice(1); + const resolvedIo = { stdout, stderr }; + + try { + if (!command || command === 'help' || command === '--help') { + stdout('Usage: crx '); + return 0; + } + + if (command === 'validate') { + const issues = await validateProject(cwd); + if (issues.length > 0) { + issues.forEach((issue) => stderr(issue)); + return 1; + } + stdout('Extension config is valid.'); + return 0; + } + + if (command === 'manifest') { + return await printManifest(cwd, args, resolvedIo); + } + + if (command === 'entry' && args[0] === 'add') { + return await addEntry(cwd, args.slice(1), resolvedIo); + } + + if (command === 'package') { + return await packageExtension(cwd, args, resolvedIo); + } + + if (command === 'create') { + return await createProject(cwd, args, resolvedIo); + } + + stderr(`Unknown command "${command}".`); + return 1; + } catch (error) { + stderr(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/scripts/lib/manifest.d.mts b/scripts/lib/manifest.d.mts new file mode 100644 index 0000000..4111234 --- /dev/null +++ b/scripts/lib/manifest.d.mts @@ -0,0 +1,57 @@ +export interface ExtensionEntry { + kind: 'popup' | 'side-panel' | 'options' | 'new-tab' | 'background' | 'content' | 'page'; + input: string; + output: string; + html?: string; + css?: string; + matches?: readonly string[]; + runAt?: 'document_idle'; + openInTab?: boolean; +} + +export interface ExtensionConfig { + namespace: string; + minimumChromeVersion: string; + defaultLocale: string; + manifest: { + nameMessage: string; + descriptionMessage: string; + version: string; + icons: Readonly>; + }; + permissions: readonly string[]; + hostPermissions: readonly string[]; + webAccessibleResources: readonly string[]; + sidePanel: { + autoOpenDefault: boolean; + allowedHosts: readonly string[]; + }; + settings: { + theme: 'light' | 'dark' | 'system'; + pinnedHosts: readonly string[]; + sidePanel: { + autoOpen: boolean; + }; + }; + entries: Readonly>; +} + +export interface ValidationContext { + existingPaths?: Set; + localeMessages?: Record>; +} + +export function generateManifest( + config: ExtensionConfig, + packageMeta?: { version?: string } +): chrome.runtime.ManifestV3; + +export function validateExtensionConfig( + config: ExtensionConfig, + context?: ValidationContext +): string[]; + +export function assertValidExtensionConfig( + config: ExtensionConfig, + context?: ValidationContext +): void; diff --git a/scripts/lib/manifest.mjs b/scripts/lib/manifest.mjs new file mode 100644 index 0000000..fb4e0a7 --- /dev/null +++ b/scripts/lib/manifest.mjs @@ -0,0 +1,159 @@ +const LOCALE_FIELDS = ['nameMessage', 'descriptionMessage']; + +function messageRef(key) { + return `__MSG_${key}__`; +} + +function getEntriesByKind(config, kind) { + return Object.values(config.entries ?? {}).filter((entry) => entry.kind === kind); +} + +function getFirstEntryByKind(config, kind) { + return getEntriesByKind(config, kind)[0]; +} + +function unique(values) { + return Array.from(new Set(values)); +} + +export function generateManifest(config, packageMeta = {}) { + const popup = getFirstEntryByKind(config, 'popup'); + const sidePanel = getFirstEntryByKind(config, 'side-panel'); + const options = getFirstEntryByKind(config, 'options'); + const newTab = getFirstEntryByKind(config, 'new-tab'); + const background = getFirstEntryByKind(config, 'background'); + const contentScripts = getEntriesByKind(config, 'content'); + const webAccessibleResources = unique(config.webAccessibleResources ?? []); + + const manifest = { + manifest_version: 3, + name: messageRef(config.manifest.nameMessage), + version: packageMeta.version ?? config.manifest.version, + description: messageRef(config.manifest.descriptionMessage), + minimum_chrome_version: config.minimumChromeVersion, + default_locale: config.defaultLocale, + icons: config.manifest.icons, + permissions: unique(config.permissions ?? []), + host_permissions: unique(config.hostPermissions ?? []), + }; + + if (popup) { + manifest.action = { + default_title: messageRef(config.manifest.nameMessage), + default_popup: popup.output, + }; + } + + if (sidePanel) { + manifest.side_panel = { + default_path: sidePanel.output, + }; + } + + if (options) { + manifest.options_ui = { + page: options.output, + open_in_tab: options.openInTab ?? true, + }; + } + + if (newTab) { + manifest.chrome_url_overrides = { + newtab: newTab.output, + }; + } + + if (background) { + manifest.background = { + service_worker: background.output, + }; + } + + if (contentScripts.length > 0) { + manifest.content_scripts = contentScripts.map((entry) => ({ + matches: entry.matches ?? [''], + js: [entry.output], + run_at: entry.runAt ?? 'document_idle', + })); + } + + if (webAccessibleResources.length > 0) { + const matches = unique(contentScripts.flatMap((entry) => entry.matches ?? [''])); + manifest.web_accessible_resources = [ + { + resources: webAccessibleResources, + matches: matches.length > 0 ? matches : [''], + }, + ]; + } + + return manifest; +} + +export function validateExtensionConfig(config, context = {}) { + const issues = []; + const entries = Object.values(config.entries ?? {}); + const outputs = new Map(); + const existingPaths = context.existingPaths; + const localeMessages = context.localeMessages ?? {}; + + if (!config.namespace) { + issues.push('Missing required "namespace".'); + } + + if (!config.manifest?.nameMessage) { + issues.push('Missing required "manifest.nameMessage".'); + } + + if (!config.manifest?.descriptionMessage) { + issues.push('Missing required "manifest.descriptionMessage".'); + } + + for (const entry of entries) { + if (!entry.input) { + issues.push(`Entry "${entry.kind ?? 'unknown'}" is missing an input.`); + } else if (existingPaths && !existingPaths.has(entry.input)) { + issues.push(`Missing entry input "${entry.input}".`); + } + + if (entry.html && existingPaths && !existingPaths.has(entry.html)) { + issues.push(`Missing entry html "${entry.html}".`); + } + + if (!entry.output) { + issues.push(`Entry "${entry.input ?? entry.kind ?? 'unknown'}" is missing an output.`); + continue; + } + + const count = outputs.get(entry.output) ?? 0; + outputs.set(entry.output, count + 1); + } + + for (const [output, count] of outputs) { + if (count > 1) { + issues.push(`Entry output "${output}" is used more than once.`); + } + } + + for (const field of LOCALE_FIELDS) { + const key = config.manifest?.[field]; + if (!key) { + continue; + } + + for (const [locale, keys] of Object.entries(localeMessages)) { + if (!keys.has(key)) { + issues.push(`Missing locale key "${key}" in ${locale}.`); + } + } + } + + return issues; +} + +export function assertValidExtensionConfig(config, context = {}) { + const issues = validateExtensionConfig(config, context); + if (issues.length > 0) { + throw new Error(`Invalid extension config:\n${issues.map((issue) => `- ${issue}`).join('\n')}`); + } +} diff --git a/scripts/lib/rspack-entries.d.mts b/scripts/lib/rspack-entries.d.mts new file mode 100644 index 0000000..575c219 --- /dev/null +++ b/scripts/lib/rspack-entries.d.mts @@ -0,0 +1,19 @@ +import type { ExtensionConfig } from './manifest.mjs'; + +export interface HtmlPluginOptions { + template: string; + filename: string; + chunks: string[]; + minify: boolean; +} + +export function createRspackEntries( + config: Pick, + rootDir: string +): Record; + +export function createHtmlPluginOptions( + config: Pick, + rootDir: string, + minify: boolean +): HtmlPluginOptions[]; diff --git a/scripts/lib/rspack-entries.mjs b/scripts/lib/rspack-entries.mjs new file mode 100644 index 0000000..2bda355 --- /dev/null +++ b/scripts/lib/rspack-entries.mjs @@ -0,0 +1,21 @@ +import path from 'node:path'; + +export function createRspackEntries(config, rootDir) { + return Object.fromEntries( + Object.entries(config.entries ?? {}).map(([name, entry]) => [ + name, + path.resolve(rootDir, entry.input), + ]) + ); +} + +export function createHtmlPluginOptions(config, rootDir, minify) { + return Object.entries(config.entries ?? {}) + .filter(([, entry]) => Boolean(entry.html)) + .map(([name, entry]) => ({ + template: path.resolve(rootDir, entry.html), + filename: entry.output, + chunks: [name], + minify, + })); +} diff --git a/src/__tests__/entries/background/background.test.ts b/src/__tests__/entries/background/background.test.ts new file mode 100644 index 0000000..7acae64 --- /dev/null +++ b/src/__tests__/entries/background/background.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerBackgroundHandlers } from '@/entries/background/app'; +import { extensionConfig } from '@/shared/config/extension'; +import * as storage from '@/shared/platform/storage'; + +function createEvent() { + const listeners = new Set<(...args: any[]) => any>(); + return { + addListener: vi.fn((listener: (...args: any[]) => any) => { + listeners.add(listener); + }), + removeListener: vi.fn((listener: (...args: any[]) => any) => { + listeners.delete(listener); + }), + emit: async (...args: any[]) => { + const results = []; + for (const listener of listeners) { + results.push(await listener(...args)); + } + return results; + }, + clear: () => { + listeners.clear(); + }, + }; +} + +vi.mock('@/shared/platform/storage', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn(async () => extensionConfig.defaultSettings), + saveSettings: vi.fn(async (partial) => ({ + ...extensionConfig.defaultSettings, + ...partial, + })), + subscribeToSettings: vi.fn(() => vi.fn()), + } satisfies typeof storage; +}); + +describe('registerBackgroundHandlers', () => { + const runtimeOnInstalled = createEvent(); + const tabsOnUpdated = createEvent(); + const tabsOnActivated = createEvent(); + const actionOnClicked = createEvent(); + const runtimeOnMessage = createEvent(); + const sidePanelSetOptions = vi.fn(async () => undefined); + const sidePanelOpen = vi.fn(async () => undefined); + const tabsGet = vi.fn(async () => ({ id: 7, url: 'https://localhost/docs' })); + + const chromeApi = { + runtime: { + onInstalled: runtimeOnInstalled, + onMessage: runtimeOnMessage, + }, + tabs: { + onUpdated: tabsOnUpdated, + onActivated: tabsOnActivated, + get: tabsGet, + }, + action: { + onClicked: actionOnClicked, + }, + sidePanel: { + setOptions: sidePanelSetOptions, + open: sidePanelOpen, + }, + } as unknown as typeof chrome; + + beforeEach(() => { + vi.clearAllMocks(); + runtimeOnInstalled.clear(); + tabsOnUpdated.clear(); + tabsOnActivated.clear(); + actionOnClicked.clear(); + runtimeOnMessage.clear(); + }); + + it('initializes default settings on first install', async () => { + registerBackgroundHandlers(chromeApi, extensionConfig); + + await runtimeOnInstalled.emit({ reason: 'install' }); + + expect(storage.saveSettings).toHaveBeenCalledWith(extensionConfig.defaultSettings); + }); + + it('enables and opens the side panel on allowed hosts', async () => { + registerBackgroundHandlers(chromeApi, extensionConfig); + + await tabsOnUpdated.emit(7, { status: 'complete' }, { id: 7, url: 'https://localhost/docs' }); + + expect(sidePanelSetOptions).toHaveBeenCalledWith({ + tabId: 7, + path: extensionConfig.sidePanel.assetPath, + enabled: true, + }); + expect(sidePanelOpen).toHaveBeenCalledWith({ tabId: 7 }); + }); + + it('responds to open side panel messages', async () => { + registerBackgroundHandlers(chromeApi, extensionConfig); + const sendResponse = vi.fn(); + const listener = runtimeOnMessage.addListener.mock.calls[0]?.[0]; + + const keepOpen = listener?.( + { type: 'crxkit:open-side-panel', payload: undefined }, + { tab: { id: 7 } }, + sendResponse + ); + await Promise.resolve(); + + expect(keepOpen).toBe(true); + await vi.waitFor(() => expect(sendResponse).toHaveBeenCalledWith({ ok: true })); + }); +}); diff --git a/src/__tests__/entries/content/ContentApp.test.tsx b/src/__tests__/entries/content/ContentApp.test.tsx index f37f13f..e3371cd 100644 --- a/src/__tests__/entries/content/ContentApp.test.tsx +++ b/src/__tests__/entries/content/ContentApp.test.tsx @@ -47,7 +47,7 @@ describe('ContentApp', () => { mockedStorage.loadSettings.mockClear(); mockedStorage.subscribeToSettings.mockClear(); mockedParseUrl.mockReset(); - document.getElementById('tiny-helmet-content-host')?.remove(); + document.getElementById('crxkit-content-host')?.remove(); themeTarget.remove(); }); diff --git a/src/__tests__/unit/scripts/cli.test.ts b/src/__tests__/unit/scripts/cli.test.ts new file mode 100644 index 0000000..c21add2 --- /dev/null +++ b/src/__tests__/unit/scripts/cli.test.ts @@ -0,0 +1,91 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { runCli } from '../../../../scripts/lib/cli.mjs'; + +async function createFixture() { + const cwd = await mkdtemp(path.join(os.tmpdir(), 'crxkit-cli-')); + await mkdir(path.join(cwd, '_locales/en'), { recursive: true }); + await mkdir(path.join(cwd, '_locales/zh_CN'), { recursive: true }); + await mkdir(path.join(cwd, 'src/entries/popup'), { recursive: true }); + + const config = { + namespace: 'crxkit', + minimumChromeVersion: '114', + defaultLocale: 'en', + manifest: { + nameMessage: 'extension_name', + descriptionMessage: 'extension_description', + version: '0.1.2', + icons: { 16: 'public/icon16.png' }, + }, + permissions: ['storage'], + hostPermissions: [''], + webAccessibleResources: [], + sidePanel: { autoOpenDefault: true, allowedHosts: ['localhost'] }, + settings: { theme: 'system', pinnedHosts: [], sidePanel: { autoOpen: true } }, + entries: { + popup: { + kind: 'popup', + input: 'src/entries/popup/main.tsx', + html: 'src/entries/popup/index.html', + output: 'popup.html', + }, + }, + }; + + await writeFile(path.join(cwd, 'extension.config.json'), `${JSON.stringify(config, null, 2)}\n`); + await writeFile(path.join(cwd, 'package.json'), '{"name":"fixture","version":"0.1.2"}\n'); + await writeFile(path.join(cwd, 'src/entries/popup/main.tsx'), 'export {};\n'); + await writeFile(path.join(cwd, 'src/entries/popup/index.html'), '
\n'); + await writeFile( + path.join(cwd, '_locales/en/messages.json'), + '{"extension_name":{"message":"Fixture"},"extension_description":{"message":"Fixture"}}\n' + ); + await writeFile( + path.join(cwd, '_locales/zh_CN/messages.json'), + '{"extension_name":{"message":"Fixture"},"extension_description":{"message":"Fixture"}}\n' + ); + + return cwd; +} + +describe('runCli', () => { + it('validates a fixture extension config', async () => { + const cwd = await createFixture(); + const lines: string[] = []; + + const code = await runCli(['validate'], { + cwd, + stdout: (line) => lines.push(line), + stderr: (line) => lines.push(line), + }); + + expect(code).toBe(0); + expect(lines).toContain('Extension config is valid.'); + }); + + it('adds a page entry and updates extension config', async () => { + const cwd = await createFixture(); + + const code = await runCli(['entry', 'add', 'demo', '--kind', 'page'], { + cwd, + stdout: () => undefined, + stderr: () => undefined, + }); + + const config = JSON.parse(await readFile(path.join(cwd, 'extension.config.json'), 'utf8')); + + expect(code).toBe(0); + expect(config.entries.demo).toEqual({ + kind: 'page', + input: 'src/entries/demo/main.tsx', + html: 'src/entries/demo/index.html', + output: 'demo.html', + }); + await expect(readFile(path.join(cwd, 'src/entries/demo/main.tsx'), 'utf8')).resolves.toContain( + 'DemoApp' + ); + }); +}); diff --git a/src/__tests__/unit/scripts/manifest.test.ts b/src/__tests__/unit/scripts/manifest.test.ts new file mode 100644 index 0000000..6c6fef2 --- /dev/null +++ b/src/__tests__/unit/scripts/manifest.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { generateManifest, validateExtensionConfig } from '../../../../scripts/lib/manifest.mjs'; + +const baseConfig = { + namespace: 'crxkit', + minimumChromeVersion: '114', + defaultLocale: 'en', + manifest: { + nameMessage: 'extension_name', + descriptionMessage: 'extension_description', + version: '0.1.2', + icons: { + 16: 'public/icon16.png', + 32: 'public/icon32.png', + 48: 'public/icon48.png', + 128: 'public/icon128.png', + }, + }, + permissions: ['storage', 'tabs', 'sidePanel'], + hostPermissions: [''], + webAccessibleResources: ['public/*', 'contentScript.css'], + sidePanel: { + autoOpenDefault: true, + allowedHosts: ['localhost'], + }, + settings: { + theme: 'system', + pinnedHosts: [], + sidePanel: { autoOpen: true }, + }, + entries: { + popup: { + kind: 'popup', + input: 'src/entries/popup/main.tsx', + html: 'src/entries/popup/index.html', + output: 'popup.html', + }, + sidePanel: { + kind: 'side-panel', + input: 'src/entries/side-panel/main.tsx', + html: 'src/entries/side-panel/index.html', + output: 'sidePanel.html', + }, + background: { + kind: 'background', + input: 'src/entries/background/index.ts', + output: 'background.js', + }, + contentScript: { + kind: 'content', + input: 'src/entries/content/index.ts', + output: 'contentScript.js', + css: 'contentScript.css', + matches: [''], + runAt: 'document_idle', + }, + options: { + kind: 'options', + input: 'src/entries/options/main.tsx', + html: 'src/entries/options/index.html', + output: 'options.html', + openInTab: true, + }, + newTab: { + kind: 'new-tab', + input: 'src/entries/new-tab/main.tsx', + html: 'src/entries/new-tab/index.html', + output: 'newTab.html', + }, + }, +} as const; + +describe('generateManifest', () => { + it('derives MV3 manifest fields from extension config and package metadata', () => { + const manifest = generateManifest(baseConfig, { version: '9.8.7' }); + + expect(manifest.manifest_version).toBe(3); + expect(manifest.name).toBe('__MSG_extension_name__'); + expect(manifest.version).toBe('9.8.7'); + expect(manifest.action?.default_popup).toBe('popup.html'); + expect(manifest.side_panel?.default_path).toBe('sidePanel.html'); + expect(manifest.options_ui).toEqual({ page: 'options.html', open_in_tab: true }); + expect(manifest.chrome_url_overrides).toEqual({ newtab: 'newTab.html' }); + expect(manifest.background).toEqual({ service_worker: 'background.js' }); + expect(manifest.content_scripts).toEqual([ + { + matches: [''], + js: ['contentScript.js'], + run_at: 'document_idle', + }, + ]); + expect(manifest.web_accessible_resources).toEqual([ + { + resources: ['public/*', 'contentScript.css'], + matches: [''], + }, + ]); + }); +}); + +describe('validateExtensionConfig', () => { + it('reports duplicate entry outputs and missing locale message keys', () => { + const invalidConfig = { + ...baseConfig, + entries: { + ...baseConfig.entries, + duplicatePopup: { + kind: 'page', + input: 'src/entries/duplicate/main.tsx', + html: 'src/entries/duplicate/index.html', + output: 'popup.html', + } as const, + }, + }; + + const issues = validateExtensionConfig(invalidConfig, { + existingPaths: new Set(['src/entries/popup/main.tsx']), + localeMessages: { + en: new Set(['extension_name']), + zh_CN: new Set(['extension_name']), + }, + }); + + expect(issues).toContain('Entry output "popup.html" is used more than once.'); + expect(issues).toContain('Missing locale key "extension_description" in en.'); + expect(issues).toContain('Missing locale key "extension_description" in zh_CN.'); + expect(issues).toContain('Missing entry input "src/entries/duplicate/main.tsx".'); + }); +}); diff --git a/src/__tests__/unit/scripts/rspack-entries.test.ts b/src/__tests__/unit/scripts/rspack-entries.test.ts new file mode 100644 index 0000000..228c83f --- /dev/null +++ b/src/__tests__/unit/scripts/rspack-entries.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + createHtmlPluginOptions, + createRspackEntries, +} from '../../../../scripts/lib/rspack-entries.mjs'; + +const config = { + entries: { + popup: { + kind: 'popup', + input: 'src/entries/popup/main.tsx', + html: 'src/entries/popup/index.html', + output: 'popup.html', + }, + background: { + kind: 'background', + input: 'src/entries/background/index.ts', + output: 'background.js', + }, + contentScript: { + kind: 'content', + input: 'src/entries/content/index.ts', + output: 'contentScript.js', + }, + }, +} as const; + +describe('Rspack entry helpers', () => { + it('creates named entry paths from extension config', () => { + expect(createRspackEntries(config, '/repo')).toEqual({ + popup: '/repo/src/entries/popup/main.tsx', + background: '/repo/src/entries/background/index.ts', + contentScript: '/repo/src/entries/content/index.ts', + }); + }); + + it('creates HTML plugin options only for entries with templates', () => { + expect(createHtmlPluginOptions(config, '/repo', true)).toEqual([ + { + template: '/repo/src/entries/popup/index.html', + filename: 'popup.html', + chunks: ['popup'], + minify: true, + }, + ]); + }); +}); diff --git a/src/entries/background/app.ts b/src/entries/background/app.ts new file mode 100644 index 0000000..5537ca2 --- /dev/null +++ b/src/entries/background/app.ts @@ -0,0 +1,180 @@ +import { type ExtensionSettings, extensionConfig } from '@/shared/config/extension'; +import { parseUrl } from '@/shared/lib/utils'; +import { loadSettings, saveSettings, subscribeToSettings } from '@/shared/platform/storage'; + +type BackgroundConfig = typeof extensionConfig; +type RuntimeMessage = { + type?: string; + payload?: unknown; +}; + +function copySettings(settings: ExtensionSettings): ExtensionSettings { + return { + ...settings, + pinnedHosts: [...settings.pinnedHosts], + sidePanel: { ...settings.sidePanel }, + }; +} + +function isAllowedHost(hostname: string, config: BackgroundConfig, settings: ExtensionSettings) { + return ( + config.sidePanel.allowedHosts.some( + (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`) + ) || settings.pinnedHosts.includes(hostname) + ); +} + +async function openSidePanel(chromeApi: typeof chrome, tabId: number): Promise { + if (typeof chromeApi.sidePanel?.open !== 'function') { + return false; + } + + try { + await chromeApi.sidePanel.open({ tabId }); + return true; + } catch (error) { + console.error('Failed to open side panel', error); + return false; + } +} + +function addMessageListener( + chromeApi: typeof chrome, + type: string, + handler: (payload: unknown, sender: chrome.runtime.MessageSender) => Promise | unknown +) { + const listener = ( + message: RuntimeMessage, + sender: chrome.runtime.MessageSender, + sendResponse: (response?: unknown) => void + ) => { + if (message?.type !== type) { + return undefined; + } + + const result = handler(message.payload, sender); + if (result instanceof Promise) { + result.then(sendResponse); + return true; + } + + sendResponse(result); + return undefined; + }; + + chromeApi.runtime.onMessage.addListener(listener); + return () => chromeApi.runtime.onMessage.removeListener(listener); +} + +export function registerBackgroundHandlers( + chromeApi: typeof chrome, + config: BackgroundConfig = extensionConfig +) { + let cachedSettings = copySettings(config.defaultSettings); + let hydrating = false; + + async function hydrateSettings() { + if (hydrating) { + return; + } + + try { + hydrating = true; + cachedSettings = copySettings(await loadSettings()); + } finally { + hydrating = false; + } + } + + async function syncSidePanel(tabId: number, url?: string | null) { + const parsedUrl = parseUrl(url ?? undefined); + const hostname = parsedUrl?.hostname?.toLowerCase(); + const enabled = Boolean(hostname && isAllowedHost(hostname, config, cachedSettings)); + + try { + await chromeApi.sidePanel.setOptions({ + tabId, + path: config.sidePanel.assetPath, + enabled, + }); + + if (enabled && cachedSettings.sidePanel.autoOpen) { + await openSidePanel(chromeApi, tabId); + } + } catch (error) { + console.error('Failed to update side panel options', error); + } + } + + hydrateSettings().catch((error) => { + console.error('Failed to hydrate settings on startup', error); + }); + + const unsubscribeStorage = subscribeToSettings((settings) => { + cachedSettings = copySettings(settings); + }); + + chromeApi.runtime.onInstalled.addListener(async ({ reason }) => { + if (reason === 'install') { + const next = copySettings(config.defaultSettings); + await saveSettings(next); + cachedSettings = next; + return; + } + + await hydrateSettings(); + }); + + chromeApi.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status !== 'complete') { + return; + } + + await hydrateSettings(); + await syncSidePanel(tabId, changeInfo.url ?? tab.url); + }); + + chromeApi.tabs.onActivated.addListener(async ({ tabId }) => { + try { + await hydrateSettings(); + const tab = await chromeApi.tabs.get(tabId); + await syncSidePanel(tabId, tab.url); + } catch (error) { + console.error('Failed to sync side panel on activation', error); + } + }); + + chromeApi.action.onClicked.addListener(async (tab) => { + if (!tab.id) { + return; + } + + await hydrateSettings(); + await syncSidePanel(tab.id, tab.url); + await openSidePanel(chromeApi, tab.id); + }); + + const removeOpenSidePanelListener = addMessageListener( + chromeApi, + `${config.namespace}:open-side-panel`, + async (_payload, sender) => { + if (!sender.tab?.id) { + return { ok: false, error: 'No tab ID' }; + } + + return { ok: await openSidePanel(chromeApi, sender.tab.id) }; + } + ); + + const removeGetTabInfoListener = addMessageListener( + chromeApi, + `${config.namespace}:get-tab-info`, + (_payload, sender) => ({ url: sender.tab?.url, id: sender.tab?.id }) + ); + + return () => { + unsubscribeStorage(); + removeOpenSidePanelListener(); + removeGetTabInfoListener(); + }; +} diff --git a/src/entries/background/index.ts b/src/entries/background/index.ts index 0b717a9..1e6fdba 100644 --- a/src/entries/background/index.ts +++ b/src/entries/background/index.ts @@ -1,150 +1,4 @@ -import { type ExtensionSettings, extensionConfig, isHostAllowed } from '@/shared/config/extension'; -import { parseUrl } from '@/shared/lib/utils'; -import { loadSettings, saveSettings, subscribeToSettings } from '@/shared/platform/storage'; +import { extensionConfig } from '@/shared/config/extension'; +import { registerBackgroundHandlers } from './app'; -const SIDE_PANEL_PATH = extensionConfig.sidePanel.assetPath; - -let cachedSettings: ExtensionSettings = { - ...extensionConfig.defaultSettings, - pinnedHosts: [...extensionConfig.defaultSettings.pinnedHosts], - sidePanel: { ...extensionConfig.defaultSettings.sidePanel }, -}; -let hydrating = false; - -function copySettings(settings: ExtensionSettings): ExtensionSettings { - return { - ...settings, - pinnedHosts: [...settings.pinnedHosts], - sidePanel: { ...settings.sidePanel }, - }; -} - -async function openSidePanel(tabId: number): Promise { - if (typeof chrome === 'undefined' || typeof chrome.sidePanel?.open !== 'function') { - return false; - } - - const openFn = chrome.sidePanel.open as (options: chrome.sidePanel.OpenOptions) => Promise; - - try { - await openFn({ tabId }); - return true; - } catch (error) { - console.error('Failed to open side panel', error); - return false; - } -} - -async function hydrateSettings() { - if (hydrating) { - return; - } - - try { - hydrating = true; - cachedSettings = copySettings(await loadSettings()); - } finally { - hydrating = false; - } -} - -hydrateSettings().catch((error) => { - console.error('Failed to hydrate settings on startup', error); -}); - -subscribeToSettings((settings) => { - cachedSettings = copySettings(settings); -}); - -chrome.runtime.onInstalled.addListener(async ({ reason }) => { - if (reason === 'install') { - const next = copySettings(extensionConfig.defaultSettings); - await saveSettings(next); - cachedSettings = next; - } else { - await hydrateSettings(); - } -}); - -async function syncSidePanel(tabId: number, url?: string | null) { - const parsedUrl = parseUrl(url ?? undefined); - const hostname = parsedUrl?.hostname?.toLowerCase(); - - const shouldEnable = Boolean( - hostname && - (isHostAllowed(hostname) || - (cachedSettings.sidePanel.autoOpen && cachedSettings.pinnedHosts.includes(hostname))) - ); - - try { - await chrome.sidePanel.setOptions({ - tabId, - path: SIDE_PANEL_PATH, - enabled: shouldEnable, - }); - - if (shouldEnable && cachedSettings.sidePanel.autoOpen) { - await openSidePanel(tabId); - } - } catch (error) { - console.error('Failed to update side panel options', error); - } -} - -chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { - if (changeInfo.status !== 'complete') { - return; - } - - await hydrateSettings(); - await syncSidePanel(tabId, changeInfo.url ?? tab.url); -}); - -chrome.tabs.onActivated.addListener(async ({ tabId }) => { - try { - await hydrateSettings(); - const tab = await chrome.tabs.get(tabId); - await syncSidePanel(tabId, tab.url); - } catch (error) { - console.error('Failed to sync side panel on activation', error); - } -}); - -chrome.action.onClicked.addListener(async (tab) => { - if (!tab.id) { - return; - } - - await hydrateSettings(); - await syncSidePanel(tab.id, tab.url); - await openSidePanel(tab.id); -}); - -import { addMessageListener } from '@/shared/platform/messaging'; - -// ... (previous functions openSidePanel, hydrateSettings, etc.) - -addMessageListener('tiny-helmet:open-side-panel', async (_, sender) => { - if (sender.tab?.id) { - const success = await openSidePanel(sender.tab.id); - return { ok: success }; - } - return { ok: false, error: 'No tab ID' }; -}); - -addMessageListener('tiny-helmet:get-tab-info', (_, sender) => { - return { url: sender.tab?.url, id: sender.tab?.id }; -}); - -addMessageListener('tiny-helmet:show-notification', (payload) => { - chrome.notifications.create({ - type: 'basic', - iconUrl: 'public/icon128.png', - title: payload.title, - message: payload.message, - priority: 2, - }); -}); - -// Example: Context Menu -// ... (rest of context menu and alarm logic) +registerBackgroundHandlers(chrome, extensionConfig); diff --git a/src/entries/content/ContentApp.tsx b/src/entries/content/ContentApp.tsx index 4a49f2d..d8d740d 100644 --- a/src/entries/content/ContentApp.tsx +++ b/src/entries/content/ContentApp.tsx @@ -1,5 +1,4 @@ -import { AnimatePresence, motion } from 'framer-motion'; -import { Zap } from 'lucide-react'; +import { motion } from 'framer-motion'; import * as React from 'react'; import { type ExtensionSettings, extensionConfig, isHostAllowed } from '@/shared/config/extension'; import { useThemeSync } from '@/shared/hooks/useThemeSync'; @@ -8,7 +7,7 @@ import { getMessage } from '@/shared/platform/i18n'; import { sendMessage } from '@/shared/platform/messaging'; import { loadSettings, subscribeToSettings } from '@/shared/platform/storage'; -const PAGE_FLAG = 'data-tiny-helmet'; +const PAGE_FLAG = 'data-crxkit'; type ContentState = { status: 'loading' | 'ready'; @@ -18,7 +17,7 @@ type ContentState = { const OPEN_LABEL = getMessage('content_open_side_panel', 'Open side panel'); const READY_LABEL = getMessage('content_side_panel_ready', 'Side panel ready'); -const OPEN_ARIA_LABEL = getMessage('content_open_side_panel_aria', 'Open Tiny Helmet side panel'); +const OPEN_ARIA_LABEL = getMessage('content_open_side_panel_aria', 'Open CRXKit side panel'); export function ContentApp({ themeTarget }: { themeTarget: HTMLElement }) { const url = React.useMemo(() => parseUrl(window.location.href), []); @@ -29,64 +28,6 @@ export function ContentApp({ themeTarget }: { themeTarget: HTMLElement }) { isAllowed: false, })); - const [selection, setSelection] = React.useState<{ - text: string; - x: number; - y: number; - visible: boolean; - }>({ text: '', x: 0, y: 0, visible: false }); - - React.useEffect(() => { - const handleMouseUp = (e: MouseEvent) => { - // Small delay to ensure selection is processed by browser - setTimeout(() => { - const sel = window.getSelection(); - const selectedText = sel?.toString().trim(); - - if (selectedText && selectedText.length > 0 && sel && sel.rangeCount > 0) { - const range = sel.getRangeAt(0); - const rects = range.getClientRects(); - - if (rects.length === 0) return; - - // Use the last rect to position at the end of the selection - const lastRect = rects[rects.length - 1]; - - const isInsideApp = e.composedPath().some((el) => el === themeTarget); - if (isInsideApp) return; - - setSelection({ - text: selectedText, - x: lastRect.right + 2, - y: lastRect.bottom + 2, - visible: true, - }); - } else { - // If no text is selected, check if we clicked outside our app to hide - const isInsideApp = e.composedPath().some((el) => el === themeTarget); - if (!isInsideApp) { - setSelection((prev) => (prev.visible ? { ...prev, visible: false } : prev)); - } - } - }, 150); - }; - - const handleMouseDown = (e: MouseEvent) => { - // If clicking outside our mount point (Shadow DOM), hide the popover - const isInsideApp = e.composedPath().some((el) => el === themeTarget); - if (!isInsideApp) { - setSelection((prev) => (prev.visible ? { ...prev, visible: false } : prev)); - } - }; - - document.addEventListener('mouseup', handleMouseUp); - document.addEventListener('mousedown', handleMouseDown); - return () => { - document.removeEventListener('mouseup', handleMouseUp); - document.removeEventListener('mousedown', handleMouseDown); - }; - }, [themeTarget]); - React.useEffect(() => { let unsub: (() => void) | null = null; let active = true; @@ -136,24 +77,12 @@ export function ContentApp({ themeTarget }: { themeTarget: HTMLElement }) { const handleOpenSidePanel = React.useCallback(async () => { try { - await sendMessage('tiny-helmet:open-side-panel', undefined); + await sendMessage('crxkit:open-side-panel', undefined); } catch (error) { console.error('Failed to open side panel from content script', error); } }, []); - const handleSelectionClick = React.useCallback(async () => { - try { - await sendMessage('tiny-helmet:show-notification', { - title: 'Text Action', - message: `You selected: "${selection.text.substring(0, 30)}${selection.text.length > 30 ? '...' : ''}"`, - }); - setSelection((s) => ({ ...s, visible: false })); - } catch (error) { - console.error('Failed to show notification', error); - } - }, [selection.text]); - if (!hostname || status === 'loading') { return null; } @@ -161,52 +90,24 @@ export function ContentApp({ themeTarget }: { themeTarget: HTMLElement }) { const autoOpen = settings.sidePanel.autoOpen; const label = autoOpen ? READY_LABEL : OPEN_LABEL; + if (!isAllowed) { + return null; + } + return ( - <> - - {selection.visible && ( - - - - )} - - - {isAllowed && ( - -
- - -
- {label} -
- )} - + + + {label} + ); } diff --git a/src/entries/content/index.ts b/src/entries/content/index.ts index 6e33af1..e5f26df 100644 --- a/src/entries/content/index.ts +++ b/src/entries/content/index.ts @@ -5,8 +5,8 @@ import '@/styles/tailwind.css'; import { ContentApp } from './ContentApp'; -const HOST_ID = 'tiny-helmet-content-host'; -const MOUNT_ID = 'tiny-helmet-content-mount'; +const HOST_ID = 'crxkit-content-host'; +const MOUNT_ID = 'crxkit-content-mount'; function createMountTree() { const existingHost = document.getElementById(HOST_ID); @@ -44,7 +44,7 @@ function createMountTree() { const { host, mount } = createMountTree(); if (!mount) { - throw new Error('Unable to mount Tiny Helmet content UI'); + throw new Error('Unable to mount CRXKit content UI'); } const root = createRoot(mount); diff --git a/src/entries/new-tab/index.html b/src/entries/new-tab/index.html index 3cefbed..6a8308b 100644 --- a/src/entries/new-tab/index.html +++ b/src/entries/new-tab/index.html @@ -4,7 +4,7 @@ - Tiny Helmet - New Tab + CRXKit - New Tab diff --git a/src/entries/new-tab/main.tsx b/src/entries/new-tab/main.tsx index c66ae82..88479e3 100644 --- a/src/entries/new-tab/main.tsx +++ b/src/entries/new-tab/main.tsx @@ -1,66 +1,41 @@ import '@/styles/tailwind.css'; -import { motion } from 'framer-motion'; -import { Sparkles } from 'lucide-react'; +import { Settings } from 'lucide-react'; import * as React from 'react'; import { createRoot } from 'react-dom/client'; +import { useChromeManifest } from '@/shared/hooks/useChromeManifest'; +import { getExtensionName } from '@/shared/platform/i18n'; import { AppProviders } from '@/shared/providers/AppProviders'; +import { Button } from '@/shared/ui/button'; function NewTabApp() { - return ( -
- -
- - - -

- Hello, Explorer. -

-

- Ready to shape the future of your browser? -

-
- -
- - -
-
+ const { data: manifest } = useChromeManifest(); + const extensionName = manifest?.name ?? getExtensionName(); -
-

© 2026 Tiny Helmet Scaffold • Modern Chrome Extension

-
-
+ return ( +
+
+

{extensionName}

+

Chrome extension framework starter

+
+ +
); } const container = document.getElementById('root'); -if (container) { - createRoot(container).render( - - - - - - ); + +if (!container) { + throw new Error('New tab root element missing'); } + +createRoot(container).render( + + + + + +); diff --git a/src/entries/options/index.html b/src/entries/options/index.html index 9955ddc..a324a2c 100644 --- a/src/entries/options/index.html +++ b/src/entries/options/index.html @@ -3,7 +3,7 @@ - Tiny Helmet - Options + CRXKit - Options
diff --git a/src/entries/options/main.tsx b/src/entries/options/main.tsx index 4d45ae3..736b852 100644 --- a/src/entries/options/main.tsx +++ b/src/entries/options/main.tsx @@ -1,276 +1,128 @@ import '@/styles/tailwind.css'; import { clsx } from 'clsx'; -import { motion } from 'framer-motion'; -import { - ExternalLink, - Globe, - Laptop, - Layout, - MoonStar, - Palette, - Settings, - Shield, -} from 'lucide-react'; +import { Laptop, MoonStar, Settings, SunMedium } from 'lucide-react'; import * as React from 'react'; import { createRoot } from 'react-dom/client'; import { useExtensionHydration } from '@/shared/hooks/useExtensionHydration'; import { useThemeSync } from '@/shared/hooks/useThemeSync'; +import { getExtensionName, getMessage } from '@/shared/platform/i18n'; import { AppProviders } from '@/shared/providers/AppProviders'; import { useExtensionStore } from '@/shared/state/useExtensionStore'; -import { Button } from '@/shared/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'; -const Github = (props: React.SVGProps) => ( - - - - -); - -const SECTIONS = [ - { id: 'general', label: 'General', icon: Settings }, - { id: 'appearance', label: 'Appearance', icon: Palette }, - { id: 'privacy', label: 'Privacy & Hosts', icon: Shield }, - { id: 'advanced', label: 'Advanced', icon: Layout }, +const THEMES = [ + { value: 'system', label: 'System', icon: Laptop }, + { value: 'light', label: 'Light', icon: SunMedium }, + { value: 'dark', label: 'Dark', icon: MoonStar }, ] as const; function OptionsApp() { const { loading } = useExtensionHydration(); const { settings, setTheme, setSidePanelAutoOpen } = useExtensionStore(); - const [activeTab, setActiveTab] = React.useState<(typeof SECTIONS)[number]['id']>('general'); - const currentTheme = settings.theme ?? 'system'; + const extensionName = getExtensionName(); + useThemeSync(currentTheme); if (loading) { return ( -
- -
+
+

+ {getMessage('popup_status_loading', 'Loading preferences...')} +

+
); } return ( -
- {/* Background decoration */} -
-
-
-
- -
-
- -
- - Configuration -
-

Settings

-

- Customize your Tiny Helmet{' '} - experience to perfectly fit your workflow. +

+
+
+
+ +
+
+

{extensionName}

+

+ {getMessage('popup_preferences_description', 'Theme and side panel preferences.')}

- - -
- -
-
- - -
- - {activeTab === 'general' && ( - - - General - - Basic behavior and automation settings. - - - -
-
-

Auto-open Side Panel

-

- Automatically expand the side panel when you navigate to a host in your - allowlist. -

-
- -
- -
-

More settings coming soon...

-
-
-
- )} - - {activeTab === 'appearance' && ( - - - - Appearance - - - Personalize how the extension looks. - - - -
- {(['light', 'dark', 'system'] as const).map((t) => ( - - ))} -
-
-
- )} - - {/* Fallback for other tabs */} - {activeTab !== 'general' && activeTab !== 'appearance' && ( - -
-
- -
-

Planned Feature

-

- We're working hard to bring you more customization options in the near future. -

-
-
- )} -
-
-
- - + + +
-
+ ); } const container = document.getElementById('root'); -if (container) { - createRoot(container).render( - - - - - - ); + +if (!container) { + throw new Error('Options root element missing'); } + +createRoot(container).render( + + + + + +); diff --git a/src/entries/popup/index.html b/src/entries/popup/index.html index 31cca3a..ab16839 100644 --- a/src/entries/popup/index.html +++ b/src/entries/popup/index.html @@ -3,7 +3,7 @@ - Tiny Helmet Popup + CRXKit Popup
diff --git a/src/entries/popup/main.tsx b/src/entries/popup/main.tsx index a11c73b..ad232fc 100644 --- a/src/entries/popup/main.tsx +++ b/src/entries/popup/main.tsx @@ -86,7 +86,7 @@ function PopupApp() { } try { - await sendMessage('tiny-helmet:open-side-panel', undefined); + await sendMessage('crxkit:open-side-panel', undefined); } catch (error) { console.error('Failed to open side panel', error); } diff --git a/src/entries/side-panel/index.html b/src/entries/side-panel/index.html index 0ecb757..11c7d24 100644 --- a/src/entries/side-panel/index.html +++ b/src/entries/side-panel/index.html @@ -3,7 +3,7 @@ - Tiny Helmet Side Panel + CRXKit Side Panel
diff --git a/src/entries/side-panel/main.tsx b/src/entries/side-panel/main.tsx index f12f7b7..7287fb9 100644 --- a/src/entries/side-panel/main.tsx +++ b/src/entries/side-panel/main.tsx @@ -2,7 +2,7 @@ import '@/styles/tailwind.css'; import { clsx } from 'clsx'; import { AnimatePresence, motion } from 'framer-motion'; -import { Globe, Info, LayoutTemplate, Pin, PinOff } from 'lucide-react'; +import { Globe, Info, LayoutTemplate, Pin, PinOff, Shield } from 'lucide-react'; import * as React from 'react'; import { createRoot } from 'react-dom/client'; @@ -209,7 +209,7 @@ function SidePanelApp() {

- Tiny Helmet Scaffold v{manifest?.version ?? '0.1.0'} + CRXKit Scaffold v{manifest?.version ?? '0.1.0'}

@@ -228,21 +228,3 @@ createRoot(container).render( ); - -// Minimal Shield import for the icon used in list header -const Shield = (props: any) => ( - - - -); diff --git a/src/manifest.json b/src/manifest.json deleted file mode 100644 index 485a246..0000000 --- a/src/manifest.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "manifest_version": 3, - "name": "__MSG_extension_name__", - "version": "0.1.2", - "description": "__MSG_extension_description__", - "minimum_chrome_version": "114", - "default_locale": "en", - "icons": { - "16": "public/icon16.png", - "32": "public/icon32.png", - "48": "public/icon48.png", - "128": "public/icon128.png" - }, - "action": { - "default_title": "__MSG_extension_name__", - "default_popup": "popup.html" - }, - "side_panel": { - "default_path": "sidePanel.html" - }, - "options_ui": { - "page": "options.html", - "open_in_tab": true - }, - "chrome_url_overrides": { - "newtab": "newTab.html" - }, - "background": { - "service_worker": "background.js" - }, - "content_scripts": [ - { - "matches": [""], - "js": ["contentScript.js"], - "run_at": "document_idle" - } - ], - "permissions": [ - "storage", - "activeTab", - "scripting", - "tabs", - "sidePanel", - "notifications", - "alarms", - "contextMenus" - ], - "host_permissions": [""], - "web_accessible_resources": [ - { - "resources": ["public/*", "contentScript.css"], - "matches": [""] - } - ] -} diff --git a/src/shared/config/extension.ts b/src/shared/config/extension.ts index 125a24b..6ee25ea 100644 --- a/src/shared/config/extension.ts +++ b/src/shared/config/extension.ts @@ -1,3 +1,5 @@ +import rootConfig from '../../../extension.config.json'; + export type ThemePreference = 'light' | 'dark' | 'system'; export interface ExtensionSettings { @@ -8,20 +10,11 @@ export interface ExtensionSettings { }; } -export const EXTENSION_NAMESPACE = 'tiny-helmet'; +export const EXTENSION_NAMESPACE = rootConfig.namespace; export const SETTINGS_STORAGE_KEY = `${EXTENSION_NAMESPACE}:settings` as const; -const DEFAULT_ALLOWED_HOSTS = ['localhost', 'zhanghe.dev'] as const; - -const defaultSettings: ExtensionSettings = { - theme: 'system', - pinnedHosts: [], - sidePanel: { - autoOpen: true, - }, -}; - interface ExtensionConfigShape { + namespace: string; popup: { assetPath: string }; sidePanel: { assetPath: string; allowedHosts: readonly string[] }; background: { assetPath: string }; @@ -29,25 +22,30 @@ interface ExtensionConfigShape { defaultSettings: ExtensionSettings; } +const configuredEntries = rootConfig.entries; +const defaultSettings = rootConfig.settings as ExtensionSettings; +const defaultAllowedHosts = rootConfig.sidePanel.allowedHosts; + export const extensionConfig: ExtensionConfigShape = { + namespace: rootConfig.namespace, popup: { - assetPath: 'popup.html', + assetPath: configuredEntries.popup.output, }, sidePanel: { - assetPath: 'sidePanel.html', - allowedHosts: DEFAULT_ALLOWED_HOSTS, + assetPath: configuredEntries.sidePanel.output, + allowedHosts: defaultAllowedHosts, }, background: { - assetPath: 'background.js', + assetPath: configuredEntries.background.output, }, contentScript: { - matches: ['*://localhost/*', '*://*.zhanghe.dev/*'], - runAt: 'document_idle', + matches: [...configuredEntries.contentScript.matches], + runAt: configuredEntries.contentScript.runAt as 'document_idle', }, defaultSettings, }; -export type DefaultAllowedHost = (typeof DEFAULT_ALLOWED_HOSTS)[number]; +export type DefaultAllowedHost = (typeof defaultAllowedHosts)[number]; export function isHostAllowed(hostname: string): boolean { return extensionConfig.sidePanel.allowedHosts.some( diff --git a/src/shared/hooks/useChromeManifest.ts b/src/shared/hooks/useChromeManifest.ts index 65318ff..11ebcb0 100644 --- a/src/shared/hooks/useChromeManifest.ts +++ b/src/shared/hooks/useChromeManifest.ts @@ -9,7 +9,7 @@ interface ManifestInfo { function readManifest(): ManifestInfo { if (typeof chrome === 'undefined' || !chrome.runtime?.getManifest) { return { - name: 'Tiny Helmet', + name: 'CRXKit', version: '0.0.0', description: 'Local development build', }; @@ -17,7 +17,7 @@ function readManifest(): ManifestInfo { const manifest = chrome.runtime.getManifest(); return { - name: manifest.name ?? 'Tiny Helmet', + name: manifest.name ?? 'CRXKit', version: manifest.version ?? '0.0.0', description: manifest.description ?? 'Chrome extension scaffold', }; diff --git a/src/shared/platform/i18n.ts b/src/shared/platform/i18n.ts index df97218..6afb0ac 100644 --- a/src/shared/platform/i18n.ts +++ b/src/shared/platform/i18n.ts @@ -14,7 +14,7 @@ export function getMessage( } export function getExtensionName(): string { - return getMessage('extension_name', 'Tiny Helmet'); + return getMessage('extension_name', 'CRXKit'); } export function getExtensionDescription(): string { diff --git a/src/shared/platform/messaging.ts b/src/shared/platform/messaging.ts index 3ddaacf..ac18d4d 100644 --- a/src/shared/platform/messaging.ts +++ b/src/shared/platform/messaging.ts @@ -4,24 +4,14 @@ */ export interface MessageMap { - 'tiny-helmet:open-side-panel': { + 'crxkit:open-side-panel': { payload: undefined; response: { ok: boolean; error?: string }; }; - 'tiny-helmet:get-tab-info': { + 'crxkit:get-tab-info': { payload: undefined; response: { url?: string; id?: number }; }; - 'tiny-helmet:sync-settings': { - payload: undefined; - // biome-ignore lint/suspicious/noConfusingVoidType: response needs to be void to match implicitly void callbacks - response: void; - }; - 'tiny-helmet:show-notification': { - payload: { title: string; message: string }; - // biome-ignore lint/suspicious/noConfusingVoidType: response needs to be void to match implicitly void callbacks - response: void; - }; } export type MessageType = keyof MessageMap; diff --git a/tests/e2e/extension.spec.ts b/tests/e2e/extension.spec.ts new file mode 100644 index 0000000..519e70d --- /dev/null +++ b/tests/e2e/extension.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from './fixtures'; + +test('loads core extension pages and injects the content script', async ({ + context, + extensionId, +}) => { + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup.html`); + await expect(popup.getByRole('button', { name: /open side panel/i })).toBeVisible(); + + const options = await context.newPage(); + await options.goto(`chrome-extension://${extensionId}/options.html`); + await expect(options.getByRole('heading', { name: /crxkit/i })).toBeVisible(); + + const sidePanel = await context.newPage(); + await sidePanel.goto(`chrome-extension://${extensionId}/sidePanel.html`); + await expect(sidePanel.getByText(/current session/i)).toBeVisible(); + + const contentPage = await context.newPage(); + await contentPage.route('https://localhost/**', (route) => + route.fulfill({ + contentType: 'text/html', + body: '
Local fixture
', + }) + ); + await contentPage.goto('https://localhost/'); + await expect(contentPage.locator('#crxkit-content-host')).toBeAttached(); + await expect(contentPage.getByRole('button', { name: /open crxkit side panel/i })).toBeVisible(); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..3ae7035 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { type BrowserContext, test as base, chromium } from '@playwright/test'; + +export const extensionPath = path.resolve(process.cwd(), 'dist'); + +type ExtensionFixtures = { + context: BrowserContext; + extensionId: string; +}; + +export const test = base.extend({ + context: async ({ browserName: _browserName }, use) => { + const context = await chromium.launchPersistentContext('', { + channel: 'chromium', + headless: false, + ignoreDefaultArgs: ['--disable-extensions'], + args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`], + }); + + await use(context); + await context.close(); + }, + extensionId: async ({ context }, use) => { + let [serviceWorker] = context.serviceWorkers(); + serviceWorker ??= await context.waitForEvent('serviceworker'); + const extensionId = serviceWorker.url().split('/')[2]; + await use(extensionId); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..99c9eff --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +const distDir = path.resolve(process.cwd(), 'dist'); + +for (const pageName of ['popup.html', 'options.html', 'newTab.html']) { + test(`${pageName} renders without fatal browser errors`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto(`file://${path.join(distDir, pageName)}`, { + waitUntil: 'domcontentloaded', + timeout: 10_000, + }); + await expect(page.locator('#root')).toBeAttached(); + + expect(errors).toEqual([]); + }); +} diff --git a/tsconfig.json b/tsconfig.json index 129f366..29f5e9f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "types": ["chrome-types", "vitest/globals"], + "types": ["chrome-types", "vitest/globals", "node"], "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { diff --git a/vitest.config.ts b/vitest.config.mjs similarity index 90% rename from vitest.config.ts rename to vitest.config.mjs index 0011920..f2a31e5 100644 --- a/vitest.config.ts +++ b/vitest.config.mjs @@ -14,6 +14,9 @@ export default defineConfig({ globals: true, include: ['src/**/*.{test,spec}.{ts,tsx}'], setupFiles: ['src/__tests__/setup/test-setup.ts'], + pool: 'forks', + fileParallelism: false, + teardownTimeout: 2000, server: { deps: { inline: [/zustand/],