diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 573b4f6..6d99425 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,9 @@ jobs: - name: Run linting run: npm run lint + - name: Run tests + run: npm test + - name: Check version bump run: | # Get current version from package.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..135bbad --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,153 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npx vitest run --coverage --coverage.reporter=json-summary + + - name: Post coverage comment + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + SUMMARY=$(cat coverage/coverage-summary.json) + STMTS=$(echo "$SUMMARY" | jq -r '.total.statements.pct') + BRANCH=$(echo "$SUMMARY" | jq -r '.total.branches.pct') + FUNCS=$(echo "$SUMMARY" | jq -r '.total.functions.pct') + LINES=$(echo "$SUMMARY" | jq -r '.total.lines.pct') + + CONTENT=$(cat </dev/null | head -1 | tr -d '[:space:]') + if [ -n "$COMMENT_ID" ]; then + gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$BODY" + fi + + e2e-tests: + needs: unit-tests + runs-on: windows-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build application + run: npm run build + + - name: Run E2E tests + id: e2e + shell: bash + env: + PLAYWRIGHT_JSON_OUTPUT_NAME: e2e-results.json + run: npx playwright test --reporter=json,list + continue-on-error: true + + - name: Verify E2E tests actually ran + shell: bash + run: | + if [ ! -f e2e-results.json ]; then + echo "::error::E2E results file was not created β€” Playwright may not have run" + exit 1 + fi + TOTAL=$(jq '[.. | objects | select(has("tests")) | .tests[]] | length' e2e-results.json 2>/dev/null || echo "0") + if [ "$TOTAL" -eq 0 ]; then + echo "::error::E2E results contain no test specs β€” tests did not actually execute" + cat e2e-results.json + exit 1 + fi + echo "E2E results validated: $TOTAL test(s) found" + + - name: Upload Playwright traces on failure + if: steps.e2e.outcome == 'failure' + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: test-results/ + retention-days: 7 + + - name: Post E2E comment + if: always() && github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + if [ ! -f e2e-results.json ]; then + CONTENT="## πŸ§ͺ E2E Test Results\n\n❌ **Tests failed to produce results**" + else + CONTENT=$(jq -r ' + .stats as $s | + (if $s.unexpected == 0 then "βœ…" else "❌" end) as $status | + [.suites[].suites[] | {title, ok: (.specs | all(.ok))}] as $suites | + "## πŸ§ͺ E2E Test Results\n\n" + + $status + " **" + ($s.expected | tostring) + " passed**, " + + ($s.unexpected | tostring) + " failed, " + + ($s.skipped | tostring) + " skipped\n\n" + + "| Test Suite | Result |\n|------------|--------|\n" + + ($suites | map("| " + .title + " | " + (if .ok then "βœ…" else "❌" end) + " |") | join("\n")) + ' e2e-results.json) + fi + + MARKER="" + BODY="$MARKER + $CONTENT" + REPO="${{ github.repository }}" + PR_NUMBER="${{ github.event.pull_request.number }}" + + COMMENT_ID=$(gh api "repos/$REPO/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" 2>/dev/null | head -1 | tr -d '[:space:]') + if [ -n "$COMMENT_ID" ]; then + gh api "repos/$REPO/issues/comments/$COMMENT_ID" -X PATCH -f body="$BODY" + else + gh api "repos/$REPO/issues/$PR_NUMBER/comments" -f body="$BODY" + fi + + - name: Fail if E2E tests failed + if: steps.e2e.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index 836febd..f48c7b7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ out electron.vite.config.*.mjs .cursor/rules/user .claude/ +test-results/ +coverage/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f471c72..c9d23f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,14 +15,42 @@ Clipless is an Electron clipboard manager built with React and TypeScript. It mo - `npm run typecheck` β€” Type check all TypeScript (runs both `typecheck:node` for main/preload and `typecheck:web` for renderer) - `npm run build:win` / `build:mac` / `build:linux` β€” Platform-specific packaging -No test framework is configured. +### Testing + +- `npm test` / `npx vitest` β€” Unit tests (Vitest) +- `npx playwright test` β€” E2E tests (Playwright with Electron) + +**Note:** E2E tests interact with the **system clipboard**. Running them will read from and write to your actual OS clipboard. Avoid copying sensitive data before running e2e tests, and expect your clipboard contents to be overwritten. + +## Verification + +After making any code changes, always run the following before considering work complete: + +1. **Lint and typecheck** β€” must produce zero errors and zero warnings: + ```bash + npm run lint && npm run typecheck + ``` + +2. **Unit tests with coverage** β€” must maintain 100% code coverage across statements, branches, functions, and lines: + ```bash + npx vitest run --coverage + ``` + +3. **E2E tests** β€” must all pass: + ```bash + npx playwright test + ``` + +Fix all failures before moving on. Do not leave broken lint, type errors, coverage gaps, or failing tests for later. ## Architecture Electron three-process architecture using `electron-vite` as the build system and Tailwind CSS v4 for styling. ### Main Process (`src/main/`) + Node.js process handling system integration. Key modules: + - **`clipboard/`** β€” 250ms polling-based clipboard monitoring, Quick Clips pattern scanning, Quick Tools URL generation, templates - **`storage/`** β€” `SecureStorage` singleton using Electron's `safeStorage` (OS-native encryption: DPAPI/Keychain/Secret Service). Data stored as `data.enc` - **`hotkeys/`** β€” Global hotkey registration via `globalShortcut` with modular registry/actions/manager pattern @@ -31,12 +59,15 @@ Node.js process handling system integration. Key modules: - **`updater/`** β€” Auto-updates via electron-updater from GitHub releases ### Preload (`src/preload/`) + Context bridge exposing typed IPC APIs to renderer. All renderer↔main communication goes through `api.*` methods defined here. IPC channels are organized by domain: clipboard, settings, storage, templates, search-terms, quick-tools. ### Renderer (`src/renderer/`) + React 19 app with three entry points (`main.tsx`, `settings-main.tsx`, `tools-launcher-main.tsx`) and corresponding HTML files. State management uses React Context providers: + - **`providers/clips/`** β€” Clipboard state with hooks: `useClipsStorage`, `useClipboardOperations`, `useClipState`. Handles clip lifecycle, deduplication, and locking - **`providers/theme.tsx`** β€” Light/dark theme with system detection - **`providers/languageDetection.tsx`** β€” Code language detection toggle @@ -44,9 +75,11 @@ State management uses React Context providers: Clip types are rendered by type-specific components in `components/clips/clip/` (TextClip, HtmlClip, ImageClip, RtfClip, BookmarkClip). ### Shared (`src/shared/`) + TypeScript interfaces and constants used by all processes. ### Data Flow + User copies β†’ main process detects via polling β†’ reads clipboard β†’ sends `clipboard-changed` IPC event β†’ renderer updates state via ClipsProvider β†’ saves back to encrypted storage via IPC. ## Linear Ticket Template @@ -57,21 +90,27 @@ When creating Linear tickets for this project, use team **Clipless** and the fol **Priority:** 1=Urgent, 2=High, 3=Normal, 4=Low ### Title + Short imperative description (e.g. "Add keyboard shortcut for clearing clips") ### Description format + ```markdown ## Summary + One or two sentences describing what needs to happen and why. ## Context + - What currently happens (for bugs) or what's missing (for features) - Any relevant user workflow or affected area (clipboard, storage, hotkeys, settings, etc.) ## Acceptance Criteria + - [ ] Specific, verifiable condition - [ ] Another condition ## Affected Areas + Which modules are likely involved: clipboard/, storage/, hotkeys/, window/, renderer components, preload API, shared types. ``` diff --git a/docs/TOOLS_LAUNCHER_HOTKEY_TESTING.md b/docs/TOOLS_LAUNCHER_HOTKEY_TESTING.md index 0c3c565..9fb9ee8 100644 --- a/docs/TOOLS_LAUNCHER_HOTKEY_TESTING.md +++ b/docs/TOOLS_LAUNCHER_HOTKEY_TESTING.md @@ -4,7 +4,7 @@ ### Test Steps: -1. **Enable Hotkeys**: +1. **Enable Hotkeys**: - Open Clipless Settings (gear icon or Ctrl+Shift+V) - Go to "Hotkeys" tab - Ensure "Enable Global Hotkeys" is checked @@ -29,6 +29,7 @@ - You should be able to select patterns and tools to launch ### Expected Behavior: + - Hotkey works even when main window is hidden/minimized - Opens tools launcher with the first (most recent) clip content - Automatically scans for patterns and displays results @@ -36,6 +37,7 @@ - After launching tools, window closes automatically ### Configuration: + - Default hotkey: `CommandOrControl+Shift+T` - Can be customized in Settings > Hotkeys - Can be enabled/disabled individually diff --git a/docs/TOOLS_LAUNCHER_IMPLEMENTATION.md b/docs/TOOLS_LAUNCHER_IMPLEMENTATION.md index e41022f..9211d64 100644 --- a/docs/TOOLS_LAUNCHER_IMPLEMENTATION.md +++ b/docs/TOOLS_LAUNCHER_IMPLEMENTATION.md @@ -7,20 +7,24 @@ The QuickClipsScanner functionality has been extracted from the main window into ## Changes Made ### 1. Configuration Updates + - **electron.vite.config.ts**: Added `tools-launcher` entry point to build configuration - **HTML File**: Created `src/renderer/tools-launcher.html` for the tools launcher window - **Entry Point**: Created `src/renderer/src/tools-launcher-main.tsx` to bootstrap the launcher ### 2. New Components + - **ToolsLauncher**: New React component that wraps the QuickClipsScanner in a window context - **Updated QuickClipsScanner**: Modified to work both as a modal (legacy) and standalone window ### 3. Window Management + - **createToolsLauncherWindow()**: New function in `src/main/window/creation.ts` - **Window positioning**: Similar to settings window, positioned relative to main window - **Window properties**: 1000x700px, non-resizable, modal parent relationship ### 4. IPC Integration + - **open-tools-launcher**: Opens tools launcher window with clip content - **close-tools-launcher**: Closes the tools launcher window - **tools-launcher-ready**: Signals when window is ready for data @@ -28,10 +32,12 @@ The QuickClipsScanner functionality has been extracted from the main window into - **onToolsLauncherInitialize**: Listener for initialization data ### 5. CSS Updates + - **Standalone Mode**: Added `.standalone` style for full-window display - **Removed Overlay**: No overlay needed when used as dedicated window ### 6. Hotkey Integration + - **New Hotkey**: `CommandOrControl+Shift+T` to open tools launcher for the first (most recent) clip - **Global Access**: Works even when main window is hidden/minimized - **Settings Integration**: Configurable in Settings > Hotkeys section @@ -39,14 +45,18 @@ The QuickClipsScanner functionality has been extracted from the main window into ## Usage ### Via Scan Button + When users click the scan button (πŸ”) on any clip: + 1. A new tools-launcher window opens with the clip content 2. The window automatically scans for patterns and displays results 3. Users can select patterns and tools, then launch them 4. Window closes automatically after tools are launched or when cancelled ### Via Hotkey + Users can press `Ctrl+Shift+T` (or `Cmd+Shift+T` on Mac): + 1. Opens tools launcher with the most recent (first) clip content 2. Works even when the main window is hidden or minimized 3. Provides quick access to tools without navigating the UI diff --git a/e2e/app-launch.spec.ts b/e2e/app-launch.spec.ts new file mode 100644 index 0000000..4e9ac23 --- /dev/null +++ b/e2e/app-launch.spec.ts @@ -0,0 +1,42 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('App Launch', () => { + test('app launches and main window is visible', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + expect(window).toBeTruthy(); + + const title = await window.title(); + expect(title).toBeTruthy(); + + const isVisible = await app.evaluate(({ BrowserWindow }) => { + const win = BrowserWindow.getAllWindows()[0]; + return win ? win.isVisible() : false; + }); + expect(isVisible).toBe(true); + + await app.close(); + }); + + test('app window has expected dimensions', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + const { width, height } = await window.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + + await app.close(); + }); +}); diff --git a/e2e/clipboard.spec.ts b/e2e/clipboard.spec.ts new file mode 100644 index 0000000..93fff4c --- /dev/null +++ b/e2e/clipboard.spec.ts @@ -0,0 +1,27 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('Clipboard', () => { + test('clip appears after copying text', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + + // Write text to clipboard via Electron + await app.evaluate(async ({ clipboard }) => { + clipboard.writeText('Test clipboard entry'); + }); + + // Wait for clip to appear (polling interval is 250ms) + await window.waitForTimeout(1000); + + // Check that the clip content appears in the window + const content = await window.textContent('body'); + expect(content).toContain('Test clipboard entry'); + + await app.close(); + }); +}); diff --git a/e2e/quick-clips.spec.ts b/e2e/quick-clips.spec.ts new file mode 100644 index 0000000..0aa992b --- /dev/null +++ b/e2e/quick-clips.spec.ts @@ -0,0 +1,32 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('Quick Clips', () => { + test('app launches without quick clips errors', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + + // Verify the app loaded successfully + const content = await window.textContent('body'); + expect(content).toBeTruthy(); + + // Check console for errors related to quick clips + const errors: string[] = []; + window.on('console', (msg) => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await window.waitForTimeout(500); + + const quickClipErrors = errors.filter((e) => e.includes('quick clip') || e.includes('pattern')); + expect(quickClipErrors).toHaveLength(0); + + await app.close(); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000..6e05980 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,19 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('Settings', () => { + test('settings window can be opened', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + + // Look for a settings button or tray action + // The settings window is opened via IPC, so we trigger it + const windowCount = app.windows().length; + expect(windowCount).toBeGreaterThanOrEqual(1); + + await app.close(); + }); +}); diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts new file mode 100644 index 0000000..7c17a48 --- /dev/null +++ b/e2e/theme.spec.ts @@ -0,0 +1,21 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import { resolve } from 'path'; + +test.describe('Theme', () => { + test('app starts with default theme applied to body', async () => { + const app = await electron.launch({ + args: [resolve(__dirname, '../out/main/index.js')], + }); + + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + + // Check that a theme class is applied to the body + // Theme is applied asynchronously after settings load + await window.waitForFunction(() => /light|dark/.test(document.body.className)); + const bodyClasses = await window.evaluate(() => document.body.className); + expect(bodyClasses).toMatch(/light|dark/); + + await app.close(); + }); +}); diff --git a/e2e/tools.spec.ts b/e2e/tools.spec.ts new file mode 100644 index 0000000..1fca1a0 --- /dev/null +++ b/e2e/tools.spec.ts @@ -0,0 +1,361 @@ +import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test'; +import { resolve } from 'path'; + +const appPath = resolve(__dirname, '../out/main/index.js'); + +// Use unique suffixes to avoid collisions with persistent storage +const UNIQUE = Date.now().toString(36); + +async function launchApp() { + const app = await electron.launch({ args: [appPath] }); + const window = await app.firstWindow(); + await window.waitForSelector('#root > *'); + return { app, window }; +} + +async function findWindowByUrl( + app: ElectronApplication, + urlFragment: string, + timeoutMs = 15000 +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const windows = app.windows(); + for (const w of windows) { + try { + const url = w.url(); + if (url.includes(urlFragment)) return w; + } catch { + // Window may be closing + } + } + await new Promise((r) => setTimeout(r, 500)); + } + const urls = app.windows().map((w) => { + try { + return w.url(); + } catch { + return 'closed'; + } + }); + throw new Error(`Window with "${urlFragment}" not found. URLs: ${urls.join(', ')}`); +} + +async function openSettingsToolsTab(app: ElectronApplication, mainWindow: Page): Promise { + await mainWindow.evaluate(async () => { + const api = (window as any).api; + await api.openSettings('tools'); + }); + + // Wait for settings window to appear - retry if needed + let settingsPage: Page | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + settingsPage = await findWindowByUrl(app, 'settings.html'); + break; + } catch { + // Settings window may not have opened yet, retry + await new Promise((r) => setTimeout(r, 1000)); + await mainWindow.evaluate(async () => { + const api = (window as any).api; + await api.openSettings('tools'); + }); + } + } + if (!settingsPage) throw new Error('Could not open settings window'); + + await settingsPage.waitForSelector('#root > *'); + // Wait for the tools tab content to load + await settingsPage.waitForSelector('button:has-text("Search Terms")', { timeout: 10000 }); + return settingsPage; +} + +async function cleanupAllData(window: Page): Promise { + await window.evaluate(async () => { + const api = (window as any).api; + const terms = await api.searchTermsGetAll(); + for (const t of terms) await api.searchTermsDelete(t.id); + const tools = await api.quickToolsGetAll(); + for (const t of tools) await api.quickToolsDelete(t.id); + const templates = await api.templatesGetAll(); + for (const t of templates) await api.templatesDelete(t.id); + }); +} + +/** Click Delete on a card item and confirm in the dialog */ +async function deleteItemByName(page: Page, name: string): Promise { + // Click Delete button in the item header + const itemHeader = page.locator(`h4:has-text("${name}")`).locator('..'); + await itemHeader.locator('button:has-text("Delete")').click(); + + // Wait for and click the confirm dialog's Delete button + const confirmBtn = page.locator('button:has-text("Delete")').last(); + await confirmBtn.waitFor({ state: 'visible', timeout: 3000 }); + await confirmBtn.click(); +} + +test.describe('Settings β€” Search Terms CRUD', () => { + let app: ElectronApplication; + let settings: Page; + + test.beforeAll(async () => { + const result = await launchApp(); + app = result.app; + await cleanupAllData(result.window); + settings = await openSettingsToolsTab(app, result.window); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test('create, edit, and delete a search term', async () => { + const name = `ST-${UNIQUE}`; + const nameUpdated = `ST-Upd-${UNIQUE}`; + + // Navigate to Search Terms sub-tab + await settings.click('button:has-text("Search Terms")'); + + // Create + await settings.click('button:has-text("Create Search Term")'); + await settings.fill('input[placeholder="Search term name"]', name); + await settings.fill( + 'textarea[placeholder*="Regular expression"]', + '(?[\\w.+-]+@[\\w-]+\\.[\\w.]+)' + ); + await settings.click('button:has-text("Save")'); + + // Verify appears + await expect(settings.locator('h4', { hasText: name })).toBeVisible(); + + // Edit + const header = settings.locator(`h4:has-text("${name}")`).locator('..'); + await header.locator('button:has-text("Edit")').click(); + await settings.fill('input[placeholder="Search term name"]', nameUpdated); + await settings.click('button:has-text("Save")'); + + await expect(settings.locator('h4', { hasText: nameUpdated })).toBeVisible(); + + // Delete with confirm + await deleteItemByName(settings, nameUpdated); + await expect(settings.locator('h4', { hasText: nameUpdated })).toBeHidden({ timeout: 5000 }); + }); +}); + +test.describe('Settings β€” Quick Tools CRUD', () => { + let app: ElectronApplication; + let settings: Page; + + test.beforeAll(async () => { + const result = await launchApp(); + app = result.app; + await cleanupAllData(result.window); + + // Seed a search term so capture groups exist + await result.window.evaluate(async () => { + const api = (window as any).api; + await api.searchTermsCreate('Email Finder', '(?[\\w.+-]+@[\\w-]+\\.[\\w.]+)'); + }); + + settings = await openSettingsToolsTab(app, result.window); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test('create, edit, and delete a quick tool', async () => { + const name = `QT-${UNIQUE}`; + + // Navigate to Tools sub-tab (not the top-level "Tools" tab which is already active) + // The second "Tools" button is the sub-tab + await settings.locator('button:has-text("Tools")').nth(1).click(); + + // Create + await settings.click('button:has-text("Create Tool")'); + await settings.fill('input[placeholder="Tool name"]', name); + await settings.fill('input[type="url"]', 'https://example.com/search?q={email}'); + await settings.click('button:has-text("Save")'); + + // Verify appears + await expect(settings.locator('h4', { hasText: name })).toBeVisible(); + + // Edit + const header = settings.locator(`h4:has-text("${name}")`).locator('..'); + await header.locator('button:has-text("Edit")').click(); + await settings.fill('input[type="url"]', 'https://example.com/lookup?email={email}'); + await settings.click('button:has-text("Save")'); + + await expect(settings.locator('h4', { hasText: name })).toBeVisible(); + + // Delete with confirm + await deleteItemByName(settings, name); + await expect(settings.locator('h4', { hasText: name })).toBeHidden({ timeout: 5000 }); + }); +}); + +test.describe('Settings β€” Templates CRUD', () => { + let app: ElectronApplication; + let settings: Page; + + test.beforeAll(async () => { + const result = await launchApp(); + app = result.app; + await cleanupAllData(result.window); + settings = await openSettingsToolsTab(app, result.window); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test('create, edit, and delete a template', async () => { + const name = `TPL-${UNIQUE}`; + const nameUpdated = `TPL-Upd-${UNIQUE}`; + + // Navigate to Templates sub-tab + await settings.click('button:has-text("Templates")'); + + // Create + await settings.click('button:has-text("Create Template")'); + await settings.fill('input[placeholder="Template name"]', name); + await settings.fill( + 'textarea[placeholder*="Template content"]', + 'Hello {c1}, your email is {email}' + ); + await settings.click('button:has-text("Save")'); + + // Verify appears (templates use h3, not h4) + await expect(settings.locator('h3', { hasText: name })).toBeVisible(); + + // Expand the template to reveal Edit/Delete buttons + await settings.locator('h3', { hasText: name }).click(); + await expect(settings.locator('button:has-text("Edit")')).toBeVisible({ timeout: 3000 }); + + // Edit + await settings.locator('button:has-text("Edit")').click(); + await settings.fill('input[placeholder="Template name"]', nameUpdated); + await settings.click('button:has-text("Save")'); + + await expect(settings.locator('h3', { hasText: nameUpdated })).toBeVisible(); + + // After save from edit, template should still be expanded (expandedId set by handleStartEdit) + // Delete button should be visible + await expect(settings.locator('button:has-text("Delete")')).toBeVisible({ timeout: 3000 }); + + // Delete β€” click Delete in the template actions + await settings.locator('button:has-text("Delete")').click(); + + // Confirm in the delete dialog + const confirmBtn = settings.locator('button:has-text("Delete")').last(); + await confirmBtn.waitFor({ state: 'visible', timeout: 3000 }); + await confirmBtn.click(); + + await expect(settings.locator('h3', { hasText: nameUpdated })).toBeHidden({ timeout: 5000 }); + }); +}); + +test.describe('Tools Launcher β€” Pattern Scanning', () => { + let app: ElectronApplication; + + test.beforeAll(async () => { + const result = await launchApp(); + app = result.app; + await cleanupAllData(result.window); + + // Seed search term, tool, and template + await result.window.evaluate(async () => { + const api = (window as any).api; + await api.searchTermsCreate('Email Pattern', '(?[\\w.+-]+@[\\w-]+\\.[\\w.]+)'); + await api.quickToolsCreate('Email Lookup', 'https://example.com/search?q={email}', ['email']); + await api.templatesCreate('Email Template', 'Contact: {email}'); + }); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test('tools launcher shows patterns, tools, and templates for matching text', async () => { + const mainWindow = await app.firstWindow(); + + // Open tools launcher with text containing an email + await mainWindow.evaluate(async () => { + const api = (window as any).api; + await api.openToolsLauncher('Contact us at test@example.com for info'); + }); + + const launcher = await findWindowByUrl(app, 'tools-launcher'); + await launcher.waitForSelector('#root > *'); + + // Wait for scanning to complete + await launcher.waitForSelector('text=test@example.com', { timeout: 10000 }); + + // Verify pattern found + await expect(launcher.locator('text=test@example.com')).toBeVisible(); + + // Wait for content to fully load + await launcher.waitForTimeout(2000); + + // Check if tools section is expanded; if not, expand it + const toolsVisible = await launcher + .locator('text=Email Lookup') + .isVisible() + .catch(() => false); + if (!toolsVisible) { + // Click "Available Tools" to expand + await launcher.locator('text=Available Tools').click(); + } + await expect(launcher.locator('text=Email Lookup')).toBeVisible({ timeout: 5000 }); + + // Check if templates section is expanded; if not, expand it + const templateVisible = await launcher + .getByText('Email Template') + .isVisible() + .catch(() => false); + if (!templateVisible) { + await launcher.locator('span:has-text("Matched Templates")').click(); + } + await expect(launcher.getByText('Email Template')).toBeVisible({ timeout: 5000 }); + }); +}); + +test.describe('Tools Launcher β€” Clip Templates', () => { + let app: ElectronApplication; + + test.beforeAll(async () => { + const result = await launchApp(); + app = result.app; + await cleanupAllData(result.window); + + // Seed a positional-only template + await result.window.evaluate(async () => { + const api = (window as any).api; + await api.templatesCreate('Positional Template', 'First: {c1}, Second: {c2}'); + }); + }); + + test.afterAll(async () => { + await app.close(); + }); + + test('tools launcher shows clip templates', async () => { + const mainWindow = await app.firstWindow(); + + // Open tools launcher + await mainWindow.evaluate(async () => { + const api = (window as any).api; + await api.openToolsLauncher('Some clipboard text'); + }); + + const launcher = await findWindowByUrl(app, 'tools-launcher'); + await launcher.waitForSelector('#root > *'); + + // Wait for content to load + await launcher.waitForTimeout(2000); + + // Verify clip template section appears + await expect(launcher.locator('text=Clip Templates')).toBeVisible({ timeout: 10000 }); + await expect(launcher.locator('text=Positional Template').first()).toBeVisible(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..e9b6927 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["./**/*.ts"] +} diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 5f73258..ee1c278 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,9 +2,7 @@ import { resolve } from 'path'; import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; - -// Load package.json to get the version -const packageJson = require('./package.json'); +import packageJson from './package.json'; export default defineConfig({ main: { diff --git a/eslint.config.mjs b/eslint.config.mjs index 35ea92f..b33efea 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ import eslintPluginReactHooks from 'eslint-plugin-react-hooks'; import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'; export default tseslint.config( - { ignores: ['**/node_modules', '**/dist', '**/out', '**/*.d.ts'] }, + { ignores: ['**/node_modules', '**/dist', '**/out', '**/*.d.ts', 'e2e/**', 'coverage/**'] }, tseslint.configs.recommended, eslintPluginReact.configs.flat.recommended, eslintPluginReact.configs.flat['jsx-runtime'], diff --git a/package-lock.json b/package-lock.json index 760e162..ae069c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clipless", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clipless", - "version": "1.7.0", + "version": "1.7.1", "hasInstallScript": true, "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -25,11 +25,17 @@ "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/tsconfig": "^1.0.1", + "@playwright/test": "^1.58.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.1", "@types/react": "^19.1.1", "@types/react-dom": "^19.1.2", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", "electron": "^35.1.5", "electron-builder": "^25.1.8", "electron-vite": "^3.1.0", @@ -37,13 +43,29 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", + "jsdom": "^27.4.0", "prettier": "^3.5.3", "react": "^19.1.0", "react-dom": "^19.1.0", "typescript": "^5.8.3", - "vite": "^6.2.6" + "vite": "^6.2.6", + "vitest": "^4.0.18" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -57,6 +79,61 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", + "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -210,9 +287,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -244,13 +321,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -351,19 +428,161 @@ } }, "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -1381,6 +1600,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.10.0.tgz", + "integrity": "sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.7.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", @@ -1667,15 +1904,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1864,6 +2101,29 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.1.tgz", + "integrity": "sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.19", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", @@ -2143,6 +2403,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -2488,6 +2755,96 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2498,6 +2855,14 @@ "node": ">= 10" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2555,6 +2920,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2565,6 +2941,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2994,32 +3377,196 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "node_modules/@vitest/coverage-v8": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz", + "integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10.0.0" + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.18", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.18", + "vitest": "4.0.18" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", "dev": true, - "license": "ISC" - }, - "node_modules/acorn": { - "version": "8.15.0", + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz", + "integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.18" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, @@ -3352,6 +3899,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -3518,6 +4075,35 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3615,6 +4201,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4026,6 +4622,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4450,6 +5056,53 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4457,6 +5110,30 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4525,6 +5202,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -4632,6 +5316,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -4780,6 +5474,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5190,6 +5892,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5320,6 +6035,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5720,6 +6442,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5730,6 +6462,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -5858,6 +6600,13 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6557,6 +7306,26 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -7085,6 +7854,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7260,6 +8036,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -7360,6 +8175,46 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7898,21 +8753,73 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", "dependencies": { "agentkeepalive": "^4.2.1", "cacache": "^16.1.0", @@ -8009,6 +8916,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8088,6 +9002,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", @@ -8238,6 +9162,16 @@ "node": ">=10" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8556,6 +9490,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -8735,6 +9680,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8806,6 +9764,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -8846,6 +9811,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz", + "integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz", + "integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -8937,6 +9949,44 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -9240,6 +10290,20 @@ "node": ">=10" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -9316,6 +10380,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -9609,6 +10683,19 @@ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", "license": "ISC" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -9795,6 +10882,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9828,6 +10922,21 @@ "node": ">=10" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/slice-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", @@ -9958,6 +11067,13 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -9968,6 +11084,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -10144,6 +11267,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10195,6 +11331,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/synckit": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.8.tgz", @@ -10334,14 +11477,31 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -10351,10 +11511,13 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -10365,9 +11528,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -10376,6 +11539,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -10409,6 +11602,42 @@ "node": ">=8.0" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -10799,6 +12028,110 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -10809,6 +12142,40 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10910,6 +12277,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -10973,6 +12357,38 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -10983,6 +12399,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 614b745..b923753 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clipless", - "version": "1.7.0", + "version": "1.7.1", "description": "An Electron application with React and TypeScript", "main": "./out/main/index.js", "author": "Daniel Essig", @@ -23,7 +23,12 @@ "build:win": "npm run build && electron-builder --win", "build:mac": "electron-vite build && electron-builder --mac", "build:linux": "electron-vite build && electron-builder --linux", - "release": "electron-builder" + "release": "electron-builder", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test" }, "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -42,11 +47,17 @@ "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.0.0", "@electron-toolkit/tsconfig": "^1.0.1", + "@playwright/test": "^1.58.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.14.1", "@types/react": "^19.1.1", "@types/react-dom": "^19.1.2", "@types/react-syntax-highlighter": "^15.5.13", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", "electron": "^35.1.5", "electron-builder": "^25.1.8", "electron-vite": "^3.1.0", @@ -54,10 +65,12 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", + "jsdom": "^27.4.0", "prettier": "^3.5.3", "react": "^19.1.0", "react-dom": "^19.1.0", "typescript": "^5.8.3", - "vite": "^6.2.6" + "vite": "^6.2.6", + "vitest": "^4.0.18" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6bf9abc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30000, + retries: 0, + workers: 1, + fullyParallel: false, + use: { + trace: 'on-first-retry', + }, +}); diff --git a/src/main/__mocks__/electron.ts b/src/main/__mocks__/electron.ts new file mode 100644 index 0000000..b2b71e1 --- /dev/null +++ b/src/main/__mocks__/electron.ts @@ -0,0 +1,59 @@ +import { vi } from 'vitest'; + +export const clipboard = { + readText: vi.fn().mockReturnValue(''), + readHTML: vi.fn().mockReturnValue(''), + readRTF: vi.fn().mockReturnValue(''), + readImage: vi.fn().mockReturnValue({ isEmpty: () => true, toDataURL: () => '' }), + readBookmark: vi.fn().mockReturnValue({ title: '', url: '' }), + writeText: vi.fn(), + writeHTML: vi.fn(), + writeRTF: vi.fn(), + writeImage: vi.fn(), + writeBookmark: vi.fn(), + write: vi.fn(), +}; + +export const safeStorage = { + isEncryptionAvailable: vi.fn().mockReturnValue(true), + encryptString: vi.fn((str: string) => Buffer.from(str)), + decryptString: vi.fn((buf: Buffer) => buf.toString()), +}; + +export const globalShortcut = { + register: vi.fn().mockReturnValue(true), + unregister: vi.fn(), + unregisterAll: vi.fn(), + isRegistered: vi.fn().mockReturnValue(false), +}; + +export const nativeImage = { + createFromDataURL: vi.fn().mockReturnValue({ + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,test', + getSize: () => ({ width: 100, height: 100 }), + }), +}; + +export const BrowserWindow = vi.fn().mockImplementation(() => ({ + isVisible: vi.fn().mockReturnValue(true), + isFocused: vi.fn().mockReturnValue(false), + isMinimized: vi.fn().mockReturnValue(false), + isDestroyed: vi.fn().mockReturnValue(false), + show: vi.fn(), + hide: vi.fn(), + focus: vi.fn(), + restore: vi.fn(), + webContents: { + send: vi.fn(), + }, +})); + +export const shell = { + openExternal: vi.fn().mockResolvedValue(undefined), +}; + +export const app = { + getPath: vi.fn().mockReturnValue('/mock/path'), + focus: vi.fn(), +}; diff --git a/src/main/clipboard/data.test.ts b/src/main/clipboard/data.test.ts new file mode 100644 index 0000000..36b25a4 --- /dev/null +++ b/src/main/clipboard/data.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('electron', () => ({ + clipboard: { + readText: vi.fn().mockReturnValue(''), + readHTML: vi.fn().mockReturnValue(''), + readRTF: vi.fn().mockReturnValue(''), + readImage: vi.fn().mockReturnValue({ isEmpty: () => true, toDataURL: () => '' }), + readBookmark: vi.fn().mockReturnValue({ title: '', url: '' }), + writeText: vi.fn(), + writeHTML: vi.fn(), + writeRTF: vi.fn(), + writeImage: vi.fn(), + write: vi.fn(), + }, + nativeImage: { + createFromDataURL: vi.fn().mockReturnValue({ + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,test', + }), + }, +})); + +import { + getCurrentClipboardData, + getClipboardText, + getClipboardHTML, + getClipboardRTF, + getClipboardImage, + getClipboardBookmark, + setClipboardText, + setClipboardHTML, + setClipboardRTF, + setClipboardImage, + setClipboardBookmark, +} from './data'; +import { clipboard, nativeImage } from 'electron'; + +describe('getCurrentClipboardData', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(clipboard.readText).mockReturnValue(''); + vi.mocked(clipboard.readRTF).mockReturnValue(''); + vi.mocked(clipboard.readHTML).mockReturnValue(''); + vi.mocked(clipboard.readImage).mockReturnValue({ + isEmpty: () => true, + toDataURL: () => '', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + vi.mocked(clipboard.readBookmark).mockReturnValue({ title: '', url: '' }); + }); + + it('returns text type when text is available', () => { + vi.mocked(clipboard.readText).mockReturnValue('hello'); + const result = getCurrentClipboardData(); + expect(result).toEqual({ type: 'text', content: 'hello' }); + }); + + it('returns rtf type when only RTF is available', () => { + vi.mocked(clipboard.readRTF).mockReturnValue('{\\rtf1 hello}'); + const result = getCurrentClipboardData(); + expect(result).toEqual({ type: 'rtf', content: '{\\rtf1 hello}' }); + }); + + it('returns html type when only HTML is available', () => { + vi.mocked(clipboard.readHTML).mockReturnValue('

hello

'); + const result = getCurrentClipboardData(); + expect(result).toEqual({ type: 'html', content: '

hello

' }); + }); + + it('returns image type when only image is available', () => { + vi.mocked(clipboard.readImage).mockReturnValue({ + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,abc', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const result = getCurrentClipboardData(); + expect(result).toEqual({ type: 'image', content: 'data:image/png;base64,abc' }); + }); + + it('returns bookmark type when only bookmark is available', () => { + vi.mocked(clipboard.readBookmark).mockReturnValue({ + title: 'Example', + url: 'https://example.com', + }); + const result = getCurrentClipboardData(); + expect(result).toEqual({ + type: 'bookmark', + content: JSON.stringify({ title: 'Example', url: 'https://example.com' }), + }); + }); + + it('returns null when readBookmark throws', () => { + vi.mocked(clipboard.readBookmark).mockImplementation(() => { + throw new Error('not supported'); + }); + const result = getCurrentClipboardData(); + expect(result).toBeNull(); + }); + + it('returns null when clipboard is empty', () => { + const result = getCurrentClipboardData(); + expect(result).toBeNull(); + }); + + it('prioritizes text over other types', () => { + vi.mocked(clipboard.readText).mockReturnValue('text'); + vi.mocked(clipboard.readHTML).mockReturnValue('

html

'); + const result = getCurrentClipboardData(); + expect(result?.type).toBe('text'); + }); +}); + +describe('getClipboardText', () => { + it('returns clipboard text', () => { + vi.mocked(clipboard.readText).mockReturnValue('test'); + expect(getClipboardText()).toBe('test'); + }); +}); + +describe('setClipboardText', () => { + it('writes text to clipboard', () => { + setClipboardText('hello'); + expect(clipboard.writeText).toHaveBeenCalledWith('hello'); + }); +}); + +describe('setClipboardImage', () => { + it('converts data URL and writes image', () => { + setClipboardImage('data:image/png;base64,test'); + expect(nativeImage.createFromDataURL).toHaveBeenCalledWith('data:image/png;base64,test'); + expect(clipboard.writeImage).toHaveBeenCalled(); + }); +}); + +describe('getClipboardHTML', () => { + it('returns clipboard HTML', () => { + vi.mocked(clipboard.readHTML).mockReturnValue('

test

'); + expect(getClipboardHTML()).toBe('

test

'); + }); +}); + +describe('getClipboardRTF', () => { + it('returns clipboard RTF', () => { + vi.mocked(clipboard.readRTF).mockReturnValue('{\\rtf1 test}'); + expect(getClipboardRTF()).toBe('{\\rtf1 test}'); + }); +}); + +describe('getClipboardImage', () => { + it('returns data URL when image exists', () => { + vi.mocked(clipboard.readImage).mockReturnValue({ + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,abc', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + expect(getClipboardImage()).toBe('data:image/png;base64,abc'); + }); + + it('returns null when no image', () => { + vi.mocked(clipboard.readImage).mockReturnValue({ + isEmpty: () => true, + toDataURL: () => '', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + expect(getClipboardImage()).toBeNull(); + }); +}); + +describe('getClipboardBookmark', () => { + it('returns bookmark when available', () => { + vi.mocked(clipboard.readBookmark).mockReturnValue({ title: 'Test', url: 'https://test.com' }); + expect(getClipboardBookmark()).toEqual({ title: 'Test', url: 'https://test.com' }); + }); + + it('returns null when readBookmark throws', () => { + vi.mocked(clipboard.readBookmark).mockImplementation(() => { + throw new Error('not supported'); + }); + expect(getClipboardBookmark()).toBeNull(); + }); +}); + +describe('setClipboardHTML', () => { + it('writes HTML to clipboard', () => { + setClipboardHTML('

hello

'); + expect(clipboard.writeHTML).toHaveBeenCalledWith('

hello

'); + }); +}); + +describe('setClipboardRTF', () => { + it('writes RTF to clipboard', () => { + setClipboardRTF('{\\rtf1 hello}'); + expect(clipboard.writeRTF).toHaveBeenCalledWith('{\\rtf1 hello}'); + }); +}); + +describe('setClipboardImage', () => { + it('throws when createFromDataURL fails', () => { + vi.mocked(nativeImage.createFromDataURL).mockImplementation(() => { + throw new Error('invalid image'); + }); + expect(() => setClipboardImage('bad-data')).toThrow('invalid image'); + }); +}); + +describe('setClipboardBookmark', () => { + it('writes bookmark data', () => { + setClipboardBookmark({ + text: 'Example', + html: 'Example', + title: 'Ex', + url: 'https://example.com', + }); + expect(clipboard.write).toHaveBeenCalledWith({ text: 'Example', html: 'Example' }); + }); + + it('throws when clipboard.write fails', () => { + vi.mocked(clipboard.write).mockImplementation(() => { + throw new Error('write failed'); + }); + expect(() => setClipboardBookmark({ text: 'a', html: 'a' })).toThrow('write failed'); + }); +}); diff --git a/src/main/clipboard/data.ts b/src/main/clipboard/data.ts index fb16c94..5f79918 100644 --- a/src/main/clipboard/data.ts +++ b/src/main/clipboard/data.ts @@ -4,17 +4,17 @@ import { clipboard, nativeImage } from 'electron'; export const getCurrentClipboardData = (): { type: string; content: string } | null => { // Priority: text > rtf > html > image > bookmark const text = clipboard.readText(); - if (text && text.trim()) { + if (text?.trim()) { return { type: 'text', content: text }; } const rtf = clipboard.readRTF(); - if (rtf && rtf.trim()) { + if (rtf?.trim()) { return { type: 'rtf', content: rtf }; } const html = clipboard.readHTML(); - if (html && html.trim()) { + if (html?.trim()) { return { type: 'html', content: html }; } @@ -25,7 +25,7 @@ export const getCurrentClipboardData = (): { type: string; content: string } | n try { const bookmark = clipboard.readBookmark(); - if (bookmark && bookmark.url) { + if (bookmark?.url) { return { type: 'bookmark', content: JSON.stringify(bookmark) }; } } catch { diff --git a/src/main/clipboard/ipc.ts b/src/main/clipboard/ipc.ts index ee2a50c..86bf69a 100644 --- a/src/main/clipboard/ipc.ts +++ b/src/main/clipboard/ipc.ts @@ -114,7 +114,9 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { saveClips(clips, lockedIndices) ); ipcMain.handle('storage-get-settings', async () => getSettings()); - ipcMain.handle('storage-save-settings', async (_event, settings: UserSettings) => saveSettings(settings)); + ipcMain.handle('storage-save-settings', async (_event, settings: UserSettings) => + saveSettings(settings) + ); ipcMain.handle('storage-get-stats', async () => getStorageStats()); ipcMain.handle('storage-export-data', async () => exportData()); ipcMain.handle('storage-import-data', async (_event, jsonData: string) => importData(jsonData)); @@ -134,7 +136,12 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { ); ipcMain.handle( 'templates-generate-text', - async (_event, templateId: string, clipContents: string[], captures?: Record) => { + async ( + _event, + templateId: string, + clipContents: string[], + captures?: Record + ) => { const templates = await getAllTemplates(); const template = templates.find((t) => t.id === templateId); const templateName = template?.name || 'Unknown'; @@ -171,7 +178,9 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { updateQuickTool(id, updates) ); ipcMain.handle('quick-tools-delete', async (_event, id: string) => deleteQuickTool(id)); - ipcMain.handle('quick-tools-reorder', async (_event, tools: QuickTool[]) => reorderQuickTools(tools)); + ipcMain.handle('quick-tools-reorder', async (_event, tools: QuickTool[]) => + reorderQuickTools(tools) + ); ipcMain.handle('quick-tools-validate-url', async (_event, url: string, captureGroups: string[]) => validateToolUrl(url, captureGroups) ); @@ -180,8 +189,10 @@ export function setupClipboardIPC(mainWindow: BrowserWindow | null): void { ipcMain.handle('quick-clips-scan-text', async (_event, text: string) => scanTextForPatterns(text) ); - ipcMain.handle('quick-clips-open-tools', async (_event, matches: PatternMatch[], toolIds: string[]) => - openToolsForMatches(matches, toolIds) + ipcMain.handle( + 'quick-clips-open-tools', + async (_event, matches: PatternMatch[], toolIds: string[]) => + openToolsForMatches(matches, toolIds) ); ipcMain.handle('quick-clips-export-config', async () => exportQuickClipsConfig()); ipcMain.handle('quick-clips-import-config', async (_event, config: QuickClipsConfig) => diff --git a/src/main/clipboard/quick-clips.test.ts b/src/main/clipboard/quick-clips.test.ts new file mode 100644 index 0000000..80d6370 --- /dev/null +++ b/src/main/clipboard/quick-clips.test.ts @@ -0,0 +1,520 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockOpenExternal } = vi.hoisted(() => ({ + mockOpenExternal: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('electron', () => ({ + shell: { openExternal: mockOpenExternal }, +})); + +vi.mock('../storage', () => ({ + storage: { + getSearchTerms: vi.fn(), + getQuickTools: vi.fn(), + getTemplates: vi.fn(), + importQuickClipsConfig: vi.fn(), + }, +})); + +import { + scanTextForPatterns, + openToolsForMatches, + exportQuickClipsConfig, + importQuickClipsConfig, +} from './quick-clips'; +import { storage } from '../storage'; +const mockedStorage = vi.mocked(storage); + +describe('scanTextForPatterns', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns empty array when no search terms exist', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([]); + const result = await scanTextForPatterns('hello world'); + expect(result).toEqual([]); + }); + + it('returns empty array when no patterns match', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'Email', + pattern: '(?[\\w.]+@[\\w.]+)', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('no emails here'); + expect(result).toEqual([]); + }); + + it('matches named capture groups', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'Email', + pattern: '(?[\\w.]+@[\\w.]+)', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('contact user@example.com for info'); + expect(result).toHaveLength(1); + expect(result[0].searchTermId).toBe('1'); + expect(result[0].searchTermName).toBe('Email'); + expect(result[0].captures.email).toBe('user@example.com'); + }); + + it('finds multiple matches in text', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'Email', + pattern: '(?[\\w.]+@[\\w.]+)', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('a@b.com and c@d.com'); + expect(result).toHaveLength(2); + expect(result[0].captures.email).toBe('a@b.com'); + expect(result[1].captures.email).toBe('c@d.com'); + }); + + it('skips disabled search terms', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'Email', + pattern: '(?[\\w.]+@[\\w.]+)', + enabled: false, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('user@example.com'); + expect(result).toEqual([]); + }); + + it('skips patterns without named capture groups', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'NoGroups', + pattern: '\\d+', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('123 456'); + expect(result).toEqual([]); + }); + + it('handles invalid regex gracefully', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'Bad', + pattern: '(?[', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + { + id: '2', + name: 'Good', + pattern: '(?\\d+)', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('test 123'); + expect(result).toHaveLength(1); + expect(result[0].captures.num).toBe('123'); + }); + + it('matches multiple capture groups in one pattern', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'NameEmail', + pattern: '(?\\w+)\\s+(?[\\w.]+@[\\w.]+)', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + const result = await scanTextForPatterns('John user@example.com'); + expect(result).toHaveLength(1); + expect(result[0].captures.name).toBe('John'); + expect(result[0].captures.email).toBe('user@example.com'); + }); + + it('skips capture group values that are undefined', async () => { + mockedStorage.getSearchTerms.mockResolvedValue([ + { + id: '1', + name: 'Optional', + pattern: '(?\\w+)(?:-(?\\w+))?', + enabled: true, + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + // "hello" matches required but optional group is undefined + const result = await scanTextForPatterns('hello'); + expect(result).toHaveLength(1); + expect(result[0].captures.required).toBe('hello'); + expect(result[0].captures.optional).toBeUndefined(); + }); + + it('throws when storage.getSearchTerms fails', async () => { + mockedStorage.getSearchTerms.mockRejectedValue(new Error('storage fail')); + await expect(scanTextForPatterns('test')).rejects.toThrow('storage fail'); + }); +}); + +describe('openToolsForMatches', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens URL with token replaced by capture value', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Search', + url: 'https://google.com/search?q={email}', + captureGroups: ['email'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { searchTermId: '1', searchTermName: 'Email', captures: { email: 'test@example.com' } }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).toHaveBeenCalledWith('https://google.com/search?q=test%40example.com'); + }); + + it('skips tool when no matching captures exist', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Search', + url: 'https://example.com/{phone}', + captureGroups: ['phone'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { searchTermId: '1', searchTermName: 'Email', captures: { email: 'test@example.com' } }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).not.toHaveBeenCalled(); + }); + + it('skips unknown tool ids', async () => { + mockedStorage.getQuickTools.mockResolvedValue([]); + + const matches = [ + { searchTermId: '1', searchTermName: 'Email', captures: { email: 'test@example.com' } }, + ]; + await openToolsForMatches(matches, ['unknown']); + + expect(mockOpenExternal).not.toHaveBeenCalled(); + }); + + it('opens URL as-is when no tokens in URL', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Static', + url: 'https://example.com/page', + captureGroups: ['email'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { searchTermId: '1', searchTermName: 'Email', captures: { email: 'test@example.com' } }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).toHaveBeenCalledWith('https://example.com/page'); + }); + + it('uses URL capture directly when tool URL is just a {url} token', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Open URL', + url: '{url}', + captureGroups: ['url'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { searchTermId: '1', searchTermName: 'URL', captures: { url: 'https://detected.com' } }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).toHaveBeenCalledWith('https://detected.com'); + }); + + it('generates combinations for multiple tokens', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Multi', + url: 'https://example.com/{name}/{email}', + captureGroups: ['name', 'email'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { + searchTermId: '1', + searchTermName: 'Contact', + captures: { name: 'John', email: 'john@test.com' }, + }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).toHaveBeenCalledWith('https://example.com/John/john%40test.com'); + }); + + it('skips URL generation when token has no matching capture values', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Search', + url: 'https://example.com/{missing}', + captureGroups: ['missing'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { searchTermId: '1', searchTermName: 'Email', captures: { email: 'test@example.com' } }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).not.toHaveBeenCalled(); + }); + + it('handles pipe-separated capture groups in tokens', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Search', + url: 'https://example.com/search?q={email|phone}', + captureGroups: ['email', 'phone'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { + searchTermId: '1', + searchTermName: 'Contact', + captures: { email: 'test@example.com', phone: '555-1234' }, + }, + ]; + await openToolsForMatches(matches, ['t1']); + + // Should open URLs for each matching capture value + expect(mockOpenExternal).toHaveBeenCalled(); + }); + + it('generates all combinations for multi-value multi-token URLs', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Multi', + url: 'https://example.com/{a|b}/{c|d}', + captureGroups: ['a', 'b', 'c', 'd'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { + searchTermId: '1', + searchTermName: 'Test', + captures: { a: 'v1', b: 'v2', c: 'v3', d: 'v4' }, + }, + ]; + await openToolsForMatches(matches, ['t1']); + + // With {a|b} having values [v1,v2] and {c|d} having [v3,v4], should get 4 combinations + expect(mockOpenExternal).toHaveBeenCalledTimes(4); + }); + + it('does not encode URL-type capture groups in non-direct URLs', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'URL redirect', + url: 'https://redirect.com?target={url}', + captureGroups: ['url'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { + searchTermId: '1', + searchTermName: 'URL', + captures: { url: 'https://example.com/path?q=1' }, + }, + ]; + await openToolsForMatches(matches, ['t1']); + + // URL captures should not be encoded when in a compound URL + expect(mockOpenExternal).toHaveBeenCalledWith( + 'https://redirect.com?target=https://example.com/path?q=1' + ); + }); + + it('handles falsy capture value for a group', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Search', + url: 'https://example.com/{email}', + captureGroups: ['email'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [{ searchTermId: '1', searchTermName: 'Email', captures: { email: '' } }]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).not.toHaveBeenCalled(); + }); + + it('handles URL capture group in multi-token URL', async () => { + mockedStorage.getQuickTools.mockResolvedValue([ + { + id: 't1', + name: 'Multi', + url: 'https://proxy.com/{url}/{name}', + captureGroups: ['url', 'name'], + createdAt: 0, + updatedAt: 0, + order: 0, + }, + ]); + + const matches = [ + { + searchTermId: '1', + searchTermName: 'Test', + captures: { url: 'https://example.com', name: 'test' }, + }, + ]; + await openToolsForMatches(matches, ['t1']); + + expect(mockOpenExternal).toHaveBeenCalledWith('https://proxy.com/https://example.com/test'); + }); + + it('throws when storage fails', async () => { + mockedStorage.getQuickTools.mockRejectedValue(new Error('storage error')); + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + openToolsForMatches([{ captures: { email: 'a@b.com' } }] as any, ['t1']) + ).rejects.toThrow('storage error'); + }); +}); + +describe('exportQuickClipsConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns config with searchTerms, tools, templates, and version', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedStorage.getSearchTerms.mockResolvedValue([{ id: '1' }] as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedStorage.getQuickTools.mockResolvedValue([{ id: '2' }] as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedStorage.getTemplates.mockResolvedValue([{ id: '3' }] as any); + + const result = await exportQuickClipsConfig(); + + expect(result).toEqual({ + searchTerms: [{ id: '1' }], + tools: [{ id: '2' }], + templates: [{ id: '3' }], + version: '1.0.0', + }); + }); + + it('throws when storage fails', async () => { + mockedStorage.getSearchTerms.mockRejectedValue(new Error('fail')); + await expect(exportQuickClipsConfig()).rejects.toThrow('fail'); + }); +}); + +describe('importQuickClipsConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('delegates to storage.importQuickClipsConfig', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const config = { searchTerms: [], tools: [], templates: [] } as any; + await importQuickClipsConfig(config); + expect(mockedStorage.importQuickClipsConfig).toHaveBeenCalledWith(config); + }); + + it('throws when storage fails', async () => { + mockedStorage.importQuickClipsConfig.mockRejectedValue(new Error('import fail')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(importQuickClipsConfig({} as any)).rejects.toThrow('import fail'); + }); +}); diff --git a/src/main/clipboard/quick-clips.ts b/src/main/clipboard/quick-clips.ts index f33fd0f..7ee26d3 100644 --- a/src/main/clipboard/quick-clips.ts +++ b/src/main/clipboard/quick-clips.ts @@ -1,5 +1,6 @@ +import { shell } from 'electron'; import { storage } from '../storage'; -import type { PatternMatch } from '../../shared/types'; +import type { PatternMatch, QuickClipsConfig } from '../../shared/types'; // Quick clips scanning functions export const scanTextForPatterns = async (text: string): Promise => { @@ -47,9 +48,8 @@ export const scanTextForPatterns = async (text: string): Promise } }; -export const openToolsForMatches = async (matches: any[], toolIds: string[]) => { +export const openToolsForMatches = async (matches: PatternMatch[], toolIds: string[]) => { try { - const { shell } = require('electron'); const tools = await storage.getQuickTools(); for (const toolId of toolIds) { @@ -113,7 +113,6 @@ export const openToolsForMatches = async (matches: any[], toolIds: string[]) => } else { // Get all combinations of values const generateCombinations = (replacements: typeof tokenReplacements): string[] => { - if (replacements.length === 0) return ['']; if (replacements.length === 1) { const replacement = replacements[0]; return replacement.values.map((value) => { @@ -175,7 +174,7 @@ export const exportQuickClipsConfig = async () => { } }; -export const importQuickClipsConfig = async (config: any) => { +export const importQuickClipsConfig = async (config: QuickClipsConfig) => { try { // Use the new batch import method to avoid race conditions await storage.importQuickClipsConfig(config); diff --git a/src/main/clipboard/quick-tools.test.ts b/src/main/clipboard/quick-tools.test.ts new file mode 100644 index 0000000..e860578 --- /dev/null +++ b/src/main/clipboard/quick-tools.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../storage', () => ({ + storage: { + getQuickTools: vi.fn(), + createQuickTool: vi.fn(), + updateQuickTool: vi.fn(), + deleteQuickTool: vi.fn(), + reorderQuickTools: vi.fn(), + }, +})); + +import { + getAllQuickTools, + createQuickTool, + updateQuickTool, + deleteQuickTool, + reorderQuickTools, + validateToolUrl, +} from './quick-tools'; +import { storage } from '../storage'; + +const mockedStorage = vi.mocked(storage); + +describe('validateToolUrl', () => { + it('returns valid for a proper URL with matching capture groups', async () => { + const result = await validateToolUrl('https://example.com/search?q={query}', ['query']); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('returns error for invalid URL format', async () => { + const result = await validateToolUrl('not-a-url', []); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Invalid URL format'); + }); + + it('returns error when URL token is not in capture groups list', async () => { + const result = await validateToolUrl('https://example.com/{email}', ['phone']); + expect(result.isValid).toBe(false); + expect(result.errors.some((e) => e.includes("'{email}'"))).toBe(true); + }); + + it('accepts URL with no tokens', async () => { + const result = await validateToolUrl('https://example.com/page', []); + expect(result.isValid).toBe(true); + }); + + it('validates multiple tokens', async () => { + const result = await validateToolUrl('https://example.com/{name}/{email}', ['name', 'email']); + expect(result.isValid).toBe(true); + }); + + it('reports all missing capture groups', async () => { + const result = await validateToolUrl('https://example.com/{a}/{b}', []); + expect(result.isValid).toBe(false); + expect(result.errors).toHaveLength(2); + }); + + it('throws when an unexpected error occurs', async () => { + // Force an error by passing null as url + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await expect(validateToolUrl(null as any, [])).rejects.toThrow(); + }); +}); + +describe('getAllQuickTools', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns tools from storage', async () => { + const tools = [{ id: '1', name: 'Tool' }]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedStorage.getQuickTools.mockResolvedValue(tools as any); + const result = await getAllQuickTools(); + expect(result).toEqual(tools); + }); + + it('throws when storage fails', async () => { + mockedStorage.getQuickTools.mockRejectedValue(new Error('fail')); + await expect(getAllQuickTools()).rejects.toThrow('fail'); + }); +}); + +describe('createQuickTool', () => { + beforeEach(() => vi.clearAllMocks()); + + it('delegates to storage.createQuickTool', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedStorage.createQuickTool.mockResolvedValue({ id: '1' } as any); + const result = await createQuickTool('Test', 'https://example.com/{q}', ['q']); + expect(mockedStorage.createQuickTool).toHaveBeenCalledWith('Test', 'https://example.com/{q}', [ + 'q', + ]); + expect(result).toEqual({ id: '1' }); + }); + + it('throws when storage fails', async () => { + mockedStorage.createQuickTool.mockRejectedValue(new Error('fail')); + await expect(createQuickTool('Test', 'url', [])).rejects.toThrow('fail'); + }); +}); + +describe('updateQuickTool', () => { + beforeEach(() => vi.clearAllMocks()); + + it('delegates to storage.updateQuickTool', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockedStorage.updateQuickTool.mockResolvedValue({ id: '1' } as any); + await updateQuickTool('1', { name: 'Updated' }); + expect(mockedStorage.updateQuickTool).toHaveBeenCalledWith('1', { name: 'Updated' }); + }); + + it('throws when storage fails', async () => { + mockedStorage.updateQuickTool.mockRejectedValue(new Error('fail')); + await expect(updateQuickTool('1', {})).rejects.toThrow('fail'); + }); +}); + +describe('deleteQuickTool', () => { + beforeEach(() => vi.clearAllMocks()); + + it('delegates to storage.deleteQuickTool', async () => { + mockedStorage.deleteQuickTool.mockResolvedValue(undefined); + await deleteQuickTool('1'); + expect(mockedStorage.deleteQuickTool).toHaveBeenCalledWith('1'); + }); + + it('throws when storage fails', async () => { + mockedStorage.deleteQuickTool.mockRejectedValue(new Error('fail')); + await expect(deleteQuickTool('1')).rejects.toThrow('fail'); + }); +}); + +describe('reorderQuickTools', () => { + beforeEach(() => vi.clearAllMocks()); + + it('delegates to storage.reorderQuickTools', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tools = [{ id: '1' }, { id: '2' }] as any; + mockedStorage.reorderQuickTools.mockResolvedValue(undefined); + await reorderQuickTools(tools); + expect(mockedStorage.reorderQuickTools).toHaveBeenCalledWith(tools); + }); + + it('throws when storage fails', async () => { + mockedStorage.reorderQuickTools.mockRejectedValue(new Error('fail')); + await expect(reorderQuickTools([])).rejects.toThrow('fail'); + }); +}); diff --git a/src/main/clipboard/quick-tools.ts b/src/main/clipboard/quick-tools.ts index d8665f3..c219d92 100644 --- a/src/main/clipboard/quick-tools.ts +++ b/src/main/clipboard/quick-tools.ts @@ -1,4 +1,5 @@ import { storage } from '../storage'; +import type { QuickTool } from '../../shared/types'; // Quick tools management functions export const getAllQuickTools = async () => { @@ -19,7 +20,7 @@ export const createQuickTool = async (name: string, url: string, captureGroups: } }; -export const updateQuickTool = async (id: string, updates: any) => { +export const updateQuickTool = async (id: string, updates: Partial) => { try { return await storage.updateQuickTool(id, updates); } catch (error) { @@ -37,7 +38,7 @@ export const deleteQuickTool = async (id: string) => { } }; -export const reorderQuickTools = async (tools: any[]) => { +export const reorderQuickTools = async (tools: QuickTool[]) => { try { await storage.reorderQuickTools(tools); } catch (error) { diff --git a/src/main/clipboard/search-terms.ts b/src/main/clipboard/search-terms.ts index 60e8f21..e960963 100644 --- a/src/main/clipboard/search-terms.ts +++ b/src/main/clipboard/search-terms.ts @@ -1,5 +1,5 @@ import { storage } from '../storage'; -import type { PatternMatch } from '../../shared/types'; +import type { PatternMatch, SearchTerm } from '../../shared/types'; const RESERVED_GROUP_NAME = /^c\d+$/; @@ -40,7 +40,7 @@ export const createSearchTerm = async (name: string, pattern: string) => { } }; -export const updateSearchTerm = async (id: string, updates: any) => { +export const updateSearchTerm = async (id: string, updates: Partial) => { try { if (updates.pattern) { validateCaptureGroupNames(updates.pattern); @@ -61,7 +61,7 @@ export const deleteSearchTerm = async (id: string) => { } }; -export const reorderSearchTerms = async (searchTerms: any[]) => { +export const reorderSearchTerms = async (searchTerms: SearchTerm[]) => { try { await storage.reorderSearchTerms(searchTerms); } catch (error) { diff --git a/src/main/clipboard/storage-integration.ts b/src/main/clipboard/storage-integration.ts index 955f9fc..bba0d67 100644 --- a/src/main/clipboard/storage-integration.ts +++ b/src/main/clipboard/storage-integration.ts @@ -1,8 +1,8 @@ import { storage } from '../storage'; -import type { ClipItem } from '../../shared/types'; +import type { ClipItem, StoredClip, UserSettings } from '../../shared/types'; // Storage integration functions -export const getClips = async (): Promise => { +export const getClips = async (): Promise => { try { return await storage.getClips(); } catch (error) { @@ -24,16 +24,16 @@ export const saveClips = async ( } }; -export const getSettings = async (): Promise => { +export const getSettings = async (): Promise => { try { return await storage.getSettings(); } catch (error) { console.error('Failed to get settings from storage:', error); - return {}; + return {} as UserSettings; } }; -export const saveSettings = async (settings: any): Promise => { +export const saveSettings = async (settings: UserSettings): Promise => { try { await storage.saveSettings(settings); return true; diff --git a/src/main/clipboard/templates.ts b/src/main/clipboard/templates.ts index 51d7fbb..bdb8df8 100644 --- a/src/main/clipboard/templates.ts +++ b/src/main/clipboard/templates.ts @@ -1,4 +1,5 @@ import { storage } from '../storage'; +import type { Template } from '../../shared/types'; // Template management functions export const getAllTemplates = async () => { @@ -19,7 +20,7 @@ export const createTemplate = async (name: string, content: string) => { } }; -export const updateTemplate = async (id: string, updates: any) => { +export const updateTemplate = async (id: string, updates: Partial