diff --git a/.claude/skills/check-japanese-in-src/SKILL.md b/.claude/skills/check-japanese-in-src/SKILL.md new file mode 100644 index 000000000..b8e39527a --- /dev/null +++ b/.claude/skills/check-japanese-in-src/SKILL.md @@ -0,0 +1,11 @@ +--- +name: check-japanese-in-src +description: ソースコード内の日本語を検出するスキル +script: scripts/check-japanese.sh +--- + +# 日本語の検出手順 + +以下を実行してください。 + +`bash "${CLAUDE_SKILL_DIR}/scripts/check-japanese.sh"` diff --git a/.claude/skills/check-japanese-in-src/scripts/check-japanese.sh b/.claude/skills/check-japanese-in-src/scripts/check-japanese.sh new file mode 100755 index 000000000..1dc6df2ca --- /dev/null +++ b/.claude/skills/check-japanese-in-src/scripts/check-japanese.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +rg -n -l '[ぁ-んァ-ン一-龠]' ./packages/extension/src \ + --glob '!**/*.stories.tsx' \ + --glob '!**/*.test.*' \ + --glob '!**/lib/locale*' \ + --glob '!content/' + +rg -n -l '[ぁ-んァ-ン一-龠]' ./packages/extension/e2e \ + --glob '!**/*.stories.tsx' \ + --glob '!**/*.test.*' \ + --glob '!**/lib/locale*' \ + --glob '!content/' diff --git a/.github/workflows/check-ai-selectors.yml b/.github/workflows/check-ai-selectors.yml index 7ff5a0a22..a664f12d8 100644 --- a/.github/workflows/check-ai-selectors.yml +++ b/.github/workflows/check-ai-selectors.yml @@ -35,7 +35,7 @@ jobs: # Must match the @playwright/test version resolved in yarn.lock; # otherwise the bundled browsers don't match and launching fails. # Keep it in sync with .github/workflows/test.yml. - image: mcr.microsoft.com/playwright:v1.62.1-jammy + image: mcr.microsoft.com/playwright:v1.64.1-jammy outputs: broken: ${{ steps.check.outputs.broken }} blocked: ${{ steps.check.outputs.blocked }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a2e61a6d..ff3188523 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: e2e: runs-on: ubuntu-latest container: - image: mcr.microsoft.com/playwright:v1.62.1-jammy + image: mcr.microsoft.com/playwright:v1.64.1-jammy steps: - name: Checkout code diff --git a/docs/design/clipboard.md b/docs/design/clipboard.md new file mode 100644 index 000000000..38d69afc4 --- /dev/null +++ b/docs/design/clipboard.md @@ -0,0 +1,46 @@ +# クリップボード読み取り + +Chrome 116相当以降を対象とし、Service Workerからの読み取りは +`readClipboard()` → `readOffscreenClipboard()` に統一する。 +`clipboard.html` による表示タブ・ウィンドウでの読み取りは使用しない。 + +## 作成コストと再利用 + +- 最初の読み取りでOffscreen Documentを遅延作成する。 +- 読み取り後もドキュメントを維持し、次回以降は同じドキュメントを再利用する。 +- 読み取りは直列化し、同時要求による二重作成や結果の混在を防ぐ。 +- Service Worker再起動時も `runtime.getContexts()` で既存ドキュメントを検出する。 +- Chromeによってドキュメントが破棄された場合は、次の要求で再作成する。 +- textareaの内容は読み取り直後に消去し、クリップボード本文を保持しない。 +- 作成時に他のコンテキストが先に作成していた場合(単一ドキュメント制約のエラー)は、 + 既存ドキュメントをそのまま使用する。 + +## 失敗時の扱い + +- 読み取りに失敗した場合は50ms間隔で最大3回まで再試行する。 +- Offscreen Documentからの応答が `ClipboardResult` の形式でない場合はエラーとして扱う。 +- textareaにフォーカスできない場合は、空文字を成功として返さずエラーとする。 +- エラー時のトーストは、ユーザーが見ているタブへ表示する。 + バックグラウンドタブで開く場合は、表示先ではなく実行元のタブへ通知する。 + +Offscreen Documentを表示先のタブ・ウィンドウへ転用するAPIはないため、 +従来の「読み取り画面を表示先として転用する」最適化はそのまま移植できない。 +代わりに、読み取り側のドキュメントを要求間で再利用する設計とする。 + +従来は、表示先タブ・ウィンドウで読み取った後、その画面を目的URLへ遷移させていた。 +新方式では読み取り用のドキュメントを繰り返し再利用し、表示先は完成したURLで +一度だけ作成する。初回は非表示ドキュメントの作成待ちが必要だが、次回以降は +読み取りごとの作成・破棄と、表示先での中間ページの読み込みを避けられる。 +ブラウザによるレンダラープロセスの割り当ては制御しないため、 +初回を含めて常に高速になるとは保証しない。 + +## 読み取りAPI + +Offscreen Documentはフォーカスできない。検証環境では +`navigator.clipboard.readText()` は存在するものの、 +`Document is not focused` で失敗したため、`clipboardRead` 権限と +textareaへの `document.execCommand("paste")` を使用する。 +APIを将来変更する際は、非フォーカス状態での読み取りが成立することを確認する。 + +- [Offscreen API](https://developer.chrome.com/docs/extensions/reference/api/offscreen) +- [runtime.getContexts](https://developer.chrome.com/docs/extensions/reference/api/runtime#method-getContexts) diff --git a/docs/design/tailwindcss-v4-migration.md b/docs/design/tailwindcss-v4-migration.md new file mode 100644 index 000000000..da5e10ea5 --- /dev/null +++ b/docs/design/tailwindcss-v4-migration.md @@ -0,0 +1,303 @@ +# Plan: Tailwind CSS v4 への移行(extension)と Hub からの Tailwind 撤去 + +- Issue: [#497 tailwindcss v4系への移行](https://github.com/ujiro99/selection-command/issues/497) +- 参照: [Tailwind CSS Upgrade Guide](https://tailwindcss.com/docs/upgrade-guide) + +## Context + +現在は Tailwind CSS v3(root `^3.4.17` / hub `^3.4.1`)を使用している。v4 は CSS-first な設定(`@theme`)、新エンジン、ネイティブ Cascade Layers / `@property` / `color-mix()` を前提とした大型アップデートであり、設定方法・ユーティリティ名・デフォルトスタイルに破壊的変更がある。 + +本対応は 1 つの PR で以下の 2 つを行う。 + +1. **extension**: Tailwind CSS v4 へ移行する。 +2. **hub**: Tailwind CSS の使用をやめ、素の CSS(CSS Modules + グローバル CSS)に置き換える。hub の役割はテストページと設定値(`ai-services.json` / `experiments.json`)のホスティングのみであり、Tailwind を導入しておくメリットがないため。 + +### 現状の構成 + +| 項目 | 内容 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 共通設定 | `/tailwind.config.js`(shadcn 系のカラートークン、`borderRadius`、`fontFamily`、`tailwindcss-animate`、`darkMode: ["class"]`、`container`)。extension と hub が spread して利用 | +| extension | `packages/extension/tailwind.config.js` が共通設定を spread して拡張(独自 keyframes/animation 多数、`fontSize` / `padding` / `translate` / `minWidth` を **px で上書き**、`listStyleType`) | +| hub | `packages/hub/tailwind.config.ts` が共通設定を spread して拡張(`chart` カラー、`scalePop` 等の keyframes、`data` variant) | +| PostCSS | extension: `postcss.config.cjs`(`tailwindcss` + `autoprefixer`) / hub: `postcss.config.mjs`(`tailwindcss`) | +| エントリ CSS | extension: `src/components/global.css`(`@import "tailwindcss/base"` 等)→ `App.css` から `@import` / hub: `src/app/globals.css`(`@tailwind base` 等) | +| `@apply` を使う CSS Modules | extension 8 ファイル(`Popup` / `TableOfContents` / `HubBanner` / `ShortcutList` / `Option` / `CommandEditDialog` / `PageActionRecorder` / `help`)、hub 2 ファイル(`page` / `Header`) | +| 関連ライブラリ | `tailwind-merge ^2.5`(shared の `cn()`、extension、hub)、`tailwindcss-animate ^1.0.7`、`class-variance-authority`、shadcn/ui(extension の `components/ui/*`、hub の `components/ui/*` と `components.json`) | +| ビルド | extension: Vite 6 + crxjs。content script の CSS は manifest から除去し、Shadow DOM(`#selection-command`)へ手動注入(`content_script.tsx` / `vite-plugin-manifest.ts`)。dev 時は `vite-plugin-css-injected-by-js` で Shadow root へ注入 | + +### extension で v4 の影響を受けるクラスの使用状況(概算、`src` 配下の grep 件数) + +| v3 クラス | v4 での扱い | 件数 | +| --------------------------------------------------------------- | ----------------------------------- | ----------- | +| `shadow-sm` / `shadow` | `shadow-xs` / `shadow-sm` | 9 / ~60 | +| `rounded-sm` / `rounded` | `rounded-xs` / `rounded-sm` | 17 / ~144 | +| `blur` / `backdrop-blur` | `blur-sm` / `backdrop-blur-sm` | ~13 / 3 | +| `outline-none` | `outline-hidden` | 16 | +| `ring` | `ring-3`(v4 の `ring` は 1px) | ~45 | +| `flex-shrink-*` | `shrink-*` | 1 | +| `space-x/y-*` | セレクタ変更(`:not(:last-child)`) | 36 | +| `bg-gradient-to-*` | `bg-linear-to-*` | 1+ | +| `!` important 修飾子 / `bg-[--var]` / `theme()` / `*-opacity-*` | — | 0 | +| `container` ユーティリティ | — | 0(未使用) | + +※ `shadow` / `rounded` / `ring` などはコメントや変数名の誤検出を含むため概数。正確な置換はアップグレードツールに任せ、差分をレビューする。 + +### hub で Tailwind を使用している箇所 + +| ファイル | 内容 | +| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `src/app/globals.css` | `@tailwind` 指令、shadcn 系 CSS 変数(`:root` / `.dark`)、`@apply` による base、`clickable-button` | +| `src/app/page.module.css` | `@apply` によるレイアウト(`sm:` / `md:` のレスポンシブ指定を含む) | +| `src/components/layout/Header.module.css` | `@apply sm:top-8 sm:right-12` | +| `src/app/layout.tsx` / `src/app/[lang]/layout.tsx` | body の `bg-stone-50 text-stone-700` 等 | +| `src/app/page.tsx` / `src/app/not-found.tsx` | 移転案内ページ(各 9 箇所の `className`) | +| `src/app/[lang]/test/page.tsx` | **e2e テストページ**(41 箇所の `className`) | +| `src/components/layout/Header.tsx` / `Footer.tsx` / `LocaleSelector.tsx` / `Image.tsx` | レイアウト部品 | +| `src/components/ui/button.tsx` / `select.tsx` / `separator.tsx` | shadcn/ui(`cva` + `cn` + Tailwind クラス)。`select` は `@radix-ui/react-select`、`separator` は `@radix-ui/react-separator` に依存 | +| `src/lib/utils.ts` / `src/lib/__tests__/utils.test.ts` | shared の `cn()`(`tailwind-merge`)の再エクスポートとそのテスト | + +## 設計方針 + +### 1. 対応ブラウザ要件 + +v4 は Chrome 111+ / Safari 16.4+ / Firefox 128+ を要求する。extension は Chrome 拡張(Manifest V3)のため **要件上の問題はない**。manifest の `minimum_chrome_version` を設定している場合は 111 以上であることを確認する。hub は Tailwind を撤去するため本要件の対象外。 + +### 2. ビルド統合 + +- **extension**: `@tailwindcss/vite` を採用(公式推奨・高速)。`postcss.config.cjs` と `autoprefixer` を削除する。 + - crxjs / `vite-plugin-css-injected-by-js` / `removeCssFromContentScript` との併用で CSS 出力ファイル構成が変わらないかを確認する。問題があれば `@tailwindcss/postcss` にフォールバックする。 +- **hub**: Tailwind / PostCSS 設定を削除する(7 章)。 +- `tailwindcss` 本体の依存は root と hub から外し、extension の `devDependencies` にのみ明示する(現在 extension は root の hoist に依存している)。 + +### 3. extension の設定は CSS-first(`@theme`)へ移行する + +`@config` で JS 設定を読み込み続ける方式も可能だが、v4 では非推奨寄りで、共通設定を spread する現在の構成は `@config` と相性が悪い。また hub が Tailwind を使わなくなるため、パッケージ間で Tailwind テーマを共有する必要もなくなる。そこで root の `tailwind.config.js` と extension の `tailwind.config.js` の内容を、extension のエントリ CSS(`global.css`、必要に応じて `theme.css` に分割)へ統合する。 + +- shadcn 系カラー: 既存の `hsl(var(--xxx))` 形式を維持し、`@theme inline { --color-border: hsl(var(--border)); ... }` で接続する(oklch への変換は本移行のスコープ外)。 +- `--radius-lg/md/sm`、`--font-sans` を移植。 +- `darkMode: ["class"]` → `@custom-variant dark (&:where(.dark, .dark *));` +- keyframes / animation(`--animate-*` + `@keyframes`)を移植。 +- `listStyleType` は `@utility list-square` / `list-circle` 等で再定義。 +- `fontSize` / `padding` / `translate` / `minWidth` の px 上書きは 4.2 の方針で置き換える。 +- `container` 設定は `container` ユーティリティ未使用のため削除する。 +- `tailwindcss-animate` → **`tw-animate-css`**(shadcn 公式の v4 向け後継)へ置き換え、CSS から `@import "tw-animate-css";` する。 +- root / extension の `tailwind.config.js` は削除する。 + +### 4. ユーザーページとの相互不干渉(設計原則) + +content script はユーザーが閲覧中の任意のページ(以下「ユーザーページ」)上で動作する。そのため、本移行では以下の 2 点を必須要件とする。 + +- **ユーザーページへ影響を与えない**: 拡張機能の CSS・CSS 変数・`@property` 登録などを Shadow root の外(`document` / `document.head`)へ一切出さない。 +- **ユーザーページから影響を受けない**: ユーザーページの root font-size(`rem` の基準)や `@property` 登録などに、content script UI の見た目が左右されないようにする。 + +以下の 4.1 / 4.2 はこの原則に基づく設計である。 + +#### 4.1 Shadow DOM 内での `@property` 非対応への対策(最重要リスク) + +content script の UI は Shadow DOM 内でスタイルを適用している。v4 は `shadow-*` / `ring-*` / `translate-*` / `scale-*` / `rotate-*` / グラデーション / `blur` などで `@property` による CSS 変数の初期値登録に依存しているが、**Chrome は Shadow root 内の `@property` を無視する**ため、これらのユーティリティが効かなくなる(既知の問題)。 + +**対策: 自前のビルドプラグインで初期値を Shadow root 内に閉じて付与する。** + +- ビルド時に生成 CSS の `@property --tw-*` 定義から `initial-value` を抽出し、`:host, *, ::before, ::after, ::backdrop { --tw-xxx: ; }` を `@layer base`(`@layer properties`)に付与する小さな PostCSS / Vite プラグインを作成する。 +- `initial-value` を持たない `@property` も `initial` として宣言する。Shadow DOM 内では未登録のカスタムプロパティとして扱われ常に継承されるため、宣言しないと祖先要素(ユーザーページ側を含む)の値が流入する(例: 親の `duration-300` が子の `transition-all` に伝わる)。 +- 付与したルールは Shadow root に注入される CSS の中にのみ存在するため、ユーザーページへは影響しない。 +- `@property` を `document.head` に注入する方式は、ユーザーページにグローバルな登録を残して影響を与えるため**採用しない**。逆に、ユーザーページ側が同名の `--tw-*` を `@property` 登録している場合(ユーザーページが Tailwind v4 製の場合など)でも、各要素に明示的に値を宣言するため初期値の差異の影響を受けない。 + - ただし `@property` の `syntax`(型)はユーザーページ側の登録が Shadow DOM 内にも適用される。Tailwind v4 同士であれば同一定義のため問題ないが、型の異なる同名登録がある場合の挙動を PoC で確認する。 +- options page / onboarding page は拡張機能自身のページであり、`@property` が通常どおり機能するため本プラグインの付与は無害(同値の宣言が増えるのみ)。content script 用 CSS のみに適用するかは PoC で判断する。 + +また v4 の Preflight は `html, :host` を対象にするため、v3 より Shadow DOM との相性は改善する見込み。 + +#### 4.2 `rem` 依存の排除(px 化) + +`rem` はドキュメントの root(``)の font-size を基準とするため、Shadow DOM 内でも**ユーザーページの root font-size の影響を受ける**。現状(v3)でも extension は `fontSize.xs/sm` と `padding` / `translate` / `minWidth` の一部を px で上書きしてこれを回避しているが、上書きしていないトークン(margin / gap / width、`text-base` 以上、`max-w-*` 等)は rem のままで、**既にユーザーページの影響を受けている**。 + +v4 ではスペーシングが `--spacing` を基準にした単一スケールになり、`padding` だけを上書きする手段がない。そこで本移行で、extension のテーマから rem 依存を排除する。 + +- `@theme { --spacing: 4px; }` とし、スペーシング系ユーティリティ(padding / margin / gap / width / height / inset / translate 等)をすべて px 基準にする。現在の px 上書き(`p-1 = 4px` 等)と同じ値になる。 +- rem で定義されている v4 のデフォルトトークンを px で再定義する(16px = 1rem 換算で、標準環境での見た目は不変)。 + - `--text-*`(`xs: 12px` / `sm: 14px` / `base: 16px` / `lg` / `xl` / `2xl`〜`4xl` 等、使用中のもの以上)と対応する `--text-*--line-height` + - `--container-*`(`max-w-lg` / `max-w-xl` / `max-w-2xl`〜`4xl` 等) + - `--radius-*`(root の `--radius` は extension では既に `8px`) + - `--tracking-*` は em 単位のため対象外。`--leading-*` は単位なしのため対象外。 + - `--breakpoint-*` もメディアクエリ内の rem がブラウザの既定フォントサイズ設定に連動して v3 と挙動が変わるため、px で再定義する(ユーザーページの影響は受けないため rem 検査の対象外)。 +- 再定義漏れを防ぐため、ビルド後の content script 用 CSS に `rem` が残っていないことを確認するチェック(ビルドスクリプト or ユニットテスト)を追加する。arbitrary value(`w-[2rem]` 等)での rem 記述も禁止し、同チェックで検出する。 +- v3 では `fontSize.xs/sm` が文字列指定のため line-height が付与されていなかったが、v4 では `--text-*--line-height` が付与される。差分が出る場合は `--text-xs--line-height` / `--text-sm--line-height` を調整する。 +- options page / onboarding page も同じテーマを共有するが、root font-size は既定の 16px のため見た目は変わらない。 + +### 5. extension のデフォルトスタイル変更への対応 + +| 変更点 | 対応 | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| border の既定色が `currentColor` に | 既存の `* { @apply border-border; }` があるため影響なし(維持する) | +| `ring` の既定幅 3px→1px、既定色 | アップグレードツールで `ring` → `ring-3` に置換。既定色は shadcn の `ring-ring` 指定箇所が多いため個別確認 | +| placeholder 色 | 現行の見た目を確認し、必要なら `@layer base` で `gray-400` 相当を指定 | +| `button` の `cursor: default` | v3 と同様にするため `button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; }` を base に追加 | +| `hover:` が `@media (hover: hover)` 内に | 拡張機能はデスクトップ前提のため原則そのまま。e2e で hover 依存のテストが落ちる場合のみ `@custom-variant hover (&:hover);` | +| `hidden` 属性の優先度 | `hidden` 属性と `flex` 等を併用している箇所がないか確認 | +| `space-x/y-*` のセレクタ変更 | 36 箇所を目視確認。インライン要素や条件付きレンダリングの子でズレる場合は `flex` + `gap` に置換 | +| `transform` 系の個別プロパティ化 | `transition-[transform]` / `transform-none` の使用箇所を確認。`onboarding-float` は既に `translate` プロパティを使用しているため互換性は良い | + +### 6. extension の CSS Modules / `@layer` の書き換え + +- `@apply` を含む CSS Modules(8 ファイル)の先頭に `@reference "";` を追加する。 +- `global.css` の `@layer components` / `@layer utilities` 内の独自クラス(`marquee-var` / `animate-pause` / `transition-size`)を `@utility` に移行する。`@layer base` はそのまま利用可能。 +- `@import "tailwindcss/base"` 等を `@import "tailwindcss";` に置換する。 + +### 7. hub からの Tailwind 撤去 + +hub は「テストページ」と「設定値(`ai-services.json` / `experiments.json`)のホスティング」のみを担うため、Tailwind・shadcn/ui をやめて素の CSS にする。見た目の厳密な再現は不要だが、**e2e テストページの DOM 構造は維持する**。 + +#### 7.1 スタイリング方針 + +- Next.js 組み込みの CSS サポート(グローバル CSS + CSS Modules)のみを使う。PostCSS プラグインは不要なため `postcss.config.mjs` を削除する(Next.js のデフォルト設定に戻る)。CSS のネスト(`common.module.css` の `&:hover`)はブラウザのネイティブ CSS Nesting で動作する。 +- `globals.css`: `@tailwind` 指令と `@apply` を削除し、必要最小限のリセット(`box-sizing`、`margin: 0` 等)と body の配色・フォントを素の CSS で記述する。shadcn 系 CSS 変数のうち使用するもの(`--border` 等)のみ残し、`.dark` / `--chart-*` / `clickable-button` は削除する。 +- `page.module.css` / `Header.module.css`: `@apply` を素の CSS に展開する。`sm:` / `md:` は `@media (min-width: 640px)` / `(min-width: 768px)` に置き換える。 +- 各コンポーネントの `className` に書かれた Tailwind クラスは、コンポーネントごとの CSS Modules に移す。対象: `layout.tsx`、`[lang]/layout.tsx`、`page.tsx`、`not-found.tsx`、`[lang]/test/page.tsx`、`Header.tsx`、`Footer.tsx`、`LocaleSelector.tsx`、`Image.tsx`。 + - `page.tsx` と `not-found.tsx` は同じ移転案内のマークアップのため、CSS Modules を共有する。 + - `Image.tsx` の `dark:invert` は `.dark` クラスを付与する仕組みが hub にないため削除する。 + +#### 7.2 shadcn/ui コンポーネントの置き換え + +| コンポーネント | 置き換え | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `ui/button.tsx` | テストページでのみ使用。素の ` + + + ) : null, +})) + +vi.mock("@/components/option/editor/FolderEditDialog", () => ({ + FolderEditDialog: () => null, +})) + +// Exposes the edit handler so the test can open the dialog for a command. +vi.mock("../CommandTreeRenderer", () => ({ + CommandTreeRenderer: (props: any) => ( + + ), +})) + +vi.mock("@dnd-kit/core", () => ({ + DndContext: (props: any) => <>{props.children}, + closestCenter: vi.fn(), + KeyboardSensor: vi.fn(), + PointerSensor: vi.fn(), + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), +})) + +vi.mock("@dnd-kit/sortable", () => ({ + SortableContext: (props: any) => <>{props.children}, + sortableKeyboardCoordinates: vi.fn(), + verticalListSortingStrategy: {}, +})) + +vi.mock("@/hooks/option/useCommandActions", () => ({ + useCommandActions: () => ({ commandRemove: vi.fn() }), +})) + +vi.mock("@/hooks/option/useCommandDragDrop", () => ({ + useCommandDragDrop: () => ({ handleDragEnd: vi.fn() }), +})) + +vi.mock("@/hooks/option/useSharedCommandIds", () => ({ + useSharedCommandIds: () => new Set(["cmd-1"]), +})) + +vi.mock("@/services/hubShare", () => ({ + editCommandToHub: vi.fn(() => true), + pushEditToHub: vi.fn(() => true), + isHubShareable: vi.fn(() => false), +})) + +vi.mock("@/components/option/HubShareToast", () => ({ + showHubShareToast: vi.fn(), +})) + +vi.mock("@/services/settings/enhancedSettings", () => ({ + enhancedSettings: { + getSection: vi.fn(), + }, +})) + +vi.mock("@/services/settings/settings", () => ({ + Settings: { + update: vi.fn(), + updateCommandId: vi.fn(), + }, +})) + +vi.mock("@/services/analytics", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + sendEvent: vi.fn(), + } +}) + +const mockEditCommandToHub = vi.mocked(editCommandToHub) +const mockPushEditToHub = vi.mocked(pushEditToHub) + +const storedCommand = { + id: "cmd-1", + title: "Before", + openMode: OPEN_MODE.POPUP, + searchUrl: "https://example.com/search?q=%s", + contentHash: "old-hash", +} as SelectionCommand + +const editedCommand = { ...storedCommand, title: "After" } as SelectionCommand + +type SaveState = { + // Whether the form has notified value changes (where SettingForm schedules + // its debounced save). + notified: boolean + // Whether the debounced save was already scheduled when saveNow was called, + // so that saveNow can take it over. + notifiedBeforeSave?: boolean + savedTitle?: string +} + +/** + * Mimics SettingForm: it subscribes to the form values (where the debounced + * save is scheduled) and passes saveNow, which resolves when `resolveSave` is + * called so the test can check what happens before the save completes. + */ +function Wrapper({ + state, + onSaveStart, +}: { + state: SaveState + onSaveStart: (resolve: () => void) => void +}) { + const { control, getValues, subscribe } = useForm({ + defaultValues: { commands: [storedCommand], folders: [] }, + }) + + useEffect( + () => + subscribe({ + formState: { values: true }, + callback: () => { + state.notified = true + }, + }), + [subscribe, state], + ) + + const saveNow = () => + new Promise((resolve) => { + state.notifiedBeforeSave = state.notified + state.savedTitle = getValues("commands")[0].title + onSaveStart(resolve) + }) + + return +} + +describe("CommandList: saving before sending a command to the Hub", () => { + beforeEach(() => { + vi.clearAllMocks() + nextSubmittedCommand = editedCommand + }) + + afterEach(() => { + history.replaceState(null, "", "/") + }) + + it("HS-01: 正常系: 「更新&共有」ではフォームの保存完了後に pushEditToHub を呼ぶ", async () => { + const state: SaveState = { notified: false } + let resolveSave: (() => void) | undefined + const user = userEvent.setup() + render( (resolveSave = r)} />) + + await user.click(screen.getByText("edit-command")) + await user.click(screen.getByText("submit-and-share-command")) + + // The edited command is saved, taking over the scheduled debounced save. + expect(state.savedTitle).toBe("After") + expect(state.notifiedBeforeSave).toBe(true) + // Not pushed until the save completes. + expect(mockPushEditToHub).not.toHaveBeenCalled() + + await act(async () => resolveSave?.()) + + expect(mockPushEditToHub).toHaveBeenCalledWith(editedCommand) + expect(mockEditCommandToHub).not.toHaveBeenCalled() + }) + + it("HS-02: 正常系: Hub からの編集ではフォームの保存完了後に editCommandToHub を呼ぶ", async () => { + history.replaceState(null, "", "/?editCommand=cmd-1&syncHub=1") + const state: SaveState = { notified: false } + let resolveSave: (() => void) | undefined + const user = userEvent.setup() + render( (resolveSave = r)} />) + + await user.click(screen.getByText("submit-command")) + + expect(state.savedTitle).toBe("After") + expect(state.notifiedBeforeSave).toBe(true) + expect(mockEditCommandToHub).not.toHaveBeenCalled() + + await act(async () => resolveSave?.()) + + expect(mockEditCommandToHub).toHaveBeenCalledWith(editedCommand) + expect(mockPushEditToHub).not.toHaveBeenCalled() + }) + + it("HS-03: 正常系: 通常の保存では Hub へ送信しない", async () => { + const state: SaveState = { notified: false } + const user = userEvent.setup() + render( {}} />) + + await user.click(screen.getByText("edit-command")) + await user.click(screen.getByText("submit-command")) + + expect(state.savedTitle).toBeUndefined() + expect(mockEditCommandToHub).not.toHaveBeenCalled() + expect(mockPushEditToHub).not.toHaveBeenCalled() + }) +}) diff --git a/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistButton.test.tsx b/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistButton.test.tsx index 57678dc82..f1c94b0f4 100644 --- a/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistButton.test.tsx +++ b/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistButton.test.tsx @@ -67,7 +67,7 @@ describe("SearchUrlAssistButton", () => { const button = screen.getByRole("button") // Check for expected styling classes - expect(button).toHaveClass("bg-gradient-to-r") + expect(button).toHaveClass("bg-linear-to-r") expect(button).toHaveClass("from-purple-50") expect(button).toHaveClass("to-blue-50") expect(button).toHaveClass("border-purple-200") diff --git a/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistDialog.test.tsx b/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistDialog.test.tsx index e7c2df74b..a6e577393 100644 --- a/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistDialog.test.tsx +++ b/packages/extension/src/components/option/editor/__tests__/SearchUrlAssistDialog.test.tsx @@ -101,9 +101,11 @@ vi.mock("@/components/ui/collapsible", () => ({ })) // Mock react-hook-form +const mockSetValue = vi.fn() vi.mock("react-hook-form", () => ({ useForm: () => ({ control: {}, + setValue: mockSetValue, handleSubmit: (fn: any) => (e?: Event) => { e?.preventDefault?.() fn({ @@ -317,4 +319,43 @@ describe("SearchUrlAssistDialog", () => { expect(screen.getByText("Cancel")).toBeInTheDocument() expect(screen.getByText("Execute with Gemini")).toBeInTheDocument() }) + + it("SUD-06: pre-fills the search result URL with initialSearchResultUrl", () => { + render( + , + ) + + expect(mockSetValue).toHaveBeenCalledWith( + "searchResultUrl", + "https://www.google.com/search?q=%s", + ) + }) + + it("SUD-07: does not overwrite the URL when initialSearchResultUrl is empty", () => { + render( + , + ) + + expect(mockSetValue).not.toHaveBeenCalled() + }) + + it("SUD-08: does not pre-fill the URL while the dialog is closed", () => { + render( + , + ) + + expect(mockSetValue).not.toHaveBeenCalled() + }) }) diff --git a/packages/extension/src/components/option/field/IconField.tsx b/packages/extension/src/components/option/field/IconField.tsx index f17f6868c..b8347a95a 100644 --- a/packages/extension/src/components/option/field/IconField.tsx +++ b/packages/extension/src/components/option/field/IconField.tsx @@ -5,6 +5,7 @@ import { FormLabel, FormMessage, FormDescription } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { MenuImage } from "@/components/menu/MenuImage" +import { useThemeContext } from "@/hooks/useTheme" import { InfoTooltip } from "./InfoTooltip" import { useFavicon } from "@/hooks/option/useFavicon" import { useGlobalIconColor } from "@/hooks/option/useGlobalIconColor" @@ -74,7 +75,7 @@ export const IconField = ({ /> {(errUrl || errSvg) && ( -

+

{errUrl && {`${errUrl.message}`}} {errSvg && {`${errSvg.message}`}}

@@ -122,7 +123,7 @@ const GlobalIconColorToggle = ({ {isAutoPreserved && ( {t("excludeFromGlobalIconColor_automatic")} @@ -166,6 +167,8 @@ const IconUrlInput = ({ const { isLoading } = useFavicon() const popup = usePopupContext() const { iconColor, hasIconColor } = useGlobalIconColor() + // Recolor in the dark theme as OptionMenuImage does. + const isDark = useThemeContext() === "dark" const hasUrl = !isEmpty(fieldUrl.value) const value = hasUrl ? fieldUrl.value : fieldSvg.value @@ -175,9 +178,11 @@ const IconUrlInput = ({ // The preview is rendered outside the popup, so the icon color variable // MenuImage paints with has to be provided here.
- + ( -
+
) function isValidUrl(urlString: string): boolean { @@ -254,7 +259,7 @@ const UrlOrSvgInput = ({ className="pl-10" /> {error && ( -

{error}

+

{error}

)} ) diff --git a/packages/extension/src/components/option/field/InfoTooltip.tsx b/packages/extension/src/components/option/field/InfoTooltip.tsx index d18d67f75..c3574d303 100644 --- a/packages/extension/src/components/option/field/InfoTooltip.tsx +++ b/packages/extension/src/components/option/field/InfoTooltip.tsx @@ -22,7 +22,7 @@ export const InfoTooltip = ({ text, testId }: InfoTooltipProps) => { diff --git a/packages/extension/src/components/option/field/InputField.tsx b/packages/extension/src/components/option/field/InputField.tsx index d348fc72f..60262a5b5 100644 --- a/packages/extension/src/components/option/field/InputField.tsx +++ b/packages/extension/src/components/option/field/InputField.tsx @@ -7,7 +7,7 @@ import { FormMessage, FormDescription, } from "@/components/ui/form" -import { MenuImage } from "@/components/menu/MenuImage" +import { OptionMenuImage } from "@/components/option/OptionMenuImage" import { InfoTooltip } from "./InfoTooltip" type InputFieldType = { @@ -54,8 +54,8 @@ export const InputField = ({
{hasPreview && ( - diff --git a/packages/extension/src/components/option/field/OpenModeToggleField.tsx b/packages/extension/src/components/option/field/OpenModeToggleField.tsx index d8b8e8713..6d99070ea 100644 --- a/packages/extension/src/components/option/field/OpenModeToggleField.tsx +++ b/packages/extension/src/components/option/field/OpenModeToggleField.tsx @@ -161,28 +161,38 @@ const OpenModeItem = ({ disabled={disabled} aria-label={t(`openMode_${mode}`)} className={cn( - "relative flex-col gap-0.5 h-full w-full py-1.5 shadow-sm text-xs font-medium text-gray-600 hover:text-gray-700", - "border transition-all duration-200", - checked && "bg-gray-50", + "relative flex-col gap-0.5 h-full w-full py-1.5 shadow-xs text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300", + "border transition-all duration-200 dark:bg-muted", + // Keep the selected background while hovering so it is not + // mistaken for the hover state. + "data-[state=on]:bg-selected data-[state=on]:hover:bg-selected", + !disabled && "hover:bg-hover", disabled && // Override toggleVariants' `disabled:pointer-events-none` so hover // still reaches this element and the reason tooltip can show. - "opacity-40 cursor-not-allowed hover:text-gray-600 disabled:pointer-events-auto", + "opacity-40 cursor-not-allowed hover:text-gray-600 dark:hover:text-gray-400 hover:bg-transparent dark:hover:bg-muted disabled:pointer-events-auto", )} > {checked && ( )} - {mode} {t(`openMode_${mode}`)} diff --git a/packages/extension/src/components/option/field/PopupPlacementField.tsx b/packages/extension/src/components/option/field/PopupPlacementField.tsx index 953b15e8a..dd112729b 100644 --- a/packages/extension/src/components/option/field/PopupPlacementField.tsx +++ b/packages/extension/src/components/option/field/PopupPlacementField.tsx @@ -198,7 +198,8 @@ const SideItem = ({ side, current }: { side: SIDE; current: SIDE }) => { value={side} aria-label={t(`popupPlacement_${side}`)} className={cn( - "relative w-full h-9 shadow-sm text-xs font-mono text-gray-600", + "relative w-full h-9 shadow-xs text-xs font-mono text-gray-600 dark:text-gray-400 dark:bg-(--sc-input-bg,var(--color-gray-800))", + "hover:bg-hover data-[state=on]:bg-selected data-[state=on]:hover:bg-selected", )} > {checked && } @@ -229,14 +230,20 @@ const AlignItem = ({ aria-label={t(`popupPlacement_${align}`)} className={cn( "relative", - "flex-col gap-0 h-auto w-full py-1 shadow-sm text-xs font-mono text-gray-600", + "flex-col gap-0 h-auto w-full py-1 shadow-xs text-xs font-mono text-gray-600 dark:text-gray-400", + "hover:bg-hover data-[state=on]:bg-selected data-[state=on]:hover:bg-selected", )} > {checked && } - Selection Command {t(`popupPlacement_${align}`)} diff --git a/packages/extension/src/components/option/field/SelectField.tsx b/packages/extension/src/components/option/field/SelectField.tsx index cca68957c..21e309260 100644 --- a/packages/extension/src/components/option/field/SelectField.tsx +++ b/packages/extension/src/components/option/field/SelectField.tsx @@ -14,7 +14,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" -import { MenuImage } from "@/components/menu/MenuImage" +import { OptionMenuImage } from "@/components/option/OptionMenuImage" import { TEST_IDS } from "@/testIds" import { InfoTooltip } from "./InfoTooltip" @@ -51,7 +51,7 @@ const renderOptionContent = (opt: SelectOptionType) => { return ( - { value={opt.value} key={opt.value} disabled={opt.disabled} - className={`${opt.isGroup ? "pointer-events-none" : "hover:bg-gray-100"}`} + className={`${opt.isGroup ? "pointer-events-none" : "hover:bg-hover focus:bg-hover"}`} style={{ paddingLeft }} data-testid={TEST_IDS.selectItem(`${opt.value}`)} > @@ -112,7 +112,7 @@ export const SelectField = ({ > diff --git a/packages/extension/src/components/option/field/UserVariablesField.tsx b/packages/extension/src/components/option/field/UserVariablesField.tsx index d662f872e..a60fd9b01 100644 --- a/packages/extension/src/components/option/field/UserVariablesField.tsx +++ b/packages/extension/src/components/option/field/UserVariablesField.tsx @@ -220,14 +220,17 @@ export const UserVariablesField = ({ {suggestion.recommendedBy && ( - - + + {t("userVariable_recommended", [ suggestion.recommendedBy, ])} @@ -240,7 +243,7 @@ export const UserVariablesField = ({
{isFull && ( -

+

{t("userVariable_max_reached", [`${MAX_VARIABLES}`])}

)} @@ -264,7 +267,7 @@ export const UserVariablesField = ({ description={t("userVariable_remove_inUse", [`${removeCount}`])} portal > -

{`{{${removeName}}}`}

+

{`{{${removeName}}}`}

@@ -332,13 +335,15 @@ const VariableBadge = ({ return ( -
+
diff --git a/packages/extension/src/components/pageAction/Controller.tsx b/packages/extension/src/components/pageAction/Controller.tsx index fe399b6df..1797bcf77 100644 --- a/packages/extension/src/components/pageAction/Controller.tsx +++ b/packages/extension/src/components/pageAction/Controller.tsx @@ -95,7 +95,7 @@ export const Controller = forwardRef(
( {t("PageAction_Controller_not_recording")} @@ -150,7 +150,10 @@ export const Controller = forwardRef( onClick={() => finish()} data-testid={TEST_IDS.pageActionCompleteButton} > - + {t("PageAction_Controller_complete")} @@ -161,21 +164,30 @@ export const Controller = forwardRef( className={css.button} onClick={() => Ipc.send(ServiceWorkerCommand.stopPageAction)} > - + {t("PageAction_Controller_stop")} ) : ( )}
) diff --git a/packages/extension/src/components/pageAction/InputEditor.tsx b/packages/extension/src/components/pageAction/InputEditor.tsx index 2c24e75b5..59c49d4b1 100644 --- a/packages/extension/src/components/pageAction/InputEditor.tsx +++ b/packages/extension/src/components/pageAction/InputEditor.tsx @@ -53,7 +53,7 @@ export function InputEditor(props: InputEditorProps) {
diff --git a/packages/extension/src/components/pageAction/InputField.tsx b/packages/extension/src/components/pageAction/InputField.tsx index c1a808a60..45b66bf98 100644 --- a/packages/extension/src/components/pageAction/InputField.tsx +++ b/packages/extension/src/components/pageAction/InputField.tsx @@ -26,8 +26,8 @@ export const InputField = forwardRef( inputClassName={cn( "h-auto px-1 py-0 text-sm", !isEditing && - "border-white shadow-none truncate disabled:cursor-auto disabled:opacity-100", - isEditing && "px-2 py-1.5 border-gray-300", + "border-transparent bg-transparent dark:bg-transparent shadow-none truncate disabled:cursor-auto disabled:opacity-100", + isEditing && "px-2 py-1.5 border-gray-300 dark:border-gray-700", )} ref={ref} unit={props.unit} diff --git a/packages/extension/src/components/pageAction/InputPopup.tsx b/packages/extension/src/components/pageAction/InputPopup.tsx index 5e629605d..98697c218 100644 --- a/packages/extension/src/components/pageAction/InputPopup.tsx +++ b/packages/extension/src/components/pageAction/InputPopup.tsx @@ -310,8 +310,10 @@ export function InputMenu(props: MenuProps): JSX.Element { icon onSelectedMenuChange(MENU.INSERT)} @@ -330,19 +332,31 @@ export function InputMenu(props: MenuProps): JSX.Element { sideOffset={2} > - + {t("PageAction_InputMenu_selectedText")} - + {t("PageAction_InputMenu_url")} - + {t("PageAction_InputMenu_clipboard")} - + {t("PageAction_InputMenu_lang")} {userVariables.length > 0 && ( @@ -351,10 +365,13 @@ export function InputMenu(props: MenuProps): JSX.Element { {userVariables.map((variable) => ( onClickVariable(variable.name)} > - + {variable.name} ))} @@ -366,9 +383,10 @@ export function InputMenu(props: MenuProps): JSX.Element { onSelectedMenuChange(MENU.FILE_PASTE)} @@ -381,14 +399,20 @@ export function InputMenu(props: MenuProps): JSX.Element { sideOffset={2} > - + {t("PageAction_InputMenu_pageHtml")} - + {t("PageAction_InputMenu_selectionHtml")} @@ -409,7 +433,7 @@ function InputMenuItem(props: ItemProps): JSX.Element { return ( props.onClick(props.value as INSERT)} - className="m-0.5 px-2.5 py-2 text-sm font-normal font-sans text-gray-700 cursor-pointer" + className="m-0.5 px-2.5 py-2 text-sm font-normal font-sans text-gray-700 dark:text-gray-300 cursor-pointer hover:bg-hover focus:bg-hover" > {props.children} diff --git a/packages/extension/src/components/pageAction/PageActionItem.tsx b/packages/extension/src/components/pageAction/PageActionItem.tsx index 711653dcd..b2951291f 100644 --- a/packages/extension/src/components/pageAction/PageActionItem.tsx +++ b/packages/extension/src/components/pageAction/PageActionItem.tsx @@ -125,13 +125,13 @@ export function PageActionItem(props: Props): JSX.Element {
@@ -142,7 +142,7 @@ export function PageActionItem(props: Props): JSX.Element { {shouldRender && ( {capitalize(step.param.type)}

{isFailed && ( -

{failedMessage}

+

+ {failedMessage} +

)}
@@ -184,10 +186,10 @@ export function PageActionItem(props: Props): JSX.Element {
@@ -220,22 +222,32 @@ export function PageActionItem(props: Props): JSX.Element {
               ) : (
                 
               )}
 
               
             
- - + {/* Unmount while closing so the bridge does not capture the + pointer during the exit animation. */} + {isOpen && ( + + )}
)} @@ -255,13 +267,13 @@ const RemoveButton = ({ onClick, size }: RemoveButtonProps) => { diff --git a/packages/extension/src/components/pageAction/PageActionRecorder.module.css b/packages/extension/src/components/pageAction/PageActionRecorder.module.css index e41265148..2a7a6b8a3 100644 --- a/packages/extension/src/components/pageAction/PageActionRecorder.module.css +++ b/packages/extension/src/components/pageAction/PageActionRecorder.module.css @@ -1,5 +1,7 @@ +@reference "../global.css"; + .recordButton { - @apply flex items-center gap-1.5 rounded p-1 py-0.5 pointer-events-auto; + @apply flex items-center gap-1.5 rounded-sm p-1 py-0.5 pointer-events-auto; @apply relative transition hover:scale-105; } @@ -11,13 +13,13 @@ } .button { - @apply flex items-center gap-1 rounded px-1 py-0.5 pointer-events-auto; + @apply flex items-center gap-1 rounded-sm px-1 py-0.5 pointer-events-auto; @apply relative transition hover:scale-110; } .buttonLabelStatus, .buttonLabel { - @apply font-mono text-gray-700; + @apply font-mono text-gray-700 dark:text-gray-300; } .buttonLabelStatus { diff --git a/packages/extension/src/components/pageAction/PageActionRecorder.tsx b/packages/extension/src/components/pageAction/PageActionRecorder.tsx index b98444700..79cfb4998 100644 --- a/packages/extension/src/components/pageAction/PageActionRecorder.tsx +++ b/packages/extension/src/components/pageAction/PageActionRecorder.tsx @@ -148,7 +148,7 @@ export function PageActionRecorder(): JSX.Element { return ( -
+
{capitalize(removeStep.param.type)}

{hasLabel && ( -

+

{removeStep.param.label}

)} diff --git a/packages/extension/src/components/pageAction/PageActionRunner.tsx b/packages/extension/src/components/pageAction/PageActionRunner.tsx index 8a03039b4..0266524a0 100644 --- a/packages/extension/src/components/pageAction/PageActionRunner.tsx +++ b/packages/extension/src/components/pageAction/PageActionRunner.tsx @@ -67,12 +67,12 @@ export function PageActionRunner(): JSX.Element { return (
-
+
{hasError && ( )} -
    +
      {results?.map((result) => ( ))} @@ -112,7 +112,11 @@ const Step = ({ result }: { result: PageActiontResult }) => { {result.label} {hasMessage && ( diff --git a/packages/extension/src/components/pageAction/StepList.tsx b/packages/extension/src/components/pageAction/StepList.tsx index 36462d4f7..24fc2e8e1 100644 --- a/packages/extension/src/components/pageAction/StepList.tsx +++ b/packages/extension/src/components/pageAction/StepList.tsx @@ -48,7 +48,7 @@ export function StepList(props: Props): JSX.Element { className={cn( "relative", i > 0 && - "after:content-[''] after:z-[-1] after:absolute after:left-[-25%] after:h-[2px] after:w-[50%] after:bg-gray-500/80 after:rounded", + "after:content-[''] after:z-[-1] after:absolute after:left-[-25%] after:h-[2px] after:w-[50%] after:bg-gray-500/80 dark:after:bg-gray-400/80 after:rounded-sm", )} /> ))} @@ -58,14 +58,14 @@ export function StepList(props: Props): JSX.Element { className={cn( "h-[24px] flex-1 flex items-center justify-center relative", (steps.length > 0 || i > 0) && - "after:content-[''] after:z-[-1] after:absolute after:left-[-25%] after:h-[2px] after:w-[50%] after:bg-gray-400/70 after:rounded", + "after:content-[''] after:z-[-1] after:absolute after:left-[-25%] after:h-[2px] after:w-[50%] after:bg-gray-400/70 dark:after:bg-gray-600/70 after:rounded-sm", )} > -
      +
      diff --git a/packages/extension/src/components/result/ResultPopup.module.css b/packages/extension/src/components/result/ResultPopup.module.css index c41d3db14..2bd5f7910 100644 --- a/packages/extension/src/components/result/ResultPopup.module.css +++ b/packages/extension/src/components/result/ResultPopup.module.css @@ -1,8 +1,14 @@ +@reference "../global.css"; + .resultPopup { padding: 10px; font-size: 14px; - color: var(--gray-600); + color: var(--color-gray-600); font-family: var(--font-family); + + @variant dark { + color: var(--color-gray-300); + } transition-duration: 150ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } @@ -22,24 +28,42 @@ height: calc(var(--icon-size) + var(--padding) * 2); padding: var(--padding); font-size: 10px; - color: var(--gray-400); + color: var(--color-gray-400); svg { width: var(--icon-size); height: var(--icon-size); - color: var(--gray-400); + color: var(--color-gray-400); + + @variant dark { + color: var(--color-gray-500); + } } &:hover { - color: var(--gray-600); - background: var(--gray-100); + color: var(--color-gray-600); + background: var(--color-gray-100); + + @variant dark { + color: var(--color-gray-300); + background: var(--color-gray-800); + } svg { - color: var(--gray-600); + color: var(--color-gray-600); + + @variant dark { + color: var(--color-gray-300); + } } svg.buttonSuccess { - color: var(--blue-500); + color: var(--color-blue-500); + + /* Restate it so the dark svg color above does not win on specificity. */ + @variant dark { + color: var(--color-blue-400); + } } } } @@ -60,7 +84,11 @@ } .resultTableHeader { - color: var(--gray-800); + color: var(--color-gray-800); + + @variant dark { + color: var(--color-gray-200); + } th { padding: 0 0 6px; @@ -80,14 +108,22 @@ td.resultTableProperty { padding-right: 12px; - color: var(--gray-500); + color: var(--color-gray-500); text-wrap: nowrap; + + @variant dark { + color: var(--color-gray-400); + } } .resultTableValue { font-family: var(--font-monospace); - color: var(--gray-600); + color: var(--color-gray-600); font-weight: 600; + + @variant dark { + color: var(--color-gray-300); + } text-wrap: nowrap; } diff --git a/packages/extension/src/components/ui/button.tsx b/packages/extension/src/components/ui/button.tsx index 3aec6644d..78c5f042a 100644 --- a/packages/extension/src/components/ui/button.tsx +++ b/packages/extension/src/components/ui/button.tsx @@ -5,16 +5,17 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:mr-1 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-1 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:mr-1 [&_svg]:shrink-0", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/70", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/70", + "bg-destructive text-destructive-foreground hover:bg-destructive/70 dark:bg-red-700 dark:hover:bg-red-700/70", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", - secondary: "bg-secondary text-secondary-foreground hover:bg-gray-200", + "border border-input bg-background dark:bg-(--sc-input-bg,hsl(var(--background))) hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-gray-200 dark:hover:bg-gray-700", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, @@ -34,7 +35,8 @@ const buttonVariants = cva( ) export interface ButtonProps - extends React.ButtonHTMLAttributes, + extends + React.ButtonHTMLAttributes, VariantProps { asChild?: boolean } diff --git a/packages/extension/src/components/ui/dialog.module.css b/packages/extension/src/components/ui/dialog.module.css new file mode 100644 index 000000000..4fb33e56a --- /dev/null +++ b/packages/extension/src/components/ui/dialog.module.css @@ -0,0 +1,20 @@ +@reference "../global.css"; + +/* + * In dark mode the default dialog surface is as dark as the page behind it, + * so lift the surface and the controls inside it to keep the dialog visible. + */ +.content { + @variant dark { + --background: 222 24% 11%; + --border: 218 18% 22%; + --input: var(--sc-overlay-input); + --secondary: 219 18% 19%; + --accent: var(--sc-overlay-hover); + --sc-hover: var(--sc-overlay-hover); + --sc-selected: var(--sc-overlay-selected); + --muted: 220 20% 15%; + /* Background of Input, Textarea and SelectTrigger. */ + --sc-input-bg: hsl(220 20% 15%); + } +} diff --git a/packages/extension/src/components/ui/dialog.tsx b/packages/extension/src/components/ui/dialog.tsx index da9ce871a..e2dd82df2 100644 --- a/packages/extension/src/components/ui/dialog.tsx +++ b/packages/extension/src/components/ui/dialog.tsx @@ -5,6 +5,8 @@ import * as DialogPrimitive from "@radix-ui/react-dialog" import { cn } from "@/lib/utils" +import css from "./dialog.module.css" + const Dialog = DialogPrimitive.Root const DialogTrigger = DialogPrimitive.Trigger @@ -31,7 +33,7 @@ const DialogOverlay = React.forwardRef< {body} diff --git a/packages/extension/src/components/ui/input.tsx b/packages/extension/src/components/ui/input.tsx index b7989daac..5d1b3d269 100644 --- a/packages/extension/src/components/ui/input.tsx +++ b/packages/extension/src/components/ui/input.tsx @@ -44,7 +44,7 @@ const Input = React.forwardRef< diff --git a/packages/extension/src/components/ui/select.tsx b/packages/extension/src/components/ui/select.tsx index 7214223a7..a774cc3f2 100644 --- a/packages/extension/src/components/ui/select.tsx +++ b/packages/extension/src/components/ui/select.tsx @@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background dark:bg-(--sc-input-bg,hsl(var(--background))) px-3 py-2 text-sm data-placeholder:text-muted-foreground focus:outline-hidden focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", className, )} {...props} @@ -75,7 +75,7 @@ const SelectContent = React.forwardRef< {children} @@ -118,7 +118,7 @@ const SelectItem = React.forwardRef< (({ className, ...props }, ref) => ( diff --git a/packages/extension/src/components/ui/textarea.tsx b/packages/extension/src/components/ui/textarea.tsx index 6fcf87277..a60a5fe87 100644 --- a/packages/extension/src/components/ui/textarea.tsx +++ b/packages/extension/src/components/ui/textarea.tsx @@ -9,7 +9,7 @@ const Textarea = React.forwardRef< return ( + + + diff --git a/packages/extension/src/offscreen_clipboard.ts b/packages/extension/src/offscreen_clipboard.ts new file mode 100644 index 000000000..1a6a7fc9a --- /dev/null +++ b/packages/extension/src/offscreen_clipboard.ts @@ -0,0 +1,36 @@ +import type { ClipboardResult } from "./services/ipc" + +chrome.runtime.onMessage.addListener((message, sender, respond) => { + if ( + sender.id !== chrome.runtime.id || + sender.tab || + message?.target !== "offscreen-clipboard" || + message.command !== "read" + ) { + return false + } + + const textarea = document.getElementById("clipboard") as HTMLTextAreaElement + try { + textarea.value = "" + textarea.focus() + textarea.select() + // Without focus, paste may report success while inserting nothing. + if (document.activeElement !== textarea) { + throw new Error("Failed to focus the clipboard textarea") + } + // Async Clipboard requires document focus, which offscreen documents lack. + if (!document.execCommand("paste")) { + throw new Error("Failed to paste clipboard text") + } + respond({ data: textarea.value } satisfies ClipboardResult) + } catch (error) { + respond({ + data: "", + err: error instanceof Error ? error.message : String(error), + } satisfies ClipboardResult) + } finally { + textarea.value = "" + } + return false +}) diff --git a/packages/extension/src/onboarding_page.html b/packages/extension/src/onboarding_page.html index 44054244f..ec9b726d7 100644 --- a/packages/extension/src/onboarding_page.html +++ b/packages/extension/src/onboarding_page.html @@ -5,6 +5,20 @@ Onboarding + diff --git a/packages/extension/src/options_page.html b/packages/extension/src/options_page.html index 16ef98809..40b582e7c 100644 --- a/packages/extension/src/options_page.html +++ b/packages/extension/src/options_page.html @@ -5,10 +5,24 @@ Option Page + - +
      diff --git a/packages/extension/src/providers/SelectContextProvider.tsx b/packages/extension/src/providers/SelectContextProvider.tsx index 4d32291ce..2e73aaaf8 100644 --- a/packages/extension/src/providers/SelectContextProvider.tsx +++ b/packages/extension/src/providers/SelectContextProvider.tsx @@ -1,5 +1,5 @@ -import { useState, ReactNode, useEffect, useCallback } from "react" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { useState, ReactNode, useEffect, useCallback, useRef } from "react" +import { Ipc, TabCommand } from "@/services/ipc" import { getSelectionText } from "@/services/dom" import { ContextType, selectContext } from "@/hooks/useSelectContext" @@ -11,17 +11,17 @@ export const SelectContextProvider = ({ const [selectionText, _setSelectionText] = useState("") const [target, setTarget] = useState(null) const [detectSelectionEnabled, setDetectSelectionEnabled] = useState(true) + const selectionRef = useRef("") - const setSelectionText = useCallback(async (text: string) => { + const setSelectionText = useCallback((text: string) => { _setSelectionText(text) - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, text) + selectionRef.current = text }, []) useEffect(() => { - const onSelectionchange = async () => { + const onSelectionchange = () => { if (!detectSelectionEnabled) return - const text = getSelectionText() - await setSelectionText(text) + setSelectionText(getSelectionText()) } document.addEventListener("selectionchange", onSelectionchange) @@ -30,6 +30,17 @@ export const SelectContextProvider = ({ } }, [detectSelectionEnabled, setSelectionText]) + useEffect(() => { + Ipc.addListener(TabCommand.getSelection, (_param, _sender, response) => { + response(selectionRef.current) + return true + }) + + return () => { + Ipc.removeListener(TabCommand.getSelection) + } + }, []) + const value: ContextType = { selectionText, setSelectionText, diff --git a/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx b/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx new file mode 100644 index 000000000..0ef68a524 --- /dev/null +++ b/packages/extension/src/providers/__tests__/SelectContextProvider.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, act } from "@testing-library/react" +import { SelectContextProvider } from "../SelectContextProvider" +import { Ipc, TabCommand, type IpcCallback } from "@/services/ipc" +import { getSelectionText } from "@/services/dom" + +vi.mock("@/services/ipc", () => ({ + Ipc: { + addListener: vi.fn(), + removeListener: vi.fn(), + }, + TabCommand: { + getSelection: "getSelection", + }, +})) + +vi.mock("@/services/dom", () => ({ + getSelectionText: vi.fn(), +})) + +const mockAddListener = vi.mocked(Ipc.addListener) +const mockGetSelectionText = vi.mocked(getSelectionText) + +// Calls the getSelection listener as the service worker does. +const requestSelection = () => { + const call = mockAddListener.mock.calls.find( + ([command]) => command === TabCommand.getSelection, + ) + const listener = call?.[1] as IpcCallback + const response = vi.fn() + listener(undefined, {} as chrome.runtime.MessageSender, response) + return response.mock.calls[0][0] +} + +const selectText = (text: string) => { + mockGetSelectionText.mockReturnValue(text) + act(() => { + document.dispatchEvent(new Event("selectionchange")) + }) +} + +describe("SelectContextProvider", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("SC-01: 正常系: 現在の選択テキストを返す", () => { + render({null}) + + selectText("selected") + + expect(requestSelection()).toBe("selected") + }) + + it("SC-02: 正常系: アンマウント時にリスナーを解除する", () => { + const { unmount } = render( + {null}, + ) + + unmount() + + expect(Ipc.removeListener).toHaveBeenCalledWith(TabCommand.getSelection) + }) +}) diff --git a/packages/extension/src/service_worker.ts b/packages/extension/src/service_worker.ts index 3c7983038..10f290712 100644 --- a/packages/extension/src/service_worker.ts +++ b/packages/extension/src/service_worker.ts @@ -21,14 +21,19 @@ import { CACHE_SECTIONS } from "@/services/settings/settingsCache" import * as PageActionServiceWorker from "@/services/pageAction/serviceWorker" import { ServiceWorkerData } from "@/services/serviceWorkerData" import { ContextMenu } from "@/services/contextMenus" -import { closeWindow, windowExists, getCurrentTab } from "@/services/chrome" +import { + closeWindow, + windowExists, + getCurrentTab, + openOptionPage, +} from "@/services/chrome" import { WindowStackManager } from "@/services/windowStackManager" import { PopupAutoClose } from "@/services/popupAutoClose" import { findMatchingPageRule, isEmpty } from "@/lib/utils" import { execute } from "@/action/serviceWorker" import * as ActionHelper from "@/action/helper" import type { WindowType } from "@/types" -import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" +import { Storage } from "@/services/storage" import { ANALYTICS_EVENTS, sendEvent, @@ -155,9 +160,9 @@ const commandFuncs = { [ServiceWorkerCommand.execApi]: ActionHelper.execApi, [ServiceWorkerCommand.openOption]: (): boolean => { - chrome.tabs.create({ - url: OPTION_PAGE_PATH, - }) + openOptionPage().catch((e) => + console.error("Failed to open option page", e), + ) return false }, @@ -174,16 +179,16 @@ const commandFuncs = { const pageRules = settings.pageRules ?? [] const matchingRule = findMatchingPageRule(pageRules, param.url) if (matchingRule == null) { - chrome.tabs.create({ - url: `${OPTION_PAGE_PATH}?addPageRule=${encodeURIComponent(param.url)}#pageRules`, - }) + await openOptionPage( + `${OPTION_PAGE_PATH}?addPageRule=${encodeURIComponent(param.url)}#pageRules`, + ) } else { - chrome.tabs.create({ - url: `${OPTION_PAGE_PATH}?editPageRule=${encodeURIComponent(matchingRule.urlPattern)}#pageRules`, - }) + await openOptionPage( + `${OPTION_PAGE_PATH}?editPageRule=${encodeURIComponent(matchingRule.urlPattern)}#pageRules`, + ) } } - add() + add().catch((e) => console.error("Failed to open option page", e)) return false }, @@ -390,28 +395,12 @@ const updateActiveTabId = async (activeTabId?: number) => { } } -// Clears the selection text unless the user opted to keep the menu open -// across tab/window changes. -const clearSelectionTextUnlessKeepOpen = async () => { - const settings = await enhancedSettings.getSection( - CACHE_SECTIONS.USER_SETTINGS, - ) - if (!settings.startupMethod?.keepMenuOpenOnFocusChange) { - await Storage.set(SESSION_STORAGE_KEY.SELECTION_TEXT, "") - } -} - chrome.action.onClicked.addListener(() => { - chrome.tabs.create({ - url: OPTION_PAGE_PATH, - }) + openOptionPage().catch((e) => console.error("Failed to open option page", e)) }) chrome.windows.onFocusChanged.addListener(async (windowId: number) => { - if (windowId === chrome.windows.WINDOW_ID_NONE) { - await clearSelectionTextUnlessKeepOpen() - return - } + if (windowId === chrome.windows.WINDOW_ID_NONE) return // Update active tab ID await updateActiveTabId() @@ -420,11 +409,7 @@ chrome.windows.onFocusChanged.addListener(async (windowId: number) => { const windowsToClose = await WindowStackManager.getWindowsToClose(windowId) // Schedule popup windows to close with configured delay. - // Run this before the settings fetch below so popup auto-close isn't - // delayed behind it and doesn't race with overlapping focus-change events. await PopupAutoClose.scheduleClose(windowsToClose) - - await clearSelectionTextUnlessKeepOpen() }) chrome.windows.onRemoved.addListener(async (windowId: number) => { @@ -677,7 +662,7 @@ Settings.addChangedListener(() => ContextMenu.init()) // ) // Add command processing -chrome.commands.onCommand.addListener(async (commandName) => { +async function handleShortcutCommand(commandName: string) { try { // Get settings const settings = await enhancedSettings.get() @@ -696,12 +681,34 @@ chrome.commands.onCommand.addListener(async (commandName) => { return } - const selectionText = await Storage.get( - SESSION_STORAGE_KEY.SELECTION_TEXT, - ) + const tab = await getCurrentTab() + const enableSendTab = + tab?.id && + !tab.url?.startsWith("chrome") && + !tab.url?.includes("chromewebstore.google.com") - // If no text is selected, handle according to noSelectionBehavior - let allowClipboardFallback = false + // A global shortcut can be pressed while another app has focus. Then the + // selection left in the page is ignored, as the user may have copied + // other text in that app. After returning to Chrome, the selection shown + // in the page is used. getLastFocused fails when no window is open. + const lastFocusedWindow = await chrome.windows + .getLastFocused() + .catch(() => undefined) + + // Ask the active tab for its latest selection. Tabs without the content + // script (e.g. chrome://) are treated as having no selection. + let selectionText = "" + if (enableSendTab && lastFocusedWindow?.focused) { + const selection = await Ipc.sendTab( + tab.id ?? 0, + TabCommand.getSelection, + ) + if (typeof selection === "string") selectionText = selection + } + + // This is the only place to decide whether to use the clipboard text as + // the selected text. Actions trust this decision and don't re-check. + let selectionFromClipboard = false if (isEmpty(selectionText)) { if ( shortcut.noSelectionBehavior === @@ -712,17 +719,10 @@ chrome.commands.onCommand.addListener(async (commandName) => { shortcut.noSelectionBehavior === SHORTCUT_NO_SELECTION_BEHAVIOR.USE_CLIPBOARD ) { - allowClipboardFallback = true + selectionFromClipboard = true } } - // Get active tab - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) - const enableSendTab = - tab?.id && - !tab.url?.startsWith("chrome") && - !tab.url?.includes("chromewebstore.google.com") - let ret: unknown if (enableSendTab) { // Execute command in tab @@ -731,7 +731,8 @@ chrome.commands.onCommand.addListener(async (commandName) => { TabCommand.executeAction, { command, - allowClipboardFallback, + selectionText, + selectionFromClipboard, }, ) } @@ -748,7 +749,7 @@ chrome.commands.onCommand.addListener(async (commandName) => { position, selectionText, target: null, - allowClipboardFallback, + selectionFromClipboard, pageUrl: tab?.url ?? "", }) } @@ -762,7 +763,16 @@ chrome.commands.onCommand.addListener(async (commandName) => { } catch (error) { console.error("Failed to execute shortcut command:", error) } -}) +} + +chrome.commands.onCommand.addListener(handleShortcutCommand) + +// Expose only in the E2E worker for Playwright evaluation, without a message endpoint. +if (import.meta.env.MODE === "e2e") { + Object.assign(globalThis, { + __e2eHandleShortcutCommand: handleShortcutCommand, + }) +} // Side panel is an optional capability (e.g. not available on Opera), so // each event is feature-detected individually. diff --git a/packages/extension/src/services/__tests__/chrome.openOptionPage.test.ts b/packages/extension/src/services/__tests__/chrome.openOptionPage.test.ts new file mode 100644 index 000000000..d386f02fd --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openOptionPage.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { openOptionPage } from "../chrome" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) + +const BASE_URL = "chrome-extension://test-extension-id/" +const OPTION_URL = `${BASE_URL}src/options_page.html` + +/** + * Mock chrome.tabs.query to return all tabs, or the active tab of the last + * focused window when queried with `active: true`. + */ +const mockTabs = (tabs: Partial[], currentWindowId = 1) => { + vi.mocked(chrome.tabs.query).mockImplementation((( + info: chrome.tabs.QueryInfo, + ) => + Promise.resolve( + info.active + ? [{ id: 100, windowId: currentWindowId, url: "https://example.com/" }] + : tabs, + )) as typeof chrome.tabs.query) +} + +describe("openOptionPage", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(chrome.runtime.getURL).mockImplementation( + (path: string) => `${BASE_URL}${path}`, + ) + vi.mocked(chrome.tabs.create).mockImplementation(() => + Promise.resolve({ id: 20 } as chrome.tabs.Tab), + ) + vi.mocked(chrome.tabs.update).mockImplementation( + async (tabId) => ({ id: tabId }) as chrome.tabs.Tab, + ) + ;(chrome.windows as any).update = vi.fn().mockResolvedValue({}) + }) + + it("COP-01: creates a new tab when no options page is open", async () => { + mockTabs([{ id: 1, windowId: 1, url: "https://example.com/" }]) + + const result = await openOptionPage() + + expect(chrome.tabs.create).toHaveBeenCalledWith({ + url: "src/options_page.html", + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + expect(result).toEqual({ tab: { id: 20 }, created: true }) + }) + + it("COP-02: activates the existing tab without navigating", async () => { + mockTabs([{ id: 5, windowId: 2, url: `${OPTION_URL}#commands` }]) + + const result = await openOptionPage() + + expect(chrome.tabs.create).not.toHaveBeenCalled() + expect(chrome.tabs.update).toHaveBeenCalledWith(5, { active: true }) + expect(chrome.windows.update).toHaveBeenCalledWith(2, { focused: true }) + expect(result).toEqual({ tab: { id: 5 }, created: false }) + }) + + it("COP-03: navigates the existing tab when a query is given", async () => { + mockTabs([{ id: 5, windowId: 2, url: OPTION_URL }]) + + await openOptionPage("src/options_page.html?addPageRule=x#pageRules") + + expect(chrome.tabs.create).not.toHaveBeenCalled() + expect(chrome.tabs.update).toHaveBeenCalledWith(5, { + active: true, + url: `${OPTION_URL}?addPageRule=x#pageRules`, + }) + expect(chrome.windows.update).toHaveBeenCalledWith(2, { focused: true }) + }) + + it("COP-04: prefers the tab in the last focused window", async () => { + mockTabs( + [ + { id: 5, windowId: 2, url: OPTION_URL }, + { id: 6, windowId: 3, url: OPTION_URL }, + ], + 3, + ) + + await openOptionPage() + + expect(chrome.tabs.update).toHaveBeenCalledWith(6, { active: true }) + expect(chrome.windows.update).toHaveBeenCalledWith(3, { focused: true }) + }) + + it("COP-05: reuses a tab that is still loading the options page", async () => { + mockTabs([{ id: 5, windowId: 1, url: "", pendingUrl: OPTION_URL }]) + + await openOptionPage() + + expect(chrome.tabs.create).not.toHaveBeenCalled() + expect(chrome.tabs.update).toHaveBeenCalledWith(5, { active: true }) + }) + + it("COP-06: does not reuse the excluded tab", async () => { + mockTabs([{ id: 5, windowId: 1, url: OPTION_URL }]) + + const result = await openOptionPage(undefined, 5) + + expect(chrome.tabs.update).not.toHaveBeenCalled() + expect(result).toEqual({ tab: { id: 20 }, created: true }) + }) +}) diff --git a/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts b/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts new file mode 100644 index 000000000..b3f3458b0 --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openPopupWindow.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { openPopupWindow } from "../chrome" +import { readOffscreenClipboard } from "../offscreenClipboard" +import { Ipc } from "../ipc" +import { POPUP_TYPE, WINDOW_STATE } from "@/const" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) +vi.mock("../screen", () => ({ + getScreenSize: vi + .fn() + .mockResolvedValue({ left: 0, top: 0, width: 1280, height: 800 }), +})) +vi.mock("../windowStackManager", () => ({ + WindowStackManager: { addWindows: vi.fn().mockResolvedValue(undefined) }, +})) + +const params = { + commandId: "test", + url: { + searchUrl: "https://example.com/?q=%s", + selectionText: "", + selectionFromClipboard: true, + }, + type: POPUP_TYPE.POPUP, + top: 10, + left: 20, + width: 600, + height: 700, +} + +describe("openPopupWindow", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "clipboard text", + }) + vi.stubGlobal("chrome", { + ...chrome, + windows: { + ...chrome.windows, + getCurrent: vi.fn().mockResolvedValue({ id: 1, incognito: true }), + create: vi.fn().mockResolvedValue({ id: 2, tabs: [{ id: 20 }] }), + update: vi.fn().mockResolvedValue(undefined), + }, + declarativeNetRequest: { + ...chrome.declarativeNetRequest, + RuleActionType: { MODIFY_HEADERS: "modifyHeaders" }, + HeaderOperation: { REMOVE: "remove", SET: "set" }, + ResourceType: { MAIN_FRAME: "main_frame" }, + getSessionRules: vi.fn().mockResolvedValue([]), + updateSessionRules: vi.fn().mockResolvedValue(undefined), + }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it("COP-01: 正常系: 完成したURLで一度だけ作成し位置・サイズ・シークレット設定を保持する", async () => { + expect(await openPopupWindow(params)).toEqual({ + tabId: 20, + clipboardText: "clipboard text", + }) + expect(chrome.windows.create).toHaveBeenCalledExactlyOnceWith({ + url: "https://example.com/?q=clipboard+text", + type: "popup", + top: 10, + left: 20, + width: 600, + height: 700, + state: undefined, + incognito: true, + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + }) + + it("COP-02: 正常系: fullscreenでは座標とサイズを渡さず作成後にも状態を適用する", async () => { + await openPopupWindow({ ...params, windowState: WINDOW_STATE.FULLSCREEN }) + expect(chrome.windows.create).toHaveBeenCalledWith( + expect.objectContaining({ + state: "fullscreen", + top: undefined, + left: undefined, + width: undefined, + height: undefined, + }), + ) + expect(chrome.windows.update).toHaveBeenCalledWith(2, { + state: "fullscreen", + }) + }) + + it("COP-03: 異常系: 読み取り失敗時にも表示先を作成してエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Read failed", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + await openPopupWindow(params) + expect(chrome.windows.create).toHaveBeenCalledOnce() + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) +}) diff --git a/packages/extension/src/services/__tests__/chrome.openTab.test.ts b/packages/extension/src/services/__tests__/chrome.openTab.test.ts new file mode 100644 index 000000000..de10216f4 --- /dev/null +++ b/packages/extension/src/services/__tests__/chrome.openTab.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { openTab } from "../chrome" +import { Ipc } from "../ipc" +import { readOffscreenClipboard } from "../offscreenClipboard" + +vi.mock("../offscreenClipboard", () => ({ readOffscreenClipboard: vi.fn() })) + +const sourceTab = { id: 10, windowId: 1, index: 2 } as chrome.tabs.Tab +const resultTab = { id: 20, windowId: 1 } as chrome.tabs.Tab +const url = { + searchUrl: "https://example.com/?q=%s", + selectionText: "", + selectionFromClipboard: true, +} + +describe("openTab", () => { + afterEach(() => vi.unstubAllGlobals()) + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(chrome.tabs.query).mockImplementation(() => + Promise.resolve([sourceTab]), + ) + vi.mocked(chrome.tabs.create).mockImplementation(() => + Promise.resolve(resultTab), + ) + vi.stubGlobal("chrome", { + ...chrome, + windows: { + ...chrome.windows, + create: vi.fn().mockResolvedValue({ id: 3, tabs: [{ id: 30 }] }), + remove: vi.fn().mockResolvedValue(undefined), + }, + }) + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "clipboard text", + }) + }) + + it("COT-01: 正常系: Offscreenで読み非アクティブタブを直接作成する", async () => { + const result = await openTab({ url, active: false }) + + expect(readOffscreenClipboard).toHaveBeenCalledOnce() + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith({ + url: "https://example.com/?q=clipboard+text", + active: false, + windowId: 1, + index: 3, + }) + expect(chrome.tabs.update).not.toHaveBeenCalled() + expect(result).toEqual({ tabId: 20, clipboardText: "clipboard text" }) + }) + + it("COT-01-a: 正常系: アクティブタブも完成したURLで一度だけ作成する", async () => { + await openTab({ url, active: true }) + expect(readOffscreenClipboard).toHaveBeenCalledOnce() + expect(chrome.tabs.create).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + url: "https://example.com/?q=clipboard+text", + active: true, + }), + ) + expect(chrome.tabs.update).not.toHaveBeenCalled() + }) + + it("COT-02: 正常系: クリップボード不要なら一時ウィンドウを開かない", async () => { + await openTab({ + url: { ...url, selectionText: "selected", selectionFromClipboard: false }, + active: false, + }) + + expect(readOffscreenClipboard).not.toHaveBeenCalled() + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(chrome.tabs.create).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://example.com/?q=selected", + active: false, + }), + ) + }) + + it("COT-03: 異常系: 読み取り失敗時は非アクティブで開き元のタブへエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Permission denied", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + const result = await openTab({ url, active: false }) + expect(chrome.tabs.create).toHaveBeenCalledWith( + expect.objectContaining({ active: false }), + ) + expect(connection).toHaveBeenCalledWith(10) + expect(sendTab).toHaveBeenCalledWith(10, "showToast", expect.any(Object)) + expect(chrome.windows.create).not.toHaveBeenCalled() + expect(result).toEqual({ tabId: 20, clipboardText: "" }) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) + + it("COT-04: 異常系: アクティブタブで開いた場合は新しいタブへエラーを通知する", async () => { + vi.mocked(readOffscreenClipboard).mockResolvedValue({ + data: "", + err: "Permission denied", + }) + const connection = vi.spyOn(Ipc, "ensureConnection").mockResolvedValue() + const sendTab = vi.spyOn(Ipc, "sendTab").mockResolvedValue(undefined) + try { + await openTab({ url, active: true }) + expect(connection).toHaveBeenCalledWith(20) + expect(sendTab).toHaveBeenCalledWith(20, "showToast", expect.any(Object)) + } finally { + connection.mockRestore() + sendTab.mockRestore() + } + }) +}) diff --git a/packages/extension/src/services/__tests__/offscreenClipboard.test.ts b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts new file mode 100644 index 000000000..fbaf9d80d --- /dev/null +++ b/packages/extension/src/services/__tests__/offscreenClipboard.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { readOffscreenClipboard } from "../offscreenClipboard" + +const createDocument = vi.fn() +const getContexts = vi.fn() +const sendMessage = vi.fn() + +describe("readOffscreenClipboard", () => { + beforeEach(() => { + vi.resetAllMocks() + createDocument.mockResolvedValue(undefined) + let documentExists = false + getContexts.mockImplementation(async () => (documentExists ? [{}] : [])) + createDocument.mockImplementation(async () => { + documentExists = true + }) + sendMessage.mockResolvedValue({ data: "日本語 clipboard" }) + vi.stubGlobal("chrome", { + ...chrome, + runtime: { + ...chrome.runtime, + getURL: (path: string) => `chrome-extension://test/${path}`, + getContexts, + ContextType: { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" }, + sendMessage, + }, + offscreen: { + createDocument, + Reason: { CLIPBOARD: "CLIPBOARD" }, + }, + }) + }) + afterEach(() => vi.unstubAllGlobals()) + + it("OC-01: 正常系: 非表示ドキュメントを再利用し毎回最新の文字列を返す", async () => { + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledWith( + expect.objectContaining({ + url: "src/offscreen_clipboard.html", + reasons: ["CLIPBOARD"], + }), + ) + sendMessage.mockResolvedValue({ data: "updated" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ data: "updated" }) + expect(createDocument).toHaveBeenCalledOnce() + expect(sendMessage).toHaveBeenCalledTimes(2) + }) + + it("OC-02: 正常系: 再起動後に残ったドキュメントを再利用する", async () => { + getContexts.mockResolvedValue([{}]) + await readOffscreenClipboard() + expect(createDocument).not.toHaveBeenCalled() + }) + + it("OC-03: 異常系: 読み取り失敗後も次の要求で読み取れる", async () => { + sendMessage.mockRejectedValue(new Error("Read failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Read failed", + }) + sendMessage.mockResolvedValue({ data: "recovered" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "recovered", + }) + expect(createDocument).toHaveBeenCalledOnce() + }) + + it("OC-04: 正常系: 同時要求でも一度だけ作成して異なる値を順に返す", async () => { + sendMessage + .mockResolvedValueOnce({ data: "first" }) + .mockResolvedValueOnce({ data: "second" }) + const results = await Promise.all([ + readOffscreenClipboard(), + readOffscreenClipboard(), + ]) + expect(results).toEqual([{ data: "first" }, { data: "second" }]) + expect(createDocument).toHaveBeenCalledOnce() + }) + + it("OC-05: 正常系: ドキュメントが破棄されたら再作成する", async () => { + await readOffscreenClipboard() + getContexts.mockResolvedValueOnce([]) + await readOffscreenClipboard() + expect(createDocument).toHaveBeenCalledTimes(2) + }) + + it("OC-06: 異常系: 作成に一時失敗しても再試行して読み取る", async () => { + createDocument.mockRejectedValueOnce(new Error("Creation failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledTimes(2) + }) + + it("OC-07: 異常系: 一時的な読み取り失敗は再試行して読み取る", async () => { + sendMessage + .mockResolvedValueOnce({ data: "", err: "Busy" }) + .mockResolvedValueOnce({ data: "retried" }) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "retried", + }) + expect(sendMessage).toHaveBeenCalledTimes(2) + }) + + it("OC-08: 異常系: 再試行上限に達したら最後のエラーを返す", async () => { + sendMessage.mockRejectedValue(new Error("Read failed")) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Read failed", + }) + expect(sendMessage).toHaveBeenCalledTimes(3) + }) + + it("OC-09: 異常系: 不正な応答はエラーとして扱う", async () => { + sendMessage.mockResolvedValue(undefined) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "", + err: "Invalid response from offscreen document", + }) + }) + + it("OC-10: 異常系: 作成時の競合で既存ドキュメントがあればそのまま読み取る", async () => { + createDocument.mockRejectedValueOnce( + new Error("Only a single offscreen document may be created."), + ) + await expect(readOffscreenClipboard()).resolves.toEqual({ + data: "日本語 clipboard", + }) + expect(createDocument).toHaveBeenCalledOnce() + expect(sendMessage).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/extension/src/services/__tests__/uuid.test.ts b/packages/extension/src/services/__tests__/uuid.test.ts deleted file mode 100644 index dd11d5db6..000000000 --- a/packages/extension/src/services/__tests__/uuid.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -import { describe, it, expect } from "vitest" -import { cmd2uuid } from "../uuid" -import { OPEN_MODE, SPACE_ENCODING } from "@/const" -import type { CommandContent } from "@/types/command" - -describe("UUID Service", () => { - describe("cmd2uuid - internal object processing", () => { - it("UUID-01: should generate the same UUID for identical SearchCommands", async () => { - const command: CommandContent = { - title: "Test Search", - searchUrl: "https://example.com/search?q=%s", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - } - - const uuid1 = await cmd2uuid(command) - const uuid2 = await cmd2uuid(command) - - expect(uuid1).toBe(uuid2) - }) - - it("UUID-02: should generate different UUIDs for different SearchCommands", async () => { - const command1: CommandContent = { - title: "Test Search 1", - searchUrl: "https://example.com/search?q=%s", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - } - - const command2: CommandContent = { - title: "Test Search 2", // Different title - searchUrl: "https://example.com/search?q=%s", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - } - - const uuid1 = await cmd2uuid(command1) - const uuid2 = await cmd2uuid(command2) - - expect(uuid1).not.toBe(uuid2) - }) - - it("UUID-03: should generate the same UUID regardless of property order in objects", async () => { - // This is tested implicitly through the normalizeObject function usage - // We verify that identical commands produce identical UUIDs - const baseProperties = { - title: "Property Order Test", - searchUrl: "https://example.com/search?q=%s", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - spaceEncoding: SPACE_ENCODING.PLUS, - } - - // Create command with same properties in different order - const command1: CommandContent = { - title: baseProperties.title, - openMode: baseProperties.openMode, - iconUrl: baseProperties.iconUrl, - searchUrl: baseProperties.searchUrl, - spaceEncoding: baseProperties.spaceEncoding, - } - - const command2: CommandContent = { - spaceEncoding: baseProperties.spaceEncoding, - searchUrl: baseProperties.searchUrl, - iconUrl: baseProperties.iconUrl, - openMode: baseProperties.openMode, - title: baseProperties.title, - } - - const uuid1 = await cmd2uuid(command1) - const uuid2 = await cmd2uuid(command2) - - expect(uuid1).toBe(uuid2) - }) - - it("UUID-04: should generate a valid UUIDv5 format", async () => { - const command: CommandContent = { - title: "UUID Format Test", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - } - - const uuid = await cmd2uuid(command) - - // UUIDv5 format: xxxxxxxx-xxxx-5xxx-xxxx-xxxxxxxxxxxx - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i - expect(uuid).toMatch(uuidRegex) - }) - }) - - describe("cmd2uuid", () => { - describe("SearchCommand", () => { - it("UUID-05: should generate correct UUID for SearchCommand", async () => { - const searchCommand: CommandContent = { - title: "Google Search", - searchUrl: "https://www.google.com/search?q=%s", - iconUrl: "https://www.google.com/favicon.ico", - openMode: OPEN_MODE.TAB, - openModeSecondary: OPEN_MODE.WINDOW, - spaceEncoding: SPACE_ENCODING.PLUS, - } - - const uuid = await cmd2uuid(searchCommand) - - expect(typeof uuid).toBe("string") - expect(uuid).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ) - }) - - it("UUID-06: should generate UUID with minimal SearchCommand properties", async () => { - const minimalSearchCommand: CommandContent = { - title: "Minimal Search", - iconUrl: "", - openMode: OPEN_MODE.TAB, - } - - const uuid = await cmd2uuid(minimalSearchCommand) - - expect(typeof uuid).toBe("string") - expect(uuid).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ) - }) - - it("UUID-07: should generate UUID with all SearchCommand properties", async () => { - const fullSearchCommand: CommandContent = { - title: "Full Search Command", - searchUrl: "https://example.com/search?q=%s", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - openModeSecondary: OPEN_MODE.WINDOW, - spaceEncoding: SPACE_ENCODING.PERCENT, - parentFolderId: "folder-123", - } - - const uuid = await cmd2uuid(fullSearchCommand) - - expect(typeof uuid).toBe("string") - expect(uuid).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ) - }) - - it("UUID-08: should consistently generate same UUID for identical SearchCommand", async () => { - const searchCommand: CommandContent = { - title: "Consistent Search", - searchUrl: "https://example.com/search?q=%s", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.TAB, - } - - const uuid1 = await cmd2uuid(searchCommand) - const uuid2 = await cmd2uuid(searchCommand) - - expect(uuid1).toBe(uuid2) - }) - }) - - describe("PageActionCommand", () => { - it("UUID-09: should generate correct UUID for PageActionCommand", async () => { - const pageActionCommand = { - title: "Page Action Test", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.PAGE_ACTION, - pageActionOption: { - startUrl: "https://example.com", - openMode: "tab", - steps: [{ type: "click", selector: "#button" }], - }, - } as CommandContent - - const uuid = await cmd2uuid(pageActionCommand) - - expect(typeof uuid).toBe("string") - expect(uuid).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ) - }) - - it("UUID-10: should generate different UUIDs for different pageActionOptions", async () => { - const baseCommand = { - title: "Page Action Base", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.PAGE_ACTION, - } - - const command1 = { - ...baseCommand, - pageActionOption: { - startUrl: "https://example1.com", - openMode: "tab", - steps: [{ type: "click", selector: "#button1" }], - }, - } as CommandContent - - const command2 = { - ...baseCommand, - pageActionOption: { - startUrl: "https://example2.com", - openMode: "tab", - steps: [{ type: "click", selector: "#button2" }], - }, - } as CommandContent - - const uuid1 = await cmd2uuid(command1) - const uuid2 = await cmd2uuid(command2) - - expect(uuid1).not.toBe(uuid2) - }) - - it("UUID-11: should consistently generate same UUID for identical PageActionCommand", async () => { - const pageActionCommand = { - title: "Consistent Page Action", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.PAGE_ACTION, - pageActionOption: { - startUrl: "https://example.com", - openMode: "tab", - steps: [ - { type: "click", selector: "#submit" }, - { type: "wait", duration: 1000 }, - ], - }, - } as CommandContent - - const uuid1 = await cmd2uuid(pageActionCommand) - const uuid2 = await cmd2uuid(pageActionCommand) - - expect(uuid1).toBe(uuid2) - }) - - it("UUID-14: Generate the same UUID as the Selection command Hub.", async () => { - const pageActionCommand = { - title: "Page Action Example", - iconUrl: "https://example.com/icon.png", - openMode: OPEN_MODE.PAGE_ACTION, - pageActionOption: { - startUrl: "https://example.com", - openMode: "tab", - steps: [ - { id: "gmavyqlj2", type: "click", selector: "#submit" }, - { id: "umb7r0prx", type: "wait", duration: 1000 }, - ], - }, - } as CommandContent - - const uuid = await cmd2uuid(pageActionCommand) - - // This UUID should match the one generated in the Selection command Hub's test case: CH-14 - expect(uuid).toBe("3b6529de-daa9-5832-9597-090d260b81aa") - }) - - it("UUID-15: Generate the same UUID as the Selection command Hub.", async () => { - const commandInHub = { - title: "サクラチェッカー", - id: "b1856c00-d775-5fdc-89e9-94d1b6f786e5", - description: "表示中ページのURLをサクラチェッカーでチェックします。", - iconUrl: "https://sakura-checker.jp/images/favicon.ico", - openMode: "pageAction", - tags: ["Shop", "Japanese"], - pageActionOption: { - startUrl: "https://sakura-checker.jp/", - openMode: "popup", - steps: [ - { - id: "diaq1mthv", - param: { - type: "start", - label: "Start", - }, - }, - { - id: "hoi75oly8", - param: { - type: "click", - label: "word", - selector: "//*[@name='word']", - selectorType: "xpath", - }, - }, - { - id: "2boq9korh", - param: { - type: "input", - label: "word", - selector: "//*[@name='word']", - selectorType: "xpath", - value: "{{Url}}", - }, - }, - { - id: "thuq16r8o", - param: { - type: "click", - label: "submit", - selector: "//*[@class='button is-primary is-medium']", - selectorType: "xpath", - }, - }, - { - id: "bcs0vx9bn", - param: { - type: "scroll", - label: "x: 0, y: 266", - x: 0, - y: 266, - }, - }, - { - id: "8viuw3650", - param: { - type: "end", - label: "End", - }, - }, - ], - }, - addedAt: "2025-04-22T12:01:00.382Z", - } as CommandContent - - const commandInExtension = { - title: "サクラチェッカー", - id: "b1856c00-d775-5fdc-89e9-94d1b6f786e5", - iconUrl: "https://sakura-checker.jp/images/favicon.ico", - openMode: "pageAction", - pageActionOption: { - openMode: "popup", - startUrl: "https://sakura-checker.jp/", - steps: [ - { - delayMs: 0, - id: "diaq1mthv", - param: { - label: "Start", - type: "start", - }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "hoi75oly8", - param: { - label: "word", - selector: "//*[@name='word']", - selectorType: "xpath", - type: "click", - }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "2boq9korh", - param: { - label: "word", - selector: "//*[@name='word']", - selectorType: "xpath", - type: "input", - value: "{{Url}}", - }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "thuq16r8o", - param: { - label: "submit", - selector: "//*[@class='button is-primary is-medium']", - selectorType: "xpath", - type: "click", - }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "bcs0vx9bn", - param: { - label: "x: 0, y: 266", - type: "scroll", - x: 0, - y: 266, - }, - skipRenderWait: false, - }, - { - delayMs: 0, - id: "8viuw3650", - param: { - label: "End", - type: "end", - }, - skipRenderWait: false, - }, - ], - }, - popupOption: { - height: 700, - width: 600, - }, - } as CommandContent - - const uuid = await cmd2uuid(commandInHub) - const uuid2 = await cmd2uuid(commandInExtension) - - // This UUID should match the one generated in the Selection command Hub's test case: CH-14 - expect(uuid).toBe("b1856c00-d775-5fdc-89e9-94d1b6f786e5") - // TODO: Should be the same UUID, but currently not due to differences - // in properties like delayMs and skipRenderWait. - expect(uuid).not.toBe(uuid2) - }) - }) - - describe("Error Handling", () => { - it("UUID-12: should throw error for invalid command type", async () => { - const invalidCommand = { - title: "Invalid Command", - iconUrl: "https://example.com/icon.png", - openMode: "INVALID_MODE" as any, - } - - await expect(cmd2uuid(invalidCommand)).rejects.toThrow( - "Invalid command", - ) - }) - - it("UUID-13: should handle null and undefined commands", async () => { - await expect(cmd2uuid(null as any)).rejects.toThrow() - await expect(cmd2uuid(undefined as any)).rejects.toThrow() - }) - }) - }) -}) diff --git a/packages/extension/src/services/chrome.ts b/packages/extension/src/services/chrome.ts index 52edba606..5cc35769e 100644 --- a/packages/extension/src/services/chrome.ts +++ b/packages/extension/src/services/chrome.ts @@ -1,16 +1,18 @@ -import { sleep, toUrl, isOverBytes, isUrlParam } from "@/lib/utils" +import { sleep, toUrl, isOverBytes, needsClipboard } from "@/lib/utils" import type { ScreenSize } from "@/services/dom" import type { ShowToastParam, UrlParam, WindowLayer } from "@/types" -import { POPUP_OFFSET, POPUP_TYPE, WINDOW_STATE } from "@/const" +import { + OPTION_PAGE_PATH, + POPUP_OFFSET, + POPUP_TYPE, + WINDOW_STATE, +} from "@/const" import { PopupOption } from "@/services/option/defaultSettings" import { ServiceWorkerData } from "@/services/serviceWorkerData" import { WindowStackManager } from "@/services/windowStackManager" -import { - ServiceWorkerCommand, - ClipboardResult, - TabCommand, -} from "@/services/ipc" +import { TabCommand } from "@/services/ipc" import { getScreenSize } from "@/services/screen" +import { readOffscreenClipboard } from "@/services/offscreenClipboard" import { Ipc } from "@/services/ipc" import { t } from "@/services/i18n" import { @@ -99,7 +101,6 @@ export const fetchIconUrl = async (url: string): Promise => { url: toUrl({ searchUrl: url, selectionText: "test", - useClipboard: false, }), state: "minimized", }) @@ -136,17 +137,6 @@ export type OpenTabProps = { active: boolean } -type ReadClipboardParam = Omit & { - incognito: boolean - state?: "fullscreen" | "maximized" -} - -type ReadClipboardResult = { - clipboardText: string - window: chrome.windows.Window - err?: string -} - type OpenResult = { tabId: number | undefined clipboardText: string @@ -340,104 +330,29 @@ const updateRules = async (tabIds: number[]) => { }) } -/** - * Read clipboard content from a tab - * @param {number} tabId - The ID of the tab to read clipboard from - * @returns {Promise} The clipboard content - */ -const readClipboardContent = async ( - tabId: number, -): Promise => { - try { - const result = await new Promise((resolve) => { - const onConnect = (port: chrome.runtime.Port) => { - if (port.sender?.tab?.id !== tabId) { - return - } - const onMessage = (msg: { command: string; data: ClipboardResult }) => { - if (msg.command === ServiceWorkerCommand.setClipboard) { - port.onMessage.removeListener(onMessage) - chrome.runtime.onConnect.removeListener(onConnect) - resolve(msg.data) - } - } - port.onMessage.addListener(onMessage) - } - chrome.runtime.onConnect.addListener(onConnect) - if (chrome.runtime.lastError) { - throw new Error(chrome.runtime.lastError.message) - } - }) - - return result - } catch (e) { - console.error(e) - return { data: "", err: e instanceof Error ? e.message : "Unknown error" } - } -} - -const openWindowAndReadClipboard = async ( - param: ReadClipboardParam, -): Promise => { - const usesWindowState = - param.state === "fullscreen" || param.state === "maximized" - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - focused: true, - type: param.type, - width: usesWindowState ? undefined : param.width, - height: usesWindowState ? undefined : param.height, - left: usesWindowState ? undefined : param.left, - top: usesWindowState ? undefined : param.top, - state: param.state, - incognito: param.incognito, - }) - - const tab = w?.tabs?.[0] - if (!tab?.id || !w?.id) { - throw new Error("Failed to create clipboard window") - } - const result = await readClipboardContent(tab.id) - - return { - window: w, - clipboardText: result.data ?? "", - err: result.err, - } -} - -/** - * Open a temporary window to read clipboard content and return the result. - * @returns {Promise<{ clipboardText: string; err?: string }>} The clipboard content and any error message - * - * Note: This function opens a temporary window to read the clipboard content, - * which is necessary due to browser security restrictions. - * The window is closed immediately after reading the clipboard. - */ +/** Read clipboard text through the shared offscreen document. */ export const readClipboard = async (): Promise<{ clipboardText: string err?: string }> => { - const w = await chrome.windows.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - focused: true, - type: "popup", - width: 1, - height: 1, - left: 0, - top: 0, - state: "normal", + const result = await readOffscreenClipboard() + return { clipboardText: result.data ?? "", err: result.err } +} + +/** Read clipboard text only when the URL refers to the clipboard. */ +const readClipboardIfNeeded = async ( + url: string | UrlParam, +): Promise<{ clipboardText: string; err?: string }> => + needsClipboard(url) ? readClipboard() : { clipboardText: "" } + +/** Notify the user in the given tab that reading the clipboard failed. */ +export const showClipboardErrorToast = async (tabId: number): Promise => { + await Ipc.ensureConnection(tabId) + await Ipc.sendTab(tabId, TabCommand.showToast, { + title: t("clipboard_error_title"), + description: t("clipboard_error_description"), + action: t("clipboard_error_action"), }) - const tab = w?.tabs?.[0] - if (!tab?.id || !w?.id) { - throw new Error("Failed to create clipboard window") - } - const result = await readClipboardContent(tab.id) - await closeWindow(w.id, "readClipboard close window") - return { - clipboardText: result.data ?? "", - err: result.err, - } } export const openPopupWindow = async ( @@ -472,68 +387,28 @@ export const openPopupWindow = async ( ? "maximized" : undefined - let window: chrome.windows.Window - let clipboardText = "" - - if (isUrlParam(url) && url.useClipboard) { - const result = await openWindowAndReadClipboard({ - commandId: param.commandId, - type, - width, - height, - top: at, - left: al, - state: windowState, - incognito: current.incognito, - }) - window = result.window - clipboardText = result.clipboardText - - try { - await chrome.tabs.update(window.tabs?.[0].id as number, { - url: toUrl(url, clipboardText), - }) - if (isFullscreen) { - // On macOS, even if you open with state: "fullscreen", - // it may not actually go fullscreen, so switch to fullscreen after opening. - await chrome.windows.update(window.id!, { state: "fullscreen" }) - } - } catch (e) { - console.error(e) + const clipboard = await readClipboardIfNeeded(url) + const window = (await chrome.windows.create({ + url: toUrl(url, clipboard.clipboardText), + type, + width: usesWindowState ? undefined : width, + height: usesWindowState ? undefined : height, + top: usesWindowState ? undefined : at, + left: usesWindowState ? undefined : al, + state: windowState, + incognito: current.incognito, + }))! + try { + if (isFullscreen) { + // macOS may require reapplying fullscreen after window creation. + await chrome.windows.update(window.id!, { state: "fullscreen" }) } + } catch (e) { + console.error(e) + } - if (result.err) { - await Ipc.ensureConnection(window.tabs?.[0].id as number) - await Ipc.sendTab( - window.tabs?.[0].id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) - } - } else { - window = (await chrome.windows.create({ - url: toUrl(url), - type, - width: usesWindowState ? undefined : width, - height: usesWindowState ? undefined : height, - top: usesWindowState ? undefined : at, - left: usesWindowState ? undefined : al, - state: windowState, - incognito: current.incognito, - }))! - try { - if (isFullscreen) { - // On macOS, even if you open with state: "fullscreen", - // it may not actually go fullscreen, so switch to fullscreen after opening. - await chrome.windows.update(window.id!, { state: "fullscreen" }) - } - } catch (e) { - console.error(e) - } + if (clipboard.err) { + await showClipboardErrorToast(window.tabs?.[0].id as number) } await updateServiceWorkerData([window], param.commandId, current.id, type) @@ -543,7 +418,7 @@ export const openPopupWindow = async ( return { tabId: window.tabs?.[0]?.id, - clipboardText, + clipboardText: clipboard.clipboardText, } } @@ -607,52 +482,23 @@ export const openTab = async (param: OpenTabProps): Promise => { console.warn("Failed to get current tab:", e) } - let useClipboard = false - if (isUrlParam(url)) { - useClipboard = url.useClipboard ?? false - } - - if (useClipboard) { - const tab = await chrome.tabs.create({ - url: chrome.runtime.getURL("src/clipboard.html"), - active: true, - windowId: currentTab?.windowId, - index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, - }) - - const result = await readClipboardContent(tab.id as number) - await chrome.tabs.update(tab.id as number, { - url: toUrl(url, result.data ?? ""), - }) + const clipboard = await readClipboardIfNeeded(url) + const tab = await chrome.tabs.create({ + url: toUrl(url, clipboard.clipboardText), + active, + windowId: currentTab?.windowId, + index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, + }) - if (result.err) { - await Ipc.ensureConnection(tab.id as number) - await Ipc.sendTab( - tab.id as number, - TabCommand.showToast, - { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }, - ) - } + if (clipboard.err) { + // A background tab is not visible, so notify in the tab the user is on. + const toastTabId = active ? tab.id : (currentTab?.id ?? tab.id) + await showClipboardErrorToast(toastTabId as number) + } - return { - tabId: tab.id as number, - clipboardText: result.data ?? "", - } - } else { - const tab = await chrome.tabs.create({ - url: toUrl(url), - active, - windowId: currentTab?.windowId, - index: currentTab?.index !== undefined ? currentTab.index + 1 : undefined, - }) - return { - tabId: tab.id as number, - clipboardText: "", - } + return { + tabId: tab.id as number, + clipboardText: clipboard.clipboardText, } } @@ -666,6 +512,42 @@ export async function getCurrentTab(): Promise { return tab } +/** + * Open the options page, reusing an existing options page tab if any. + * A tab in the last focused window is preferred over tabs in other windows. + * @param path - The options page path. It may include a query and a hash. + * @param excludeTabId - A tab not to reuse, e.g. one that is being closed. + * @returns The tab showing the options page, and whether it was newly created. + */ +export async function openOptionPage( + path: string = OPTION_PAGE_PATH, + excludeTabId?: number, +): Promise<{ tab: chrome.tabs.Tab | undefined; created: boolean }> { + const optionPageUrl = chrome.runtime.getURL(OPTION_PAGE_PATH) + const [tabs, currentTab] = await Promise.all([ + chrome.tabs.query({}), + getCurrentTab(), + ]) + // A tab that is still loading has its destination only in pendingUrl. + const candidates = tabs.filter( + (t) => + t.id !== excludeTabId && + (t.pendingUrl || t.url)?.startsWith(optionPageUrl), + ) + const existing = + candidates.find((t) => t.windowId === currentTab?.windowId) ?? candidates[0] + if (existing?.id == null) { + const tab = await chrome.tabs.create({ url: path }) + return { tab, created: true } + } + // Navigate only when parameters are given, to keep the current page state. + const props: chrome.tabs.UpdateProperties = { active: true } + if (path !== OPTION_PAGE_PATH) props.url = chrome.runtime.getURL(path) + const tab = await chrome.tabs.update(existing.id, props) + await chrome.windows.update(existing.windowId, { focused: true }) + return { tab: tab ?? existing, created: false } +} + /** * Close a window. * @param windowId - The ID of the window to close. diff --git a/packages/extension/src/services/contextMenus.ts b/packages/extension/src/services/contextMenus.ts index 2a707644c..461ed844e 100644 --- a/packages/extension/src/services/contextMenus.ts +++ b/packages/extension/src/services/contextMenus.ts @@ -11,8 +11,13 @@ import { export type executeActionProps = { command: Command - /** See ExecuteCommandParams.allowClipboardFallback. */ - allowClipboardFallback?: boolean + /** + * The selected text decided by the sender (e.g. the shortcut handler). + * If undefined, the current selection in the tab is used. + */ + selectionText?: string + /** See ExecuteCommandParams.selectionFromClipboard. */ + selectionFromClipboard?: boolean } type Callback = () => void diff --git a/packages/extension/src/services/experiments/__tests__/experimentConfig.test.ts b/packages/extension/src/services/experiments/__tests__/experimentConfig.test.ts index 030aee5cc..ff93ac90e 100644 --- a/packages/extension/src/services/experiments/__tests__/experimentConfig.test.ts +++ b/packages/extension/src/services/experiments/__tests__/experimentConfig.test.ts @@ -5,7 +5,7 @@ import { fetchExperimentConfig, } from "../experimentConfig" -const EXPERIMENT_ID = "onboarding_v3" +const EXPERIMENT_ID = "onboarding_v4" const mockFetchOnce = (body: unknown, ok = true, status = 200) => { vi.stubGlobal( diff --git a/packages/extension/src/services/experiments/__tests__/onboardingExperiment.test.ts b/packages/extension/src/services/experiments/__tests__/onboardingExperiment.test.ts index 005e3f2d9..fbe2b464b 100644 --- a/packages/extension/src/services/experiments/__tests__/onboardingExperiment.test.ts +++ b/packages/extension/src/services/experiments/__tests__/onboardingExperiment.test.ts @@ -36,7 +36,7 @@ describe("ensureOnboardingAssignment", () => { vi.restoreAllMocks() }) - it("assigns variant C when the random draw lands below the allocation", async () => { + it("assigns variant D when the random draw lands below the allocation", async () => { fetchExperimentConfig.mockResolvedValue({ config: { enabled: true, allocation: 0.5 }, source: "remote", @@ -45,20 +45,20 @@ describe("ensureOnboardingAssignment", () => { const assignment = await ensureOnboardingAssignment() - expect(assignment.variant).toBe("C") + expect(assignment.variant).toBe("D") expect(assignment.configSource).toBe("remote") - expect(getOnboardingVariantSync()).toBe("C") - expect((await readStored())[ONBOARDING_EXPERIMENT_ID].variant).toBe("C") + expect(getOnboardingVariantSync()).toBe("D") + expect((await readStored())[ONBOARDING_EXPERIMENT_ID].variant).toBe("D") }) - it("assigns the control when the draw lands above the allocation", async () => { + it("assigns the control (variant C) when the draw lands above the allocation", async () => { fetchExperimentConfig.mockResolvedValue({ config: { enabled: true, allocation: 0.5 }, source: "remote", }) vi.spyOn(Math, "random").mockReturnValue(0.5) - expect((await ensureOnboardingAssignment()).variant).toBe("A") + expect((await ensureOnboardingAssignment()).variant).toBe("C") }) it("assigns everyone to the control while the experiment is disabled", async () => { @@ -68,7 +68,7 @@ describe("ensureOnboardingAssignment", () => { }) vi.spyOn(Math, "random").mockReturnValue(0) - expect((await ensureOnboardingAssignment()).variant).toBe("A") + expect((await ensureOnboardingAssignment()).variant).toBe("C") }) it("still assigns randomly when the config could not be fetched", async () => { @@ -80,7 +80,7 @@ describe("ensureOnboardingAssignment", () => { const assignment = await ensureOnboardingAssignment() - expect(assignment.variant).toBe("C") + expect(assignment.variant).toBe("D") expect(assignment.configSource).toBe("fallback") }) @@ -91,7 +91,7 @@ describe("ensureOnboardingAssignment", () => { }) await Storage.set(LOCAL_STORAGE_KEY.EXPERIMENTS, { [ONBOARDING_EXPERIMENT_ID]: { - variant: "A", + variant: "C", allocation: 0.5, configSource: "remote", assignedAt: 1, @@ -100,7 +100,7 @@ describe("ensureOnboardingAssignment", () => { const assignment = await ensureOnboardingAssignment() - expect(assignment.variant).toBe("A") + expect(assignment.variant).toBe("C") expect(assignment.assignedAt).toBe(1) expect(fetchExperimentConfig).not.toHaveBeenCalled() }) @@ -123,6 +123,6 @@ describe("ensureOnboardingAssignment", () => { const stored = await readStored() expect(stored.other_experiment.variant).toBe("C") - expect(stored[ONBOARDING_EXPERIMENT_ID].variant).toBe("C") + expect(stored[ONBOARDING_EXPERIMENT_ID].variant).toBe("D") }) }) diff --git a/packages/extension/src/services/experiments/experimentConfig.ts b/packages/extension/src/services/experiments/experimentConfig.ts index 79fde5709..bc1f5ee63 100644 --- a/packages/extension/src/services/experiments/experimentConfig.ts +++ b/packages/extension/src/services/experiments/experimentConfig.ts @@ -23,7 +23,7 @@ const FETCH_TIMEOUT_MS = 3000 * update these values too, or the fallback path keeps using the old split. */ export const DEFAULT_CONFIGS: Record = { - onboarding_v3: { enabled: true, allocation: 0.5 }, + onboarding_v4: { enabled: true, allocation: 0.5 }, } const DEFAULT_CONFIG: ExperimentConfig = { enabled: false, allocation: 0 } diff --git a/packages/extension/src/services/experiments/onboardingExperiment.ts b/packages/extension/src/services/experiments/onboardingExperiment.ts index f130ecb80..05990103d 100644 --- a/packages/extension/src/services/experiments/onboardingExperiment.ts +++ b/packages/extension/src/services/experiments/onboardingExperiment.ts @@ -7,7 +7,13 @@ import type { } from "./types" /** Experiment id reported to GA4 as `experiment_id`. */ -export const ONBOARDING_EXPERIMENT_ID = "onboarding_v3" +export const ONBOARDING_EXPERIMENT_ID = "onboarding_v4" + +/** Arm everyone falls back to: experiment disabled, or assignment failed. */ +export const ONBOARDING_CONTROL_VARIANT: ExperimentVariant = "C" + +/** Arm assigned to the `allocation` share of users. */ +export const ONBOARDING_TREATMENT_VARIANT: ExperimentVariant = "D" /** * Last resolved assignment, kept in module memory so analytics helpers can @@ -24,7 +30,7 @@ export const getOnboardingVariantSync = (): ExperimentVariant | null => /** * dev/e2e only: force a variant for the rest of this page's lifetime without - * fetching or persisting anything, so `?variant=C` can be used to review a + * fetching or persisting anything, so `?variant=D` can be used to review a * screen directly. See useOnboardingVariant(). */ export const overrideOnboardingAssignment = ( @@ -32,7 +38,7 @@ export const overrideOnboardingAssignment = ( ): ExperimentAssignment => { cachedAssignment = { variant, - allocation: variant === "C" ? 1 : 0, + allocation: variant === ONBOARDING_TREATMENT_VARIANT ? 1 : 0, configSource: "fallback", assignedAt: Date.now(), } @@ -71,7 +77,9 @@ export const ensureOnboardingAssignment = ONBOARDING_EXPERIMENT_ID, ) const variant: ExperimentVariant = - config.enabled && Math.random() < config.allocation ? "C" : "A" + config.enabled && Math.random() < config.allocation + ? ONBOARDING_TREATMENT_VARIANT + : ONBOARDING_CONTROL_VARIANT const assignment: ExperimentAssignment = { variant, allocation: config.allocation, diff --git a/packages/extension/src/services/experiments/types.ts b/packages/extension/src/services/experiments/types.ts index e4b9df1ba..ecd7bc9b8 100644 --- a/packages/extension/src/services/experiments/types.ts +++ b/packages/extension/src/services/experiments/types.ts @@ -1,11 +1,16 @@ /** * Variant a user is assigned to for an A/B test. - * "A" is always the control (the behavior that shipped before the test). - * The treatment letter is bumped for every new pattern under test ("B" was - * onboarding_v2's treatment, "C" is onboarding_v3's), so a GA4 `variant` - * value never means two different patterns. + * Each letter names one UI pattern and keeps meaning that pattern across + * experiments, so a GA4 `variant` value never means two different patterns + * ("A" is the original INTRO flow, "B" was onboarding_v2's treatment, "C" is + * the welcome-overlay flow, "D" is C without the step rail and with the + * welcome glow as the page background). Which letter is the control is + * decided per experiment: onboarding_v4 compares C (control) with D. + * + * "A" is no longer assigned by onboarding_v4 but stays in the type until its + * code path is removed. */ -export type ExperimentVariant = "A" | "C" +export type ExperimentVariant = "A" | "C" | "D" /** Remote configuration of a single experiment, served by the hub. */ export type ExperimentConfig = { diff --git a/packages/extension/src/services/hub/__tests__/commandContent.test.ts b/packages/extension/src/services/hub/__tests__/commandContent.test.ts index 8e00a2882..2fae8d783 100644 --- a/packages/extension/src/services/hub/__tests__/commandContent.test.ts +++ b/packages/extension/src/services/hub/__tests__/commandContent.test.ts @@ -2,12 +2,14 @@ import { describe, it, expect } from "vitest" import { applyHubContent, calcContentHash, + hasUnpushedEdits, isLocallyModified, nextContentUpdatedAt, parseContentUpdatedAt, pickHubContent, stampForHub, } from "../commandContent" +import { commandSchema } from "@/types/schema" import type { SelectionCommand } from "@/types" const base = { @@ -90,6 +92,74 @@ describe("isLocallyModified", () => { }) }) +describe("hasUnpushedEdits", () => { + it("CC-07a: false when the fingerprint matches", () => { + const cmd = { ...base, contentHash: calcContentHash(base) } + expect(hasUnpushedEdits(cmd)).toBe(false) + }) + + it("CC-07b: true when the content differs from the fingerprint", () => { + const cmd = { ...base, contentHash: calcContentHash(base), title: "Edit" } + expect(hasUnpushedEdits(cmd)).toBe(true) + }) + + it("CC-07c: false when there is no fingerprint", () => { + expect(hasUnpushedEdits(base)).toBe(false) + }) + + // Saving from the edit dialog passes the command through the zod schema. + // A command already saved once must keep its fingerprint through it, + // otherwise saving without changes would mark it as edited. + it("CC-07d: false after saving a saved command again through the form schema", () => { + const commands = [ + { + id: "s", + title: "Search", + iconUrl: "https://a.com/i.png", + openMode: "popup", + openModeSecondary: "tab", + spaceEncoding: "plus", + searchUrl: "https://a.com/?q=%s", + }, + { + id: "a", + title: "AI", + iconUrl: "https://a.com/i.png", + openMode: "aiPrompt", + aiPromptOption: { + serviceId: "gemini", + prompt: "{{SelectedText}}", + openMode: "popup", + }, + }, + { + id: "p", + title: "Page Action", + iconUrl: "https://a.com/i.png", + openMode: "pageAction", + pageActionOption: { + startUrl: "https://a.com", + openMode: "popup", + steps: [ + { + id: "1", + // Set by the recorder but not part of the schema + timestamp: 1, + param: { type: "start", label: "Start" }, + }, + ], + }, + }, + ] + for (const raw of commands) { + const saved = commandSchema.parse(raw) as SelectionCommand + const stamped = { ...saved, contentHash: calcContentHash(saved) } + const resaved = commandSchema.parse(stamped) as SelectionCommand + expect(hasUnpushedEdits(resaved)).toBe(false) + } + }) +}) + describe("parseContentUpdatedAt", () => { it("CC-08: normalizes valid dates to ISO 8601", () => { expect(parseContentUpdatedAt("2026-01-02T03:04:05Z")).toBe( diff --git a/packages/extension/src/services/hub/__tests__/serviceWorker.test.ts b/packages/extension/src/services/hub/__tests__/serviceWorker.test.ts index ee31d06bd..9b7b31cc2 100644 --- a/packages/extension/src/services/hub/__tests__/serviceWorker.test.ts +++ b/packages/extension/src/services/hub/__tests__/serviceWorker.test.ts @@ -18,6 +18,7 @@ import { Settings } from "@/services/settings/settings" import { sendEvent, getOrCreateClientId } from "@/services/analytics" import { Ipc, TabCommand } from "@/services/ipc" import { calcContentHash } from "@/services/hub/commandContent" +import { openOptionPage } from "@/services/chrome" vi.mock("@/services/storage", () => ({ Storage: { @@ -35,6 +36,8 @@ vi.mock("@/services/storage", () => ({ }, })) +vi.mock("@/services/chrome", () => ({ openOptionPage: vi.fn() })) + vi.mock("@/services/settings/settings", () => ({ Settings: { addCommands: vi.fn(), updateCommandId: vi.fn() }, })) @@ -84,6 +87,13 @@ type MessageListener = ( sendResponse: (response?: unknown) => void, ) => boolean +function mockOpenOptionPage(tab: Partial, created = true) { + vi.mocked(openOptionPage).mockResolvedValue({ + tab: tab as chrome.tabs.Tab, + created, + }) +} + function getRegisteredListener(): MessageListener { initHubExternalListener() const calls = vi.mocked(chrome.runtime.onMessageExternal.addListener).mock @@ -719,10 +729,7 @@ describe("handleEditCommand", () => { }) it("EC-02: creates options tab with correct URL and responds with result:true", async () => { - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({ id: 42 } as chrome.tabs.Tab) - return Promise.resolve({ id: 42 } as chrome.tabs.Tab) - }) + mockOpenOptionPage({ id: 42 }) const sendResponse = vi.fn() const result = handleEditCommand( "cmd-123", @@ -730,13 +737,9 @@ describe("handleEditCommand", () => { sendResponse, ) expect(result).toBe(true) - expect(chrome.tabs.create).toHaveBeenCalledWith( - { - url: expect.stringContaining( - "src/options_page.html?editCommand=cmd-123&syncHub=1#commands", - ), - }, - expect.any(Function), + expect(openOptionPage).toHaveBeenCalledWith( + "src/options_page.html?editCommand=cmd-123&syncHub=1#commands", + undefined, ) await vi.waitFor(() => expect(sendResponse).toHaveBeenCalledWith({ result: true }), @@ -744,10 +747,7 @@ describe("handleEditCommand", () => { }) it("EC-03: responds with error when tab creation returns no id", async () => { - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({} as chrome.tabs.Tab) - return Promise.resolve({} as chrome.tabs.Tab) - }) + mockOpenOptionPage({}) const sendResponse = vi.fn() handleEditCommand( "cmd-1", @@ -761,6 +761,38 @@ describe("handleEditCommand", () => { }), ) }) + + it("EC-05: does not reuse the edit tab of the cancelled session", async () => { + mockOpenOptionPage({ id: 77 }) + const sender = { tab: { id: 10 } as chrome.tabs.Tab } + handleEditCommand("cmd-1", sender, vi.fn()) + await vi.mocked(openOptionPage).mock.results.at(-1)?.value + + handleEditCommand("cmd-2", sender, vi.fn()) + + expect(chrome.tabs.remove).toHaveBeenCalledWith(77) + expect(openOptionPage).toHaveBeenLastCalledWith( + "src/options_page.html?editCommand=cmd-2&syncHub=1#commands", + 77, + ) + }) + + it("EC-04: responds with error when opening the options page fails", async () => { + vi.mocked(openOptionPage).mockRejectedValue(new Error("No window")) + const sendResponse = vi.fn() + handleEditCommand( + "cmd-1", + { tab: { id: 10 } as chrome.tabs.Tab }, + sendResponse, + ) + await vi.waitFor(() => + expect(sendResponse).toHaveBeenCalledWith({ + result: false, + error: "Failed to create edit tab", + }), + ) + expect(chrome.runtime.onConnectExternal.removeListener).toHaveBeenCalled() + }) }) // --------------------------------------------------------------------------- @@ -769,10 +801,7 @@ describe("handleEditCommand", () => { describe("onMessageExternal - EditCommand routing", () => { it("EC-R-01: opens options page with editCommand and syncHub params", async () => { - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({ id: 42 } as chrome.tabs.Tab) - return Promise.resolve({ id: 42 } as chrome.tabs.Tab) - }) + mockOpenOptionPage({ id: 42 }) const listener = getRegisteredListener() const sendResponse = vi.fn() const result = listener( @@ -795,14 +824,11 @@ describe("onMessageExternal - EditCommand routing", () => { sendResponse, ) expect(result).toBe(false) - expect(chrome.tabs.create).not.toHaveBeenCalled() + expect(openOptionPage).not.toHaveBeenCalled() }) it("EC-R-03: ignores hub-edit port when sender tab id does not match", () => { - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({ id: 42 } as chrome.tabs.Tab) - return Promise.resolve({ id: 42 } as chrome.tabs.Tab) - }) + mockOpenOptionPage({ id: 42 }) const listener = getRegisteredListener() listener( { action: "EditCommand", id: "cmd-123" }, @@ -825,10 +851,7 @@ describe("onMessageExternal - EditCommand routing", () => { }) it("EC-R-04: ignores hub-edit port from wrong origin", () => { - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({ id: 42 } as chrome.tabs.Tab) - return Promise.resolve({ id: 42 } as chrome.tabs.Tab) - }) + mockOpenOptionPage({ id: 42 }) const listener = getRegisteredListener() listener( { action: "EditCommand", id: "cmd-123" }, @@ -851,10 +874,7 @@ describe("onMessageExternal - EditCommand routing", () => { it("EC-R-05: hub-edit connect listener times out after EDIT_CONNECT_TIMEOUT_MS", () => { vi.useFakeTimers() - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({ id: 42 } as chrome.tabs.Tab) - return Promise.resolve({ id: 42 } as chrome.tabs.Tab) - }) + mockOpenOptionPage({ id: 42 }) const listener = getRegisteredListener() listener( { action: "EditCommand", id: "cmd-123" }, @@ -881,11 +901,8 @@ describe("editCommandToHub", () => { targetUrl: "https://example.com", } as any - function setupConnectedHubEditPort() { - vi.mocked(chrome.tabs.create).mockImplementation((_opts, cb) => { - cb?.({ id: 77 } as chrome.tabs.Tab) - return Promise.resolve({ id: 77 } as chrome.tabs.Tab) - }) + async function setupConnectedHubEditPort(created = true) { + mockOpenOptionPage({ id: 77 }, created) const listener = getRegisteredListener() listener( { action: "EditCommand", id: "cmd-1" }, @@ -903,6 +920,8 @@ describe("editCommandToHub", () => { onDisconnect: { addListener: vi.fn() }, } hubEditConnectListener(port as any) + // Wait until the opened options page tab is registered to the session. + await vi.mocked(openOptionPage).mock.results.at(-1)?.value return port } @@ -914,7 +933,7 @@ describe("editCommandToHub", () => { }) it("EH-02: on ack, updates storage excluding locale/targetUrl", async () => { - const port = setupConnectedHubEditPort() + const port = await setupConnectedHubEditPort() let onAckMessage: ((msg: unknown) => void) | undefined vi.mocked(port.onMessage.addListener).mockImplementation((fn) => { onAckMessage = fn @@ -949,9 +968,27 @@ describe("editCommandToHub", () => { }) }) - it("EH-03: responds with false when ack timeout passes", () => { + it("EH-02-a: keeps the reused options page tab open after ack", async () => { + const port = await setupConnectedHubEditPort(false) + let onAckMessage: ((msg: unknown) => void) | undefined + vi.mocked(port.onMessage.addListener).mockImplementation((fn) => { + onAckMessage = fn + }) + const response = vi.fn() + + editCommandToHub(editParam, sender, response) + onAckMessage?.({ type: "edit-command-ack" }) + + await vi.waitFor(() => { + expect(response).toHaveBeenCalledWith(true) + expect(chrome.tabs.update).toHaveBeenCalledWith(10, { active: true }) + }) + expect(chrome.tabs.remove).not.toHaveBeenCalled() + }) + + it("EH-03: responds with false when ack timeout passes", async () => { vi.useFakeTimers() - const port = setupConnectedHubEditPort() + const port = await setupConnectedHubEditPort() const response = vi.fn() editCommandToHub(editParam, sender, response) @@ -962,9 +999,9 @@ describe("editCommandToHub", () => { vi.useRealTimers() }) - it("EH-04: rejects duplicate request while previous ack is pending", () => { + it("EH-04: rejects duplicate request while previous ack is pending", async () => { vi.useFakeTimers() - setupConnectedHubEditPort() + await setupConnectedHubEditPort() const firstResponse = vi.fn() const secondResponse = vi.fn() diff --git a/packages/extension/src/services/hub/commandContent.ts b/packages/extension/src/services/hub/commandContent.ts index 981b0bd6c..1801da5eb 100644 --- a/packages/extension/src/services/hub/commandContent.ts +++ b/packages/extension/src/services/hub/commandContent.ts @@ -96,6 +96,17 @@ export function isLocallyModified(cmd: SelectionCommand): boolean { return cmd.contentHash !== calcContentHash(cmd) } +/** + * Whether a shared command has local edits not yet pushed to the Hub. + * Unlike isLocallyModified, a command without a fingerprint (shared before + * fingerprints existed) counts as unchanged, to avoid flagging every such + * command. The fingerprint is saved on the next push, enabling detection. + */ +export function hasUnpushedEdits(cmd: SelectionCommand): boolean { + if (!cmd.contentHash) return false + return cmd.contentHash !== calcContentHash(cmd) +} + /** Returns the value as ISO 8601 if it is a valid date string. */ export function parseContentUpdatedAt(value: unknown): string | undefined { if (typeof value !== "string") return undefined diff --git a/packages/extension/src/services/hub/serviceWorker.ts b/packages/extension/src/services/hub/serviceWorker.ts index 2f987c68b..687f4cc99 100644 --- a/packages/extension/src/services/hub/serviceWorker.ts +++ b/packages/extension/src/services/hub/serviceWorker.ts @@ -7,6 +7,7 @@ import { SCREEN, } from "@/const" import { Ipc, TabCommand } from "@/services/ipc" +import { openOptionPage } from "@/services/chrome" import type { Sender, ConfirmCommandUpdateProps } from "@/services/ipc" import { Storage, LOCAL_STORAGE_KEY } from "@/services/storage" import type { SubmitCommandInput } from "@/services/hubShare" @@ -554,6 +555,8 @@ export function handleEditCommand( } // Cancel any in-progress session; the new request takes over (last-write-wins). + // Its edit tab is being closed, so it must not be reused for this request. + const closingTabId = _editSession?.editTabId cancelEditSession() const newSession: EditSession = { @@ -602,26 +605,35 @@ export function handleEditCommand( if (_editSession === newSession) _editSession = undefined }, EDIT_CONNECT_TIMEOUT_MS) - chrome.tabs.create( - { - url: `${OPTION_PAGE_PATH}?editCommand=${encodeURIComponent(id)}&syncHub=1#commands`, - }, - (tab) => { + const failToOpen = () => { + cleanupHubEditConnectListener() + if (_editSession === newSession) _editSession = undefined + sendResponse({ result: false, error: "Failed to create edit tab" }) + } + + openOptionPage( + `${OPTION_PAGE_PATH}?editCommand=${encodeURIComponent(id)}&syncHub=1#commands`, + closingTabId, + ) + .then(({ tab, created }) => { if (_editSession !== newSession) { - if (tab?.id != null) chrome.tabs.remove(tab.id) + if (created && tab?.id != null) chrome.tabs.remove(tab.id) sendResponse({ result: false, error: "Edit session timed out" }) return } if (tab?.id == null) { - cleanupHubEditConnectListener() - _editSession = undefined - sendResponse({ result: false, error: "Failed to create edit tab" }) + failToOpen() return } - newSession.editTabId = tab.id + // Close the tab after editing only if it was opened for this edit, so + // that an options page the user already had open is kept. + if (created) newSession.editTabId = tab.id sendResponse({ result: true }) - }, - ) + }) + .catch((err) => { + console.error("[handleEditCommand] Failed to open options page:", err) + failToOpen() + }) return true } diff --git a/packages/extension/src/services/ipc.ts b/packages/extension/src/services/ipc.ts index ec93cb32b..8cb5e4145 100644 --- a/packages/extension/src/services/ipc.ts +++ b/packages/extension/src/services/ipc.ts @@ -62,7 +62,6 @@ export enum ServiceWorkerCommand { toggleStar = "toggleStar", getTabId = "getTabId", getActiveTabId = "getActiveTabId", - setClipboard = "setClipboard", closeTab = "closeTab", resolveIconColors = "resolveIconColors", // Hub @@ -87,6 +86,7 @@ export enum TabCommand { ping = "ping", connected = "connected", executeAction = "executeAction", + getSelection = "getSelection", clickElement = "clickElement", closeMenu = "closeMenu", showReviewRequest = "showReviewRequest", @@ -150,7 +150,10 @@ export type SidePanelPendingAction = { selectedText: string srcUrl: string clipboardText: string - useClipboard?: boolean + /** See UrlParam.selectionFromClipboard. */ + selectionFromClipboard?: boolean + /** See UrlParam.clipboardForSteps. */ + clipboardForSteps?: boolean pageHtml?: string selectionHtml?: string userVariables?: Array diff --git a/packages/extension/src/services/offscreenClipboard.ts b/packages/extension/src/services/offscreenClipboard.ts new file mode 100644 index 000000000..096265f8e --- /dev/null +++ b/packages/extension/src/services/offscreenClipboard.ts @@ -0,0 +1,72 @@ +import type { ClipboardResult } from "./ipc" +import { sleep } from "@/lib/utils" + +const DOCUMENT_PATH = "src/offscreen_clipboard.html" +const MAX_ATTEMPTS = 3 +const RETRY_DELAY_MS = 50 + +let pendingRead: Promise = Promise.resolve() + +const toErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) + +const isClipboardResult = (value: unknown): value is ClipboardResult => + typeof value === "object" && + value !== null && + typeof (value as ClipboardResult).data === "string" + +async function ensureDocument(): Promise { + const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [chrome.runtime.getURL(DOCUMENT_PATH)], + }) + // Recheck after worker restarts or if Chrome discarded the document. + if (contexts.length > 0) return + try { + await chrome.offscreen.createDocument({ + url: DOCUMENT_PATH, + reasons: [chrome.offscreen.Reason.CLIPBOARD], + justification: "Read clipboard text without opening a visible window", + }) + } catch (error) { + // Another context may have created it between the check and the creation. + if (!/single offscreen document/i.test(toErrorMessage(error))) throw error + } +} + +async function readOnce(): Promise { + try { + await ensureDocument() + const result: unknown = await chrome.runtime.sendMessage({ + target: "offscreen-clipboard", + command: "read", + }) + if (!isClipboardResult(result)) { + return { data: "", err: "Invalid response from offscreen document" } + } + return result + } catch (error) { + return { data: "", err: toErrorMessage(error) } + } +} + +async function readWithRetry(): Promise { + let result: ClipboardResult = { data: "" } + for (let i = 0; i < MAX_ATTEMPTS; i++) { + result = await readOnce() + if (!result.err) return result + console.warn( + `Retry to read clipboard (attempt ${i + 1}/${MAX_ATTEMPTS}):`, + result.err, + ) + if (i < MAX_ATTEMPTS - 1) await sleep(RETRY_DELAY_MS) + } + return result +} + +export function readOffscreenClipboard(): Promise { + // Serialize reads and creation; keep the document for subsequent requests. + const read = pendingRead.then(readWithRetry) + pendingRead = read.catch(() => undefined) + return read +} diff --git a/packages/extension/src/services/option/__tests__/userStyles.test.ts b/packages/extension/src/services/option/__tests__/userStyles.test.ts new file mode 100644 index 000000000..0e1701bdc --- /dev/null +++ b/packages/extension/src/services/option/__tests__/userStyles.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest" +import { STYLE_VARIABLE } from "@/const" +import { usesIconColor, getDefaultValue } from "../userStyles" + +describe("usesIconColor", () => { + const iconColor = { name: STYLE_VARIABLE.ICON_COLOR, value: "#123456" } + const bgColor = { name: STYLE_VARIABLE.BACKGROUND_COLOR, value: "#123456" } + + it("US-04: 正常系: ダークテーマで色の設定がなければ true を返す", () => { + expect(usesIconColor(undefined, true)).toBe(true) + expect(usesIconColor([], true)).toBe(true) + }) + + it("US-05: 正常系: ダークテーマではアイコン色以外の色が設定されていても true を返す", () => { + expect(usesIconColor([bgColor], true)).toBe(true) + }) + + it("US-06: 正常系: アイコン色が設定されていればテーマに関係なく true を返す", () => { + expect(usesIconColor([iconColor], false)).toBe(true) + expect(usesIconColor([iconColor, bgColor], true)).toBe(true) + }) + + it("US-07: 正常系: ライトテーマでアイコン色の設定がなければ false を返す", () => { + expect(usesIconColor(undefined, false)).toBe(false) + expect(usesIconColor([bgColor], false)).toBe(false) + }) +}) + +describe("getDefaultValue", () => { + it("US-08: 正常系: ライトテーマでは通常の初期値を返す", () => { + expect(getDefaultValue(STYLE_VARIABLE.BACKGROUND_COLOR, false)).toBe( + "#FFFFFF", + ) + expect(getDefaultValue(STYLE_VARIABLE.FONT_COLOR, false)).toBe("#0F172A") + }) + + it("US-09: 正常系: ダークテーマではダーク用の初期値を返す", () => { + expect(getDefaultValue(STYLE_VARIABLE.BACKGROUND_COLOR, true)).toBe( + "#23272F", + ) + expect(getDefaultValue(STYLE_VARIABLE.BORDER_COLOR, true)).toBe("#3C4049") + expect(getDefaultValue(STYLE_VARIABLE.FONT_COLOR, true)).toBe("#E2E8F0") + }) + + it("US-10: 正常系: ダーク用の初期値がない変数はテーマに関係なく同じ値を返す", () => { + expect(getDefaultValue(STYLE_VARIABLE.FONT_SCALE, true)).toBe(1) + expect(getDefaultValue(STYLE_VARIABLE.POPUP_DELAY, true)).toBe(250) + }) +}) diff --git a/packages/extension/src/services/option/defaultSettings.ts b/packages/extension/src/services/option/defaultSettings.ts index 349876927..80c93c3f4 100644 --- a/packages/extension/src/services/option/defaultSettings.ts +++ b/packages/extension/src/services/option/defaultSettings.ts @@ -7,6 +7,7 @@ import { DRAG_OPEN_MODE, STARTUP_METHOD, STYLE, + THEME, LINK_COMMAND_ENABLED, LINK_COMMAND_STARTUP_METHOD, KEYBOARD, @@ -40,6 +41,7 @@ export const emptySettings: SettingsType = { folders: [], pageRules: [], style: STYLE.HORIZONTAL, + theme: THEME.SYSTEM, popupPlacement: { side: SIDE.top, align: ALIGN.start, @@ -149,6 +151,7 @@ export default { ], pageRules: [], style: STYLE.HORIZONTAL, + theme: THEME.SYSTEM, startupMethod: { method: STARTUP_METHOD.TEXT_SELECTION, keepMenuOpenOnFocusChange: false, diff --git a/packages/extension/src/services/option/userStyles.ts b/packages/extension/src/services/option/userStyles.ts index 42c81de81..4e3f5e70a 100644 --- a/packages/extension/src/services/option/userStyles.ts +++ b/packages/extension/src/services/option/userStyles.ts @@ -6,6 +6,8 @@ import type { StyleVariable } from "@/types" type AttributesType = { type: "string" | "number" | "color" default: string | number + /** Default in the dark theme, matching the popup's own dark colors. */ + darkDefault?: string max?: number min?: number step?: number @@ -17,10 +19,12 @@ export const Attributes: AttributeMap = { [STYLE_VARIABLE.BACKGROUND_COLOR]: { type: "color", default: "#FFFFFF", + darkDefault: "#23272F", }, [STYLE_VARIABLE.BORDER_COLOR]: { type: "color", default: "#F3F4F6", + darkDefault: "#3C4049", }, [STYLE_VARIABLE.FONT_SCALE]: { type: "number", @@ -32,10 +36,12 @@ export const Attributes: AttributeMap = { [STYLE_VARIABLE.FONT_COLOR]: { type: "color", default: "#0F172A", + darkDefault: "#E2E8F0", }, [STYLE_VARIABLE.ICON_COLOR]: { type: "color", default: "#0F172A", + darkDefault: "#E2E8F0", }, [STYLE_VARIABLE.IMAGE_SCALE]: { type: "number", @@ -67,6 +73,15 @@ export const Attributes: AttributeMap = { }, } +/** Returns the initial value of a style variable for the current theme. */ +export function getDefaultValue( + name: STYLE_VARIABLE, + isDark: boolean, +): string | number { + const attr = Attributes[name] + return (isDark ? attr.darkDefault : undefined) ?? attr.default +} + /** Style variables that are also exposed as separate HSL components. */ const HSL_COMPONENT_VARIABLES: STYLE_VARIABLE[] = [ STYLE_VARIABLE.BACKGROUND_COLOR, @@ -89,6 +104,19 @@ export function hasUserStyle( return !isEmpty(findUserStyleValue(userStyles, name)) } +/** + * Checks whether the popup paints recolorable icons in an icon color: either + * the user's icon color, or the dark theme's own one. Like the other dark + * defaults, the dark theme's icon color applies even when other colors are + * customized; otherwise the icons keep their own colors. + */ +export function usesIconColor( + userStyles: StyleVariable[] | undefined, + isDark: boolean, +): boolean { + return isDark || hasUserStyle(userStyles, STYLE_VARIABLE.ICON_COLOR) +} + /** Builds the `--sc-*` custom properties the popup and the menu are styled with. */ export function toCssVariables( userStyles: StyleVariable[] | undefined, diff --git a/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts b/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts new file mode 100644 index 000000000..c31bc8cb3 --- /dev/null +++ b/packages/extension/src/services/pageAction/__tests__/dispatcher.input.test.ts @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from "vitest" +import { PageActionDispatcher } from "../dispatcher" +import { SelectorType, PAGE_ACTION_EVENT } from "@/const" + +const codeText = ` const contexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [documentUrl], + })` + +const inputCases = [ + { tag: "textarea", text: codeText }, + { + tag: "input", + text: "[KeyA] [Enter] {Enter} {Control>} [] [[ {{ [ 日本語 \\", + }, +] as const + +describe("PageActionDispatcher literal text input", () => { + afterEach(() => document.body.replaceChildren()) + + for (const source of ["Clipboard", "SelectedText", "literal"] as const) { + for (const { tag, text } of inputCases) { + it(`PDI-LITERAL-${source}-${tag}: 正常系: 括弧を含む${source}を${tag}へそのまま入力する`, async () => { + const element = document.createElement(tag) + element.id = "target" + document.body.append(element) + + const result = await PageActionDispatcher.input({ + type: PAGE_ACTION_EVENT.input, + label: "Target", + selector: "#target", + selectorType: SelectorType.css, + value: source === "literal" ? text : `{{${source}}}`, + clipboardText: source === "Clipboard" ? text : "", + selectedText: source === "SelectedText" ? text : "", + srcUrl: "https://example.com/", + }) + + expect(result).toEqual([true]) + expect(element.value).toBe(text) + }) + } + } +}) diff --git a/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts b/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts index 301fafbae..381f9a4b4 100644 --- a/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts +++ b/packages/extension/src/services/pageAction/__tests__/serviceWorker-execution.test.ts @@ -10,9 +10,9 @@ import { mockReadClipboard, mockMatchesPageActionUrl, mockIncrementCommandExecutionCount, - mockIsEmpty, mockIsUrl, mockIsUrlParam, + mockNeedsClipboard, mockConsole, } from "./serviceWorker-setup" import { openAndRun, preview, stopRunner } from "../serviceWorker" @@ -329,10 +329,10 @@ describe("serviceWorker.ts - Execution Operations", () => { expect(mockOpenTab).not.toHaveBeenCalled() }) - it("SWD-45: Normal case: Clipboard is fetched in CURRENT_TAB mode when useClipboard is true", async () => { + it("SWD-45: Normal case: Clipboard is fetched in CURRENT_TAB mode when the url needs the clipboard", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.CURRENT_TAB, - url: { url: "https://example.com", useClipboard: true }, + url: { url: "https://example.com", clipboardForSteps: true }, commandId: "cmd-1", selectedText: "selected", userVariables: [], @@ -350,6 +350,7 @@ describe("serviceWorker.ts - Execution Operations", () => { url: "https://example.com/page", }) mockIsUrlParam.mockReturnValue(true) + mockNeedsClipboard.mockReturnValue(true) mockReadClipboard.mockResolvedValue({ clipboardText: "clipboard content", err: null, @@ -368,10 +369,10 @@ describe("serviceWorker.ts - Execution Operations", () => { expect(mockIpc.ensureConnection).toHaveBeenCalledWith(555) }) - it("SWD-46: Normal case: Clipboard is NOT fetched in CURRENT_TAB mode when useClipboard is false", async () => { + it("SWD-46: Normal case: Clipboard is NOT fetched in CURRENT_TAB mode when the url does not need the clipboard", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.CURRENT_TAB, - url: { url: "https://example.com", useClipboard: false }, + url: { url: "https://example.com" }, commandId: "cmd-1", selectedText: "selected", userVariables: [], @@ -389,6 +390,7 @@ describe("serviceWorker.ts - Execution Operations", () => { url: "https://example.com/page", }) mockIsUrlParam.mockReturnValue(true) + mockNeedsClipboard.mockReturnValue(false) mockStorage.getCommands.mockResolvedValue([mockCommand]) const result = openAndRun( @@ -403,16 +405,15 @@ describe("serviceWorker.ts - Execution Operations", () => { expect(mockIpc.ensureConnection).toHaveBeenCalledWith(555) }) - it("SWD-44: Normal case: Use clipboardText when selectedText is empty and useClipboard is true", async () => { + it("SWD-44: Normal case: Use clipboardText as selectedText when selectionFromClipboard is true", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.TAB, - url: { url: "https://example.com", useClipboard: true }, + url: { url: "https://example.com", selectionFromClipboard: true }, commandId: "cmd-1", selectedText: "", steps: [{ id: "step-1", param: { type: "click" } }], } - mockIsEmpty.mockReturnValue(true) mockIsUrlParam.mockReturnValue(true) mockOpenTab.mockResolvedValue({ tabId: 456, @@ -433,6 +434,34 @@ describe("serviceWorker.ts - Execution Operations", () => { ) }) + it("SWD-44-b: Normal case: Keep selectedText empty when only the steps use the clipboard", async () => { + const mockParam = { + openMode: PAGE_ACTION_OPEN_MODE.TAB, + url: { url: "https://example.com", clipboardForSteps: true }, + commandId: "cmd-1", + selectedText: "", + steps: [{ id: "step-1", param: { type: "click" } }], + } + + mockIsUrlParam.mockReturnValue(true) + mockOpenTab.mockResolvedValue({ + tabId: 456, + clipboardText: "clipboard content", + }) + + openAndRun(mockParam as any, mockSender as any, mockResponse) + await vi.runAllTimersAsync() + + expect(mockIpc.sendTab).toHaveBeenCalledWith( + 456, + "execPageAction", + expect.objectContaining({ + selectedText: "", + clipboardText: "clipboard content", + }), + ) + }) + it("SWD-47: Error case: When tabId retrieval fails", async () => { const mockParam = { openMode: PAGE_ACTION_OPEN_MODE.TAB, diff --git a/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts b/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts index e4525285d..2b4715b74 100644 --- a/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts +++ b/packages/extension/src/services/pageAction/__tests__/serviceWorker-setup.ts @@ -49,6 +49,7 @@ vi.mock("@/services/pageAction", () => ({ initTab: vi.fn(), clearTab: vi.fn(), }, + getStepTimeout: vi.fn(() => 1000), })) vi.mock("@/services/commandMetrics", () => ({ @@ -63,6 +64,7 @@ vi.mock("@/lib/utils", () => ({ isUrl: vi.fn(), isUrlParam: vi.fn(), matchesPageActionUrl: vi.fn(), + needsClipboard: vi.fn(), sleep: vi.fn(), })) @@ -121,6 +123,7 @@ import { isUrl, isUrlParam, matchesPageActionUrl, + needsClipboard, sleep, } from "@/lib/utils" import { resetLastUrl } from "../serviceWorker" @@ -141,6 +144,7 @@ export const mockIsEmpty = isEmpty as any export const mockIsPageActionCommand = isPageActionCommand as any export const mockIsUrl = isUrl as any export const mockIsUrlParam = isUrlParam as any +export const mockNeedsClipboard = needsClipboard as any export const mockMatchesPageActionUrl = matchesPageActionUrl as any export const mockSleep = sleep as any diff --git a/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts b/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts index 6b6ac7464..75c439dd0 100644 --- a/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts +++ b/packages/extension/src/services/pageAction/__tests__/serviceWorker-sidePanel.test.ts @@ -267,9 +267,13 @@ describe("serviceWorker.ts - Side Panel Connection", () => { ) }) - it("SP-12: Does not call readClipboard when useClipboard is false", async () => { + it("SP-12: Does not call readClipboard when the pending action does not need the clipboard", async () => { const port = createMockPort("https://chatgpt.com") - const pending = { ...createPendingAction(), useClipboard: false } + const pending = { + ...createPendingAction(), + selectionFromClipboard: false, + clipboardForSteps: false, + } mockStorage.get.mockResolvedValue(pending) mockStorage.set.mockResolvedValue(undefined) @@ -278,11 +282,11 @@ describe("serviceWorker.ts - Side Panel Connection", () => { expect(mockReadClipboard).not.toHaveBeenCalled() }) - it("SP-13: Reads clipboard from service worker context when useClipboard is true", async () => { + it("SP-13: Reads clipboard from service worker context when the steps use the clipboard", async () => { const port = createMockPort("https://chatgpt.com") const pending = { ...createPendingAction(), - useClipboard: true, + clipboardForSteps: true, clipboardText: "", } mockStorage.get.mockResolvedValue(pending) @@ -300,6 +304,35 @@ describe("serviceWorker.ts - Side Panel Connection", () => { expect.objectContaining({ command: "execPageAction", param: expect.objectContaining({ + selectedText: "test", + clipboardText: "clipboard content", + }), + }), + ) + }) + + it("SP-13-b: Uses the clipboard text as selectedText when selectionFromClipboard is true", async () => { + const port = createMockPort("https://chatgpt.com") + const pending = { + ...createPendingAction(), + selectedText: "", + selectionFromClipboard: true, + clipboardText: "", + } + mockStorage.get.mockResolvedValue(pending) + mockStorage.set.mockResolvedValue(undefined) + mockReadClipboard.mockResolvedValue({ + clipboardText: "clipboard content", + }) + + await handleSidePanelConnect(port as any) + + expect(mockReadClipboard).toHaveBeenCalledTimes(1) + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + command: "execPageAction", + param: expect.objectContaining({ + selectedText: "clipboard content", clipboardText: "clipboard content", }), }), diff --git a/packages/extension/src/services/pageAction/__tests__/status.test.ts b/packages/extension/src/services/pageAction/__tests__/status.test.ts index 4747788df..ef0e7c61e 100644 --- a/packages/extension/src/services/pageAction/__tests__/status.test.ts +++ b/packages/extension/src/services/pageAction/__tests__/status.test.ts @@ -5,7 +5,14 @@ import type { PageActionStep, } from "@/types" import { PAGE_ACTION_EXEC_STATE as EXEC_STATE } from "@/const" -import { PAGE_ACTION_TIMEOUT as TIMEOUT } from "@/const" +import { + PAGE_ACTION_TIMEOUT as TIMEOUT, + PAGE_ACTION_EVENT, + PAGE_ACTION_CONDITION_ACTION, + PAGE_ACTION_CONDITION_TYPE, + SelectorType, +} from "@/const" +import type { PageAction } from "@/services/pageAction" // Mock dependencies vi.mock("@/services/storage", () => ({ @@ -29,7 +36,7 @@ const mockConsole = { } // Import modules after mocking -import { RunningStatus } from "../status" +import { RunningStatus, getStepTimeout } from "../status" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" // Get reference to mocked Storage @@ -441,3 +448,63 @@ describe("RunningStatus", () => { // Note: RunningStatus now provides multi-tab support by default // All functionality is covered by the test suite above + +describe("getStepTimeout", () => { + const clickStep = ( + param: Partial = {}, + delayMs = 0, + ): PageActionStep => ({ + id: "click", + delayMs, + skipRenderWait: false, + param: { + type: PAGE_ACTION_EVENT.click, + label: "Click", + selector: "button", + selectorType: SelectorType.css, + ...param, + }, + }) + + it("GST-01: returns TIMEOUT plus delay for a step without condition", () => { + expect(getStepTimeout(clickStep({}, 200))).toBe(TIMEOUT + 200) + }) + + it("GST-02: adds the waitUntil condition timeout for a click", () => { + const step = clickStep({ + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, + conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, + selector: "button", + selectorType: SelectorType.css, + timeout: 30_000, + }, + }) + expect(getStepTimeout(step)).toBe(TIMEOUT + 30_000) + }) + + it("GST-03: falls back to TIMEOUT when the condition has no timeout", () => { + const step = clickStep({ + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, + conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, + selector: "button", + selectorType: SelectorType.css, + }, + }) + expect(getStepTimeout(step)).toBe(TIMEOUT * 2) + }) + + it("GST-04: ignores a skip condition", () => { + const step = clickStep({ + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.skip, + conditionType: PAGE_ACTION_CONDITION_TYPE.empty, + selector: "textarea", + selectorType: SelectorType.css, + timeout: 30_000, + }, + }) + expect(getStepTimeout(step)).toBe(TIMEOUT) + }) +}) diff --git a/packages/extension/src/services/pageAction/dispatcher.ts b/packages/extension/src/services/pageAction/dispatcher.ts index 85339ea9d..b1e455590 100644 --- a/packages/extension/src/services/pageAction/dispatcher.ts +++ b/packages/extension/src/services/pageAction/dispatcher.ts @@ -139,7 +139,8 @@ export const PageActionDispatcher = { if (param.clearBefore) clearInput(element) await inputContentEditable(element, value, 10, null) } else { - value = value.replace(/{/g, "{{") // escape + // Treat input text literally instead of user-event keyboard descriptors. + value = value.replaceAll("{", "{{").replaceAll("[", "[[") const isTextControl = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement diff --git a/packages/extension/src/services/pageAction/index.ts b/packages/extension/src/services/pageAction/index.ts index 9186965c0..3a977eac9 100644 --- a/packages/extension/src/services/pageAction/index.ts +++ b/packages/extension/src/services/pageAction/index.ts @@ -2,7 +2,7 @@ export { PageActionListener } from "./listener" export { PageActionDispatcher } from "./dispatcher" export { BackgroundTabPageActionDispatcher } from "./backgroundTabDispatcher" export type { PageAction } from "./dispatcher" -export { RunningStatus } from "./status" +export { RunningStatus, getStepTimeout } from "./status" export * from "./helper" export { PAGE_HTML_MAX_CHARS } from "@/const" export * from "./insertSymbols" diff --git a/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts b/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts index 65ffd4a38..f3a61c9b3 100644 --- a/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts +++ b/packages/extension/src/services/pageAction/serviceWorker-sidePanel.ts @@ -9,10 +9,10 @@ import { PAGE_ACTION_CONTROL, PAGE_ACTION_EVENT, PAGE_ACTION_OPEN_MODE, - PAGE_ACTION_TIMEOUT as TIMEOUT, } from "@/const" import { generateRandomID, sleep } from "@/lib/utils" import type { PageActionStep } from "@/types" +import { getStepTimeout } from "./status" /** * Run page action steps through a long-lived port connection. @@ -75,7 +75,7 @@ export const runViaPort = ( port.onMessage.removeListener(onMsg) reject(new Error(`Timeout executing step: ${step.id}`)) }, - TIMEOUT + (step.delayMs ?? 0), + getStepTimeout(step), ) const onMsg = (msg: Record) => { @@ -194,13 +194,16 @@ const runPendingSidePanelAction = async ( return } await Storage.set(SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING, null) - if (pending.useClipboard) { + if (pending.selectionFromClipboard || pending.clipboardForSteps) { try { const clipboard = await readClipboard() pending.clipboardText = clipboard.clipboardText } catch (e) { console.warn("Failed to read clipboard in background:", e) } + if (pending.selectionFromClipboard) { + pending.selectedText = pending.clipboardText + } } runViaPort(port, pending) } diff --git a/packages/extension/src/services/pageAction/serviceWorker.ts b/packages/extension/src/services/pageAction/serviceWorker.ts index a13a5a4b8..06ae97bf1 100644 --- a/packages/extension/src/services/pageAction/serviceWorker.ts +++ b/packages/extension/src/services/pageAction/serviceWorker.ts @@ -7,7 +7,7 @@ import { } from "@/services/ipc" import { Storage, SESSION_STORAGE_KEY } from "@/services/storage" import type { PageAction } from "@/services/pageAction" -import { RunningStatus } from "@/services/pageAction" +import { RunningStatus, getStepTimeout } from "@/services/pageAction" import { getScreenSize, ScreenSize } from "@/services/screen" import { openPopupWindow, @@ -15,6 +15,7 @@ import { getCurrentTab, OpenPopupProps, readClipboard, + showClipboardErrorToast, } from "@/services/chrome" import { incrementCommandExecutionCount } from "@/services/commandMetrics" import { @@ -24,14 +25,13 @@ import { PAGE_ACTION_OPEN_MODE, PAGE_ACTION_EXEC_STATE as EXEC_STATE, PAGE_ACTION_EVENT as EVENT, - PAGE_ACTION_TIMEOUT as TIMEOUT, } from "@/const" import { generateRandomID, - isEmpty, isUrl, isUrlParam, matchesPageActionUrl, + needsClipboard, sleep, } from "@/lib/utils" import type { @@ -40,9 +40,7 @@ import type { PageActionContext, PopupOption, DeepPartial, - ShowToastParam, } from "@/types" -import { t } from "@/services/i18n" import { ServiceWorkerData } from "@/services/serviceWorkerData" ServiceWorkerData.init() @@ -382,15 +380,11 @@ export const openAndRun = ( tabId = currentTab.id // Get clipboard text only when needed. - if (isUrlParam(param.url) && param.url.useClipboard) { + if (needsClipboard(param.url)) { const clipboard = await readClipboard() clipboardText = clipboard.clipboardText if (clipboard.err) { - await Ipc.sendTab(tabId, TabCommand.showToast, { - title: t("clipboard_error_title"), - description: t("clipboard_error_description"), - action: t("clipboard_error_action"), - }) + await showClipboardErrorToast(tabId) } } } else { @@ -412,13 +406,7 @@ export const openAndRun = ( return } - // Use clipboard text if selected text is empty and useClipboard is true. - // This is for the case for shortcut key. - if ( - isEmpty(selectedText) && - isUrlParam(param.url) && - param.url.useClipboard - ) { + if (isUrlParam(param.url) && param.url.selectionFromClipboard) { selectedText = clipboardText } @@ -517,7 +505,7 @@ const run = ( step.id, EXEC_STATE.Doing, "", - TIMEOUT + delay, + getStepTimeout(step), ) // Wait for the delay time diff --git a/packages/extension/src/services/pageAction/status.ts b/packages/extension/src/services/pageAction/status.ts index a692fd7e8..7fd3b3afa 100644 --- a/packages/extension/src/services/pageAction/status.ts +++ b/packages/extension/src/services/pageAction/status.ts @@ -6,7 +6,34 @@ import type { PageActionStep, } from "@/types" import type { PageAction } from "@/services/pageAction" -import { PAGE_ACTION_TIMEOUT as TIMEOUT, PAGE_ACTION_CONTROL } from "@/const" +import { + PAGE_ACTION_TIMEOUT as TIMEOUT, + PAGE_ACTION_CONTROL, + PAGE_ACTION_EVENT, + PAGE_ACTION_CONDITION_ACTION, +} from "@/const" + +const CLICK_TYPES: string[] = [ + PAGE_ACTION_EVENT.click, + PAGE_ACTION_EVENT.doubleClick, + PAGE_ACTION_EVENT.tripleClick, +] + +/** + * Upper bound of how long a step may take to execute, including its delay. + * A click with a waitUntil condition first polls the condition (up to its own + * timeout) and then waits for the target element (up to TIMEOUT), so both + * waits are added together. + */ +export const getStepTimeout = (step: PageActionStep): number => { + const base = TIMEOUT + (step.delayMs ?? 0) + if (!CLICK_TYPES.includes(step.param.type)) return base + const condition = (step.param as PageAction.Click).condition + if (condition?.actionType !== PAGE_ACTION_CONDITION_ACTION.waitUntil) { + return base + } + return base + (condition.timeout ?? TIMEOUT) +} /** * RunningStatus manages the execution status of page actions across multiple tabs. diff --git a/packages/extension/src/services/searchUrlAssist.ts b/packages/extension/src/services/searchUrlAssist.ts index 0503e9b7a..8c0b6182c 100644 --- a/packages/extension/src/services/searchUrlAssist.ts +++ b/packages/extension/src/services/searchUrlAssist.ts @@ -3,6 +3,9 @@ import { OPEN_MODE, PAGE_ACTION_CONTROL, PAGE_ACTION_EVENT, + PAGE_ACTION_CONDITION_ACTION, + PAGE_ACTION_CONDITION_TYPE, + PAGE_ACTION_TIMEOUT, SelectorType, } from "@/const" import type { AiService } from "@/types" @@ -18,6 +21,10 @@ const PROMPT_TEMPLATE = // ends up with an empty selector. const FALLBACK_COPY_SELECTOR = "copy-button button" +// The copy button only appears after Gemini finishes generating the answer, +// which often takes longer than the default PAGE_ACTION_TIMEOUT. +const COPY_WAIT_TIMEOUT = PAGE_ACTION_TIMEOUT * 6 + /** * Build the Search URL Assist PageAction command for Gemini. * Input/submit selectors are taken from `service` (sourced from @@ -97,6 +104,13 @@ export const createSearchUrlAssistAction = (service: AiService) => { selector: copySelector, selectorType: SelectorType.css, type: PAGE_ACTION_EVENT.click, + condition: { + actionType: PAGE_ACTION_CONDITION_ACTION.waitUntil, + conditionType: PAGE_ACTION_CONDITION_TYPE.clickable, + selector: copySelector, + selectorType: SelectorType.css, + timeout: COPY_WAIT_TIMEOUT, + }, }, skipRenderWait: false, }, diff --git a/packages/extension/src/services/storage/const.ts b/packages/extension/src/services/storage/const.ts index c0463b3fa..525378be5 100644 --- a/packages/extension/src/services/storage/const.ts +++ b/packages/extension/src/services/storage/const.ts @@ -24,7 +24,6 @@ export enum LOCAL_STORAGE_KEY { export enum SESSION_STORAGE_KEY { SERVICE_WORKER_DATA = "serviceWorkerData", - SELECTION_TEXT = "selectionText ", SESSION_DATA = "sessionData", MESSAGE_QUEUE = "messageQueue", TMP_CAPTURES = "tmpCaptures", diff --git a/packages/extension/src/services/storage/index.ts b/packages/extension/src/services/storage/index.ts index 9339ae985..46b0a2cf5 100644 --- a/packages/extension/src/services/storage/index.ts +++ b/packages/extension/src/services/storage/index.ts @@ -105,7 +105,6 @@ const DEFAULTS = { [SESSION_STORAGE_KEY.PA_CONTEXT]: {}, [SESSION_STORAGE_KEY.PA_RECORDER_OPTION]: {}, [SESSION_STORAGE_KEY.TMP_CAPTURES]: {}, - [SESSION_STORAGE_KEY.SELECTION_TEXT]: "", [SESSION_STORAGE_KEY.PA_SIDE_PANEL_PENDING]: null, [SESSION_STORAGE_KEY.DEV_TOOLS_ENABLED]: false, } as const diff --git a/packages/extension/src/services/uuid.ts b/packages/extension/src/services/uuid.ts deleted file mode 100644 index 945a010ab..000000000 --- a/packages/extension/src/services/uuid.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - isSearchCommand, - isPageActionCommand, - normalizeObject, -} from "@/lib/utils" -import { v5 as uuidv5 } from "uuid" -import type { CommandContent } from "@/types/command" - -/** - * Generate UUID from object, using UUIDv5. - * @param obj Object to generate UUID from. - * @returns UUID. - */ -async function generateUUIDFromObject(obj: object): Promise { - const objString = JSON.stringify(normalizeObject(obj)) - const encoder = new TextEncoder() - const data = encoder.encode(objString) - const hashBuffer = await crypto.subtle.digest("SHA-1", data) - const hashArray = Array.from(new Uint8Array(hashBuffer)) - const hash = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") - // UUIDv5 from https://ujiro99.github.io/selection-command/ - const namespace = "fe352db3-6a8e-5d07-9aaf-c45a2e9d9f5c" - return uuidv5(hash, namespace) -} - -export async function cmd2uuid(cmd: CommandContent): Promise { - if (isSearchCommand(cmd)) { - return await generateUUIDFromObject({ - title: cmd.title, - searchUrl: cmd.searchUrl, - iconUrl: cmd.iconUrl, - openMode: cmd.openMode, - openModeSecondary: cmd.openModeSecondary, - spaceEncoding: cmd.spaceEncoding, - }) - } else if (isPageActionCommand(cmd)) { - return await generateUUIDFromObject({ - title: cmd.title, - iconUrl: cmd.iconUrl, - openMode: cmd.openMode, - pageActionOption: cmd.pageActionOption, - }) - } else { - throw new Error("Invalid command") - } -} diff --git a/packages/extension/src/testIds.ts b/packages/extension/src/testIds.ts index 75c427fca..082891c96 100644 --- a/packages/extension/src/testIds.ts +++ b/packages/extension/src/testIds.ts @@ -17,6 +17,7 @@ export const TEST_IDS = { submitCommandButton: "submit-command-button", shareCommandButton: "share-command-button", sharedCommandButton: "shared-command-button", + modifiedSharedCommandButton: "modified-shared-command-button", commandType: (type: string) => `command-type-${type}`, recButton: "rec-button", pageActionCompleteButton: "page-action-complete-button", diff --git a/packages/extension/src/types/__tests__/schema.test.ts b/packages/extension/src/types/__tests__/schema.test.ts index 016d02a99..e8f84ef67 100644 --- a/packages/extension/src/types/__tests__/schema.test.ts +++ b/packages/extension/src/types/__tests__/schema.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest" -import { AiPromptOptionSchema, commandSchema, folderSchema } from "../schema" +import { + AiPromptOptionSchema, + commandSchema, + folderSchema, + normalizeCommandTitle, +} from "../schema" import { INSERT, toInsertTemplate } from "@/services/pageAction" import { OPEN_MODE } from "@shared/constants/open-mode" import { PAGE_ACTION_OPEN_MODE, SPACE_ENCODING } from "@/const" @@ -268,3 +273,119 @@ describe("PageActionOption in schemas", () => { } }) }) + +describe("normalizeCommandTitle", () => { + it("SC-19: trims and collapses whitespace runs into one ASCII space", () => { + expect(normalizeCommandTitle(" Google ")).toBe("Google") + expect(normalizeCommandTitle("Google Search")).toBe("Google Search") + expect(normalizeCommandTitle("Google\u3000検索")).toBe("Google 検索") + expect(normalizeCommandTitle("A\t\nB")).toBe("A B") + expect(normalizeCommandTitle("\u3000\u3000検索\u3000")).toBe("検索") + expect(normalizeCommandTitle("Google 検索")).toBe("Google 検索") + }) +}) + +describe("command title in schemas", () => { + const searchCmd = (title: string) => ({ + id: "test-cmd", + openMode: OPEN_MODE.POPUP, + title, + iconUrl: "https://example.com/icon.png", + searchUrl: "https://example.com/search?q=%s", + openModeSecondary: OPEN_MODE.TAB, + spaceEncoding: SPACE_ENCODING.PLUS, + }) + + it("SC-20: accepts a two-character title", () => { + const res = commandSchema.safeParse(searchCmd("検索")) + expect(res.success).toBe(true) + }) + + it("SC-21: rejects a one-character title", () => { + const res = commandSchema.safeParse(searchCmd("a")) + expect(res.success).toBe(false) + if (!res.success) { + expect(res.error.issues[0].path).toEqual(["title"]) + } + }) + + it("SC-25: accepts a 100-character title and rejects a 101-character one", () => { + expect(commandSchema.safeParse(searchCmd("a".repeat(100))).success).toBe( + true, + ) + const res = commandSchema.safeParse(searchCmd("a".repeat(101))) + expect(res.success).toBe(false) + if (!res.success) { + expect(res.error.issues[0].path).toEqual(["title"]) + } + }) + + it("SC-26: checks the max length after normalization", () => { + const res = commandSchema.safeParse(searchCmd(` ${"a".repeat(100)} `)) + expect(res.success).toBe(true) + }) + + it("SC-22: returns the normalized title", () => { + const res = commandSchema.safeParse(searchCmd(" Google\u3000\u3000検索 ")) + expect(res.success).toBe(true) + if (res.success) { + expect(res.data.title).toBe("Google 検索") + } + }) + + it("SC-23: checks the title length after normalization", () => { + const res = commandSchema.safeParse(searchCmd(" a\u3000 ")) + expect(res.success).toBe(false) + }) + + it("SC-24: keeps the default title when it is omitted", () => { + const res = commandSchema.safeParse({ + id: "copy-cmd", + openMode: OPEN_MODE.COPY, + iconUrl: "https://example.com/icon.png", + }) + expect(res.success).toBe(true) + if (res.success) { + expect(res.data.title).toBe("Copy text") + } + }) +}) + +describe("searchUrl in schemas", () => { + const searchCmd = (searchUrl: string) => ({ + id: "test-cmd", + openMode: OPEN_MODE.POPUP, + title: "Search", + iconUrl: "https://example.com/icon.png", + searchUrl, + openModeSecondary: OPEN_MODE.TAB, + spaceEncoding: SPACE_ENCODING.PLUS, + }) + + const geminiLink = + "[https://example.com/search?q=%s](https://www.google.com/search?q=https://example.com/search?q%3D%25s)" + + it("SC-27: accepts a Gemini markdown link and unwraps it to the URL", () => { + const res = commandSchema.safeParse(searchCmd(geminiLink)) + expect(res.success).toBe(true) + if (res.success && "searchUrl" in res.data) { + expect(res.data.searchUrl).toBe("https://example.com/search?q=%s") + } + }) + + it("SC-28: accepts a Gemini markdown link for an API command", () => { + const res = commandSchema.safeParse({ + id: "api-cmd", + openMode: OPEN_MODE.API, + title: "API", + iconUrl: "https://example.com/icon.png", + searchUrl: geminiLink, + }) + expect(res.success).toBe(true) + }) + + it("SC-29: still rejects a value that is not a URL", () => { + const res = commandSchema.safeParse(searchCmd("not a url")) + expect(res.success).toBe(false) + }) +}) diff --git a/packages/extension/src/types/index.ts b/packages/extension/src/types/index.ts index 83d0906fe..befd8caa3 100644 --- a/packages/extension/src/types/index.ts +++ b/packages/extension/src/types/index.ts @@ -6,6 +6,7 @@ import type { ALIGN, STYLE, FOLDER_STYLE, + THEME, KEYBOARD, STARTUP_METHOD, SPACE_ENCODING, @@ -206,6 +207,8 @@ export type UserSettings = { folders: Array pageRules: Array style: STYLE + /** Color theme. Settings saved before this option existed lack it. */ + theme?: THEME userStyles: Array shortcuts: ShortcutSettings windowOption: WindowOption @@ -249,15 +252,21 @@ export type ExecuteCommandParams = { target?: Element | null useSecondary?: boolean /** - * Allows the clipboard text to be used in place of an empty selection - * (e.g. a shortcut key run without a selection). Each action decides whether - * it actually needs the clipboard, and requests it via UrlParam.useClipboard. + * Use the clipboard text as the selected text. This is decided only once, + * where the command is triggered (e.g. a shortcut key run without a + * selection), so selectionText is always empty when this is true and + * actions must not check selectionText again. */ - allowClipboardFallback?: boolean + selectionFromClipboard?: boolean changeState?: (state: ExecState, message?: string) => void pageUrl?: string } +/** Parameters each action receives. The executor fills in the defaults. */ +export type ActionExecuteParams = ExecuteCommandParams & { + selectionFromClipboard: boolean +} + export type ActionTypes = PAGE_ACTION_EVENT | PAGE_ACTION_CONTROL export type PageActionStep = { id: string @@ -321,8 +330,17 @@ export type UrlParam = { searchUrl: string selectionText: string spaceEncoding?: SPACE_ENCODING - useClipboard?: boolean pageUrl?: string + /** + * Replace the selected text in searchUrl with the clipboard text. + * See ExecuteCommandParams.selectionFromClipboard. + */ + selectionFromClipboard?: boolean + /** + * Set when page action steps reference {{Clipboard}}. The clipboard text is + * read when opening the page and passed to the steps. + */ + clipboardForSteps?: boolean /** * Set when selectionText is a prompt template (AI prompt query URL) that * still contains {{Clipboard}} / {{SelectedText}} placeholders. They are diff --git a/packages/extension/src/types/schema.ts b/packages/extension/src/types/schema.ts index 51137498a..cbc4161f1 100644 --- a/packages/extension/src/types/schema.ts +++ b/packages/extension/src/types/schema.ts @@ -20,11 +20,44 @@ import { } from "@/const" import { t } from "@/services/i18n" -import { isEmpty, isReservedVariableName } from "@/lib/utils" +import { isEmpty, isReservedVariableName, parseGeminiUrl } from "@/lib/utils" import { SEARCH_OPEN_MODE } from "@shared/constants/open-mode" import { countHtmlAttachments } from "@/services/pageAction" import type { AiPromptCommand } from "@/types" +export const COMMAND_TITLE_MIN_LENGTH = 2 +export const COMMAND_TITLE_MAX_LENGTH = 100 + +/** + * Normalizes a command title: trims both ends and collapses every whitespace + * run (including full-width spaces, tabs and newlines) into one ASCII space. + * Must match the Hub's normalization so both sides store the same title. + */ +export const normalizeCommandTitle = (title: string): string => + title.trim().replace(/\s+/g, " ") + +// Length is checked after normalization, and the normalized title is saved. +// Gemini returns URLs as markdown links; unwrap them before validating so a +// pasted Gemini answer is accepted while typing, not only after blur. +const searchUrlSchema = z.preprocess( + (v) => (typeof v === "string" ? parseGeminiUrl(v) : v), + z.string().url({ message: t("Option_zod_url") }), +) + +const commandTitleSchema = z + .string() + .transform(normalizeCommandTitle) + .pipe( + z + .string() + .min(COMMAND_TITLE_MIN_LENGTH, { + message: t("Option_zod_string_min", [String(COMMAND_TITLE_MIN_LENGTH)]), + }) + .max(COMMAND_TITLE_MAX_LENGTH, { + message: t("Option_zod_string_max", [String(COMMAND_TITLE_MAX_LENGTH)]), + }), + ) + const commandSourceSchema = { sourceType: z.nativeEnum(COMMAND_SOURCE_TYPE).optional(), sourceId: z.string().optional(), @@ -37,12 +70,12 @@ const commandSourceSchema = { const searchSchema = z.object({ openMode: z.enum(SEARCH_OPEN_MODE), id: z.string(), - title: z.string().min(3, { message: t("Option_zod_string_min", ["3"]) }), + title: commandTitleSchema, iconUrl: z .string() .url({ message: t("Option_zod_url") }) .max(1000, { message: t("Option_zod_string_max", ["1000"]) }), - searchUrl: z.string().url({ message: t("Option_zod_url") }), + searchUrl: searchUrlSchema, parentFolderId: z.string().optional(), openModeSecondary: z.enum(SEARCH_OPEN_MODE), spaceEncoding: z.nativeEnum(SPACE_ENCODING), @@ -73,12 +106,12 @@ export const isSearchType = (data: unknown): data is SearchType => { const apiSchema = z.object({ openMode: z.literal(OPEN_MODE.API), id: z.string(), - title: z.string().min(3, { message: t("Option_zod_string_min", ["3"]) }), + title: commandTitleSchema, iconUrl: z .string() .url({ message: t("Option_zod_url") }) .max(1000, { message: t("Option_zod_string_max", ["1000"]) }), - searchUrl: z.string().url({ message: t("Option_zod_url") }), + searchUrl: searchUrlSchema, parentFolderId: z.string().optional(), fetchOptions: z.string().optional(), ...commandSourceSchema, @@ -99,10 +132,7 @@ const linkPopupSchema = z.object({ id: z.string(), parentFolderId: z.string().optional(), ...commandSourceSchema, - title: z - .string() - .min(3, { message: t("Option_zod_string_min", ["3"]) }) - .default("Link Popup"), + title: commandTitleSchema.default("Link Popup"), iconUrl: z .string() .url({ message: t("Option_zod_url") }) @@ -121,10 +151,7 @@ const copySchema = z.object({ id: z.string(), parentFolderId: z.string().optional(), ...commandSourceSchema, - title: z - .string() - .min(3, { message: t("Option_zod_string_min", ["3"]) }) - .default("Copy text"), + title: commandTitleSchema.default("Copy text"), iconUrl: z .string() .url({ message: t("Option_zod_url") }) @@ -140,10 +167,7 @@ const textStyleSchema = z.object({ id: z.string(), parentFolderId: z.string().optional(), ...commandSourceSchema, - title: z - .string() - .min(3, { message: t("Option_zod_string_min", ["3"]) }) - .default("Get Text Styles"), + title: commandTitleSchema.default("Get Text Styles"), iconUrl: z .string() .url({ message: t("Option_zod_url") }) @@ -309,7 +333,7 @@ const pageActionSchema = z.object({ id: z.string(), parentFolderId: z.string().optional(), ...commandSourceSchema, - title: z.string().min(3, { message: t("Option_zod_string_min", ["3"]) }), + title: commandTitleSchema, iconUrl: z .string() .url({ message: t("Option_zod_url") }) @@ -356,7 +380,7 @@ const aiPromptSchema = z.object({ id: z.string(), parentFolderId: z.string().optional(), ...commandSourceSchema, - title: z.string().min(3, { message: t("Option_zod_string_min", ["3"]) }), + title: commandTitleSchema, iconUrl: z .string() .url({ message: t("Option_zod_url") }) diff --git a/packages/extension/tailwind.config.js b/packages/extension/tailwind.config.js deleted file mode 100644 index b38758149..000000000 --- a/packages/extension/tailwind.config.js +++ /dev/null @@ -1,153 +0,0 @@ -const baseConfig = require("../../tailwind.config") - -/** @type {import('tailwindcss').Config} */ -module.exports = { - ...baseConfig, - content: ["./src/**/*.{ts,tsx}"], - theme: { - ...baseConfig.theme, - extend: { - ...baseConfig.theme.extend, - fontSize: { - xs: "12px", - sm: "14px", - }, - keyframes: { - ...baseConfig.theme.extend.keyframes, - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: "0" }, - }, - marquee: { - "0%": { transform: "translateX(0%)" }, - "100%": { transform: "translateX(-100%)" }, - }, - marquee2: { - "0%": { transform: "translateX(100%)" }, - "100%": { transform: "translateX(0%)" }, - }, - popup: { - "0%": { - opacity: "0", - transform: "scale(0.5) translate(-10px, 10px)", - }, - "100%": { - opacity: "1", - transform: "scale(1)", - }, - }, - // Onboarding-only keyframes. Namespaced with an `onboarding-` prefix - // (see also the `onboarding-` animation names below) so they read as - // a related set alongside the rest of this file's keyframes. - "onboarding-rise": { - from: { opacity: "0", transform: "translateY(10px)" }, - to: { opacity: "1", transform: "translateY(0)" }, - }, - // Linear-style enter/exit used by the variant C welcome overlay: - // content resolves out of a blur instead of just sliding up. - "onboarding-blur-in": { - from: { - opacity: "0", - filter: "blur(8px)", - transform: "translateY(6px)", - }, - to: { opacity: "1", filter: "blur(0px)", transform: "translateY(0)" }, - }, - "onboarding-blur-out": { - from: { opacity: "1", filter: "blur(0px)" }, - to: { opacity: "0", filter: "blur(8px)" }, - }, - // Lets the welcome overlay's background glow bloom outward from the - // center of the screen instead of just fading in. - "onboarding-glow-in": { - from: { opacity: "0", transform: "scale(0.6)" }, - to: { opacity: "1", transform: "scale(1)" }, - }, - "onboarding-ring": { - "0%, 100%": { boxShadow: "0 0 0 0 var(--onboarding-ring-color)" }, - "50%": { boxShadow: "0 0 0 6px var(--onboarding-ring-color)" }, - }, - "onboarding-blink": { - "0%, 100%": { opacity: "1" }, - "50%": { opacity: "0.45" }, - }, - // Drives the selection-demo highlight span: fades a selection-blue - // background in, holds, then fades it out, looping. - "onboarding-highlight": { - "0%, 10%": { backgroundColor: "rgb(191 219 254 / 0)" }, - "44%, 70%": { backgroundColor: "rgb(191 219 254 / 1)" }, - "82%, 100%": { backgroundColor: "rgb(191 219 254 / 0)" }, - }, - // The one-shot scale-in used for the the completion screen's party-popper icon. - "onboarding-pop": { - "0%": { opacity: "0", transform: "scale(0.72)" }, - "100%": { opacity: "1", transform: "scale(1)" }, - }, - "onboarding-pop-2": { - "0%": { - opacity: "0", - transform: "scale(0.72) translate(-10px, 10px)", - }, - "100%": { opacity: "1", transform: "scale(1)" }, - }, - // Gentle vertical bob used to draw extra attention to the onboarding - // callout bubble. Uses the standalone `translate` property (not - // `transform`) so it composes with, instead of overriding, the - // `transform` Radix Popper uses to rotate/position PopoverArrow - - // both the bubble and its arrow share this animation and must move - // in sync. - "onboarding-float": { - "0%, 100%": { translate: "0 0" }, - "50%": { translate: "0 -4px" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - marquee: "marquee var(--marquee-duration) linear infinite", - marquee2: "marquee2 var(--marquee-duration) linear infinite", - "spin-slow": "spin 3s linear infinite", - popup: "popup 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both", - "onboarding-rise": - "onboarding-rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both", - "onboarding-blur-in": - "onboarding-blur-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) both", - "onboarding-blur-out": - "onboarding-blur-out 0.32s cubic-bezier(0.4, 0, 1, 1) both", - "onboarding-glow-in": - "onboarding-glow-in 1.6s cubic-bezier(0.16, 1, 0.3, 1) both", - "onboarding-ring": "onboarding-ring 2.4s ease-in-out 0.6s infinite", - "onboarding-blink": "onboarding-blink 1.5s ease-in-out 0.3s infinite", - "onboarding-highlight": "onboarding-highlight 4s ease-in-out infinite", - "onboarding-pop": - "onboarding-pop 0.36s cubic-bezier(0.34, 1.4, 0.64, 1) both", - "onboarding-pop-2": - "onboarding-pop-2 0.36s cubic-bezier(0.34, 1.4, 0.64, 1) both", - "onboarding-float": "onboarding-float 1.4s ease-in-out infinite", - }, - padding: { - 0.5: "2px", - 1: "4px", - 1.5: "6px", - 2: "8px", - }, - translate: { - 2: "8px", - 2.5: "10px", - }, - minWidth: { - 2: "8px", - 3: "12px", - 4: "16px", - }, - listStyleType: { - square: "square", - circle: "circle", - }, - }, - }, -} diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 1a101b89a..be5de2dcf 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -2,6 +2,7 @@ import fs from "fs" import path from "path" import { defineConfig, loadEnv } from "vite" import react from "@vitejs/plugin-react" +import tailwindcss from "@tailwindcss/vite" import { crx } from "@crxjs/vite-plugin" import { sentryVitePlugin } from "@sentry/vite-plugin" import manifest from "./manifest.json" @@ -12,6 +13,7 @@ import refreshLocales from "./src/lib/vite-plugin-refresh-locales" import packageJson from "./package.json" import { importIfPlugin } from "./src/lib/vite-plugin-import-if" import { CONTENT_SCRIPT_CSS_PLACEHOLDER } from "./src/lib/contentScriptCss" +import shadowDomProperties from "./src/lib/postcss-shadow-dom-properties" const shouldUploadSourcemaps = process.env.UPLOAD_SOURCEMAP_TO_SENTRY === "true" @@ -48,6 +50,7 @@ export default defineConfig(({ mode }) => { const plugins = [ react(), + tailwindcss(), crx({ manifest: activeManifest }), ...importIfPlugin({ mode }), viteTouchCss({ @@ -139,6 +142,13 @@ export default defineConfig(({ mode }) => { CONTENT_SCRIPT_CSS_PLACEHOLDER, ]), }, + css: { + postcss: { + // Tailwind CSS v4 relies on `@property`, which Chrome ignores inside + // the content script's Shadow DOM. See postcss-shadow-dom-properties. + plugins: [shadowDomProperties()], + }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), @@ -171,7 +181,7 @@ export default defineConfig(({ mode }) => { emptyOutDir: !isWatchMode, // prevent deleting the dist folder when running in watch mode rollupOptions: { input: { - clipboard: "src/clipboard.html", + offscreenClipboard: "src/offscreen_clipboard.html", onboarding: "src/onboarding_page.html", }, }, diff --git a/packages/hub/AGENTS.md b/packages/hub/AGENTS.md index d0bc12a61..99a478671 100644 --- a/packages/hub/AGENTS.md +++ b/packages/hub/AGENTS.md @@ -43,7 +43,7 @@ This file provides guidance to AI Agent when working with code in this repositor "": { "enabled": true, "allocation": 0.5 } } ``` - `allocation` は treatment 側の variant(現在の `onboarding_v3` では variant C)に + `allocation` は treatment 側の variant(現在の `onboarding_v4` では variant D。対照群は variant C)に 割り当てるユーザーの比率(0..1) - Extension 側には hub 障害時用のフォールバック値が `packages/extension/src/services/experiments/experimentConfig.ts` の @@ -75,8 +75,9 @@ This file provides guidance to AI Agent when working with code in this repositor - `src/app/[lang]/layout.tsx`, `src/app/layout.tsx` - App Router の必須レイアウト(Header/Footer/LanguageProvider を保持) - `src/features/locale/` - 14言語分の辞書ファイル(layout.tsx が 無条件にバレルインポートするため削除できない) -- `src/components/ui/` - shadcn/ui のうち button, separator, select のみ残存 -- `src/lib/utils.ts` - 共通ユーティリティ(`cn` 等、`@shared` からの re-export) +- `src/components/MovedNotice.tsx` - ルートページと 404 ページで共有する移転案内 +- `src/lib/utils.ts` - 共通ユーティリティ(`@shared` からの re-export 等) +- `src/app/globals.css` + 各 `*.module.css` - スタイル(素の CSS のみ。下記参照) ## 削除済みの機能 @@ -97,3 +98,15 @@ Google Analytics 連携・お問い合わせフォーム等は、全て新リポ 壊れないことを確認すること - `src/app/[lang]/test/page.tsx` を変更する場合は、`packages/extension/e2e/` の 対応する spec が壊れないことを確認すること(本番反映後の e2e 実行で最終確認) + - デプロイ前の確認は、`yarn build` した `out/` をローカルで配信し、 + `E2E_HUB_BASE_URL` を指定して extension の e2e を実行する + (例: `python3 -m http.server 3100 -d packages/hub/out` → + `E2E_HUB_BASE_URL=http://localhost:3100 yarn test:e2e`)。 + `ujiro99.github.io` へのリクエストがローカルへ転送される + (`packages/extension/e2e/utils/hubRoute.ts`)。ただし拡張機能が自ら開く + ウィンドウ(PageAction の録画画面など)は転送できず、デプロイ済みのページが表示される +- スタイリングは素の CSS(`globals.css` と CSS Modules)のみで行う。 + Tailwind CSS や shadcn/ui などの CSS フレームワーク・UI ライブラリは導入しない + (テストページと設定値のホスティングのみが目的で、導入するメリットがないため) +- テストページの DOM 構造(見出し、`data-testid`、`placeholder`、文言など)は + e2e が参照しているため、スタイル変更時も維持すること diff --git a/packages/hub/components.json b/packages/hub/components.json deleted file mode 100644 index a5577c5db..000000000 --- a/packages/hub/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "src/app/globals.css", - "baseColor": "stone", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "iconLibrary": "lucide" -} diff --git a/packages/hub/package.json b/packages/hub/package.json index f86808553..7bc28beef 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -14,17 +14,11 @@ "pretty-quick": "pretty-quick" }, "dependencies": { - "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-separator": "^1.1.1", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "lucide-react": "^1.24.0", "next": "^15.1.0", "quill": "^2.0.3", "react": "^19.0.0", - "react-dom": "^19.0.0", - "tailwind-merge": "^2.5.5", - "tailwindcss-animate": "^1.0.7" + "react-dom": "^19.0.0" }, "devDependencies": { "@next/eslint-plugin-next": "^15.1.0", @@ -40,9 +34,7 @@ "@vitest/ui": "^3.2.4", "eslint-config-next": "^15.1.0", "jsdom": "^26.1.0", - "postcss": "^8", "pretty-quick": "^4.2.2", - "tailwindcss": "^3.4.1", "typescript": "^5", "typescript-eslint": "^8.18.2", "vite": "^6.0.0" diff --git a/packages/hub/postcss.config.mjs b/packages/hub/postcss.config.mjs deleted file mode 100644 index 0dc456ad9..000000000 --- a/packages/hub/postcss.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - tailwindcss: {}, - }, -} - -export default config diff --git a/packages/hub/public/data/experiments.json b/packages/hub/public/data/experiments.json index 811e4becb..536a583cd 100644 --- a/packages/hub/public/data/experiments.json +++ b/packages/hub/public/data/experiments.json @@ -6,5 +6,9 @@ "onboarding_v3": { "enabled": true, "allocation": 0.5 + }, + "onboarding_v4": { + "enabled": true, + "allocation": 0.5 } } diff --git a/packages/hub/src/app/[lang]/test/page.tsx b/packages/hub/src/app/[lang]/test/page.tsx index 827163ece..29f7ca5fd 100644 --- a/packages/hub/src/app/[lang]/test/page.tsx +++ b/packages/hub/src/app/[lang]/test/page.tsx @@ -1,8 +1,8 @@ "use client" import { Send } from "lucide-react" -import css from "@/app/page.module.css" -import { Button } from "@/components/ui/button" +import layoutCss from "@/app/page.module.css" +import css from "./test.module.css" import dynamic from "next/dynamic" import "quill/dist/quill.snow.css" import { useSearchParams } from "next/navigation" @@ -18,7 +18,7 @@ function SearchQuery() { const searchParams = useSearchParams() const search = searchParams.get("k") ?? "" return ( -
      +    
             {search}
           
      ) @@ -71,62 +71,62 @@ export default function Page() { } return ( -
      -
      -

      Browser Feature Test

      +
      +
      +

      Browser Feature Test

      -
      -

      Search Query Parameter

      +
      +

      Search Query Parameter

      Loading...
      }> -
      +
      -

      Text

      +

      Text

      -

      Password

      +

      Password

      -

      Number

      +

      Number

      -

      Textarea

      -