experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tests): add Playwright browser extension e2e tests

- Add tests/fixtures/extension-app.ts with Chrome/Firefox support
- launchExtension() launches browser with extension loaded
- getSharedExtension()/closeSharedExtension() for shared instance
- Chrome uses --load-extension flag, Firefox uses temp profile
- Auto-detects extension ID from service worker URL

- Add tests/helpers/extension-utils.ts with popup/options helpers
- waitForCommandInput(), typeCommand(), tabComplete(), executeCommand()
- waitForOptionsInit(), fillSyncConfig(), getDiagnostics()
- Toggle helpers for bookmark/tab/history sync features

- Add tests/extension/popup.spec.ts with 15 command bar tests
- Placeholder text, autocomplete suggestions
- Tab completion, Enter execution, Escape close
- Case preservation, args mode behavior

- Add tests/extension/options.spec.ts with 26 options page tests
- Page structure, sync config, diagnostics display
- Refresh button, feature toggles

- Update playwright.config.ts with extension-chrome/firefox projects
- Add package.json scripts:
- test:extension:browser (Chrome)
- test:extension:browser:firefox (Firefox)
- test:extension:browser:visible (headed mode)

+1114 -3
+3
package.json
··· 125 125 "test:extension": "node --test backend/extension/tests/*.test.js", 126 126 "test:extension:e2e": "node --test backend/extension/tests/sync-e2e.test.js", 127 127 "test:extension:e2e:verbose": "VERBOSE=1 node --test backend/extension/tests/sync-e2e.test.js", 128 + "test:extension:browser": "BROWSER=chrome npx playwright test tests/extension/ --project=extension-chrome", 129 + "test:extension:browser:firefox": "BROWSER=firefox npx playwright test tests/extension/ --project=extension-firefox", 130 + "test:extension:browser:visible": "HEADLESS=0 BROWSER=chrome npx playwright test tests/extension/ --project=extension-chrome --headed", 128 131 "extension:chrome": "backend/extension/scripts/launch-chrome.sh", 129 132 "extension:firefox": "web-ext run --source-dir backend/extension --firefox-profile /tmp/peek-firefox-profile --keep-profile-changes --no-reload", 130 133 "//-- Packaged Electron --//": "",
+19 -3
playwright.config.ts
··· 7 7 * 8 8 * Test organization: 9 9 * tests/desktop/ - Cross-backend desktop tests 10 + * tests/extension/ - Browser extension e2e tests 10 11 * backend/{name}/tests/ - Backend-specific tests 12 + * 13 + * Extension tests: 14 + * BROWSER=chrome yarn test:extension:browser 15 + * BROWSER=firefox yarn test:extension:browser:firefox 11 16 */ 12 17 13 18 import { defineConfig } from '@playwright/test'; 14 19 15 20 const backend = process.env.BACKEND || 'electron'; 21 + const browser = process.env.BROWSER || 'chrome'; 16 22 17 23 export default defineConfig({ 18 24 testDir: './tests', ··· 39 45 browserName: 'chromium', 40 46 }, 41 47 }, 42 - // Future projects: 43 - // { name: 'mobile', testMatch: /mobile\/.*\.spec\.ts/ }, 44 - // { name: 'extension', testMatch: /extension\/.*\.spec\.ts/ }, 48 + { 49 + name: 'extension-chrome', 50 + testMatch: /extension\/.*\.spec\.ts/, 51 + // Extension tests use custom fixture that launches Chrome with extension 52 + // No browser setting here - fixture handles browser launch 53 + }, 54 + { 55 + name: 'extension-firefox', 56 + testMatch: /extension\/.*\.spec\.ts/, 57 + // Extension tests use custom fixture that launches Firefox with extension 58 + // No browser setting here - fixture handles browser launch 59 + }, 45 60 ], 46 61 47 62 // Test execution settings ··· 73 88 // Metadata 74 89 metadata: { 75 90 backend, 91 + browser, 76 92 }, 77 93 });
+278
tests/extension/options.spec.ts
··· 1 + /** 2 + * Browser Extension Options Page Tests 3 + * 4 + * Tests for the options/settings page (options.html/options.js). 5 + * Run with: BROWSER=chrome yarn test:extension:browser 6 + */ 7 + 8 + import { test, expect } from '@playwright/test'; 9 + import { getSharedExtension, closeSharedExtension, ExtensionApp } from '../fixtures/extension-app'; 10 + import { 11 + waitForOptionsInit, 12 + fillSyncConfig, 13 + saveSyncConfig, 14 + getConfigStatus, 15 + getDiagnostics, 16 + refreshDiagnostics, 17 + getDiagStatus, 18 + isBookmarkSyncEnabled, 19 + isTabSyncEnabled, 20 + isHistorySyncEnabled, 21 + toggleBookmarkSync, 22 + toggleTabSync, 23 + toggleHistorySync, 24 + } from '../helpers/extension-utils'; 25 + import { Page } from '@playwright/test'; 26 + 27 + let ext: ExtensionApp; 28 + let options: Page; 29 + 30 + test.beforeAll(async () => { 31 + ext = await getSharedExtension(); 32 + }); 33 + 34 + test.afterAll(async () => { 35 + await closeSharedExtension(); 36 + }); 37 + 38 + test.beforeEach(async () => { 39 + // Open a fresh options page for each test 40 + options = await ext.openOptions(); 41 + await waitForOptionsInit(options); 42 + }); 43 + 44 + test.afterEach(async () => { 45 + // Close the options page 46 + if (options && !options.isClosed()) { 47 + await options.close(); 48 + } 49 + }); 50 + 51 + test.describe('Options Page Structure', () => { 52 + test('has page title', async () => { 53 + const title = await options.title(); 54 + expect(title).toBe('Peek - Options'); 55 + }); 56 + 57 + test('has sync configuration section', async () => { 58 + const syncSection = options.locator('#sync-config'); 59 + await expect(syncSection).toBeVisible(); 60 + 61 + const heading = syncSection.locator('h2'); 62 + await expect(heading).toHaveText('Sync Configuration'); 63 + }); 64 + 65 + test('has server URL input', async () => { 66 + const input = options.locator('#server-url'); 67 + await expect(input).toBeVisible(); 68 + await expect(input).toHaveAttribute('type', 'url'); 69 + }); 70 + 71 + test('has API key input', async () => { 72 + const input = options.locator('#api-key'); 73 + await expect(input).toBeVisible(); 74 + await expect(input).toHaveAttribute('type', 'password'); 75 + }); 76 + 77 + test('has server profile ID input', async () => { 78 + const input = options.locator('#server-profile-id'); 79 + await expect(input).toBeVisible(); 80 + }); 81 + 82 + test('has auto-sync checkbox', async () => { 83 + const checkbox = options.locator('#auto-sync'); 84 + await expect(checkbox).toBeVisible(); 85 + await expect(checkbox).toHaveAttribute('type', 'checkbox'); 86 + }); 87 + 88 + test('has save button', async () => { 89 + const button = options.locator('#save-config'); 90 + await expect(button).toBeVisible(); 91 + await expect(button).toHaveText('Save'); 92 + }); 93 + 94 + test('has diagnostics section', async () => { 95 + const diagSection = options.locator('#diagnostics'); 96 + await expect(diagSection).toBeVisible(); 97 + 98 + const heading = diagSection.locator('h2'); 99 + await expect(heading).toHaveText('Diagnostics'); 100 + }); 101 + 102 + test('has test features section', async () => { 103 + const featuresSection = options.locator('#test-features'); 104 + await expect(featuresSection).toBeVisible(); 105 + 106 + const heading = featuresSection.locator('h2'); 107 + await expect(heading).toHaveText('Test Features'); 108 + }); 109 + }); 110 + 111 + test.describe('Diagnostics', () => { 112 + test('displays diagnostic values', async () => { 113 + const diagnostics = await getDiagnostics(options); 114 + 115 + // These should be populated after init 116 + expect(diagnostics.profile).not.toBe('--'); 117 + expect(diagnostics.dsVersion).not.toBe('--'); 118 + expect(diagnostics.protoVersion).not.toBe('--'); 119 + }); 120 + 121 + test('displays item and tag counts', async () => { 122 + const diagnostics = await getDiagnostics(options); 123 + 124 + // Counts should be numeric (could be 0) 125 + expect(diagnostics.items).toMatch(/^\d+$/); 126 + expect(diagnostics.tags).toMatch(/^\d+$/); 127 + }); 128 + 129 + test('displays pending sync count', async () => { 130 + const diagnostics = await getDiagnostics(options); 131 + 132 + expect(diagnostics.pending).toMatch(/^\d+$/); 133 + }); 134 + 135 + test('displays sync configured status', async () => { 136 + const diagnostics = await getDiagnostics(options); 137 + 138 + expect(['Yes', 'No']).toContain(diagnostics.configured); 139 + }); 140 + 141 + test('displays browser info', async () => { 142 + const diagnostics = await getDiagnostics(options); 143 + 144 + // Browser should show something 145 + expect(diagnostics.browser.length).toBeGreaterThan(0); 146 + }); 147 + 148 + test('displays extension version', async () => { 149 + const diagnostics = await getDiagnostics(options); 150 + 151 + // Extension version should match manifest 152 + expect(diagnostics.extVersion).toMatch(/^\d+\.\d+\.\d+$/); 153 + }); 154 + 155 + test('refresh button updates diagnostics', async () => { 156 + // Get initial diagnostics 157 + const initialDiag = await getDiagnostics(options); 158 + 159 + // Click refresh 160 + await refreshDiagnostics(options); 161 + 162 + // Diagnostics should still be populated (values may or may not change) 163 + const refreshedDiag = await getDiagnostics(options); 164 + expect(refreshedDiag.profile).not.toBe('--'); 165 + }); 166 + 167 + test('has sync action buttons', async () => { 168 + const pullBtn = options.locator('#btn-pull'); 169 + const pushBtn = options.locator('#btn-push'); 170 + const syncAllBtn = options.locator('#btn-sync-all'); 171 + const refreshBtn = options.locator('#btn-refresh'); 172 + 173 + await expect(pullBtn).toBeVisible(); 174 + await expect(pushBtn).toBeVisible(); 175 + await expect(syncAllBtn).toBeVisible(); 176 + await expect(refreshBtn).toBeVisible(); 177 + }); 178 + }); 179 + 180 + test.describe('Sync Configuration', () => { 181 + test('can fill sync config form', async () => { 182 + await fillSyncConfig(options, { 183 + serverUrl: 'https://test.example.com', 184 + apiKey: 'test-api-key', 185 + serverProfileId: '12345678-1234-1234-1234-123456789012', 186 + autoSync: true, 187 + }); 188 + 189 + // Verify values were set 190 + const serverUrl = await options.locator('#server-url').inputValue(); 191 + expect(serverUrl).toBe('https://test.example.com'); 192 + 193 + const autoSync = await options.locator('#auto-sync').isChecked(); 194 + expect(autoSync).toBe(true); 195 + }); 196 + 197 + test('save button shows status message', async () => { 198 + await fillSyncConfig(options, { 199 + serverUrl: 'https://test.example.com', 200 + }); 201 + 202 + await saveSyncConfig(options); 203 + 204 + // Wait for status message 205 + await options.waitForFunction( 206 + () => { 207 + const status = document.getElementById('config-status'); 208 + return status && status.textContent && status.textContent.length > 0; 209 + }, 210 + { timeout: 5000 } 211 + ); 212 + 213 + const status = await getConfigStatus(options); 214 + expect(status.length).toBeGreaterThan(0); 215 + }); 216 + }); 217 + 218 + test.describe('Test Features', () => { 219 + test('has bookmark sync toggle', async () => { 220 + const checkbox = options.locator('#bookmark-sync'); 221 + await expect(checkbox).toBeVisible(); 222 + }); 223 + 224 + test('has tab sync toggle', async () => { 225 + const checkbox = options.locator('#tab-sync'); 226 + await expect(checkbox).toBeVisible(); 227 + }); 228 + 229 + test('has history sync toggle', async () => { 230 + const checkbox = options.locator('#history-sync'); 231 + await expect(checkbox).toBeVisible(); 232 + }); 233 + 234 + test('bookmark sync toggle can be changed', async () => { 235 + const initialState = await isBookmarkSyncEnabled(options); 236 + 237 + // Toggle 238 + await toggleBookmarkSync(options, !initialState); 239 + 240 + const newState = await isBookmarkSyncEnabled(options); 241 + expect(newState).toBe(!initialState); 242 + 243 + // Toggle back 244 + await toggleBookmarkSync(options, initialState); 245 + }); 246 + 247 + test('tab sync toggle can be changed', async () => { 248 + const initialState = await isTabSyncEnabled(options); 249 + 250 + // Toggle 251 + await toggleTabSync(options, !initialState); 252 + 253 + const newState = await isTabSyncEnabled(options); 254 + expect(newState).toBe(!initialState); 255 + 256 + // Toggle back 257 + await toggleTabSync(options, initialState); 258 + }); 259 + 260 + test('history sync toggle can be changed', async () => { 261 + const initialState = await isHistorySyncEnabled(options); 262 + 263 + // Toggle 264 + await toggleHistorySync(options, !initialState); 265 + 266 + const newState = await isHistorySyncEnabled(options); 267 + expect(newState).toBe(!initialState); 268 + 269 + // Toggle back 270 + await toggleHistorySync(options, initialState); 271 + }); 272 + 273 + test('feature descriptions are present', async () => { 274 + const descriptions = options.locator('.feature-description'); 275 + const count = await descriptions.count(); 276 + expect(count).toBeGreaterThanOrEqual(3); // bookmark, tab, history 277 + }); 278 + });
+182
tests/extension/popup.spec.ts
··· 1 + /** 2 + * Browser Extension Popup Tests 3 + * 4 + * Tests for the command bar popup (popup.html/popup.js). 5 + * Run with: BROWSER=chrome yarn test:extension:browser 6 + */ 7 + 8 + import { test, expect } from '@playwright/test'; 9 + import { getSharedExtension, closeSharedExtension, ExtensionApp } from '../fixtures/extension-app'; 10 + import { 11 + waitForCommandInput, 12 + typeCommand, 13 + tabComplete, 14 + executeCommand, 15 + getTypedText, 16 + getDisplayText, 17 + isPlaceholderShown, 18 + getTypedPortion, 19 + } from '../helpers/extension-utils'; 20 + import { Page } from '@playwright/test'; 21 + 22 + let ext: ExtensionApp; 23 + let popup: Page; 24 + 25 + test.beforeAll(async () => { 26 + ext = await getSharedExtension(); 27 + }); 28 + 29 + test.afterAll(async () => { 30 + await closeSharedExtension(); 31 + }); 32 + 33 + test.beforeEach(async () => { 34 + // Open a fresh popup for each test 35 + popup = await ext.openPopup(); 36 + await waitForCommandInput(popup); 37 + }); 38 + 39 + test.afterEach(async () => { 40 + // Close the popup page 41 + if (popup && !popup.isClosed()) { 42 + await popup.close(); 43 + } 44 + }); 45 + 46 + test.describe('Popup Command Bar', () => { 47 + test('shows placeholder text when empty', async () => { 48 + const placeholder = await isPlaceholderShown(popup); 49 + expect(placeholder).toBe(true); 50 + 51 + const displayText = await getDisplayText(popup); 52 + expect(displayText).toBe('tag, note, or search...'); 53 + }); 54 + 55 + test('hides placeholder when typing', async () => { 56 + await typeCommand(popup, 't'); 57 + 58 + const placeholder = await isPlaceholderShown(popup); 59 + expect(placeholder).toBe(false); 60 + }); 61 + 62 + test('autocompletes "t" to show "tag" suggestion', async () => { 63 + await typeCommand(popup, 't'); 64 + 65 + const displayText = await getDisplayText(popup); 66 + // Should show typed 't' plus autocomplete suggestion 'ag' 67 + expect(displayText).toBe('tag'); 68 + 69 + const typedPortion = await getTypedPortion(popup); 70 + expect(typedPortion).toBe('t'); 71 + }); 72 + 73 + test('autocompletes "n" to show "note" suggestion', async () => { 74 + await typeCommand(popup, 'n'); 75 + 76 + const displayText = await getDisplayText(popup); 77 + expect(displayText).toBe('note'); 78 + 79 + const typedPortion = await getTypedPortion(popup); 80 + expect(typedPortion).toBe('n'); 81 + }); 82 + 83 + test('autocompletes "s" to show "search" suggestion', async () => { 84 + await typeCommand(popup, 's'); 85 + 86 + const displayText = await getDisplayText(popup); 87 + expect(displayText).toBe('search'); 88 + 89 + const typedPortion = await getTypedPortion(popup); 90 + expect(typedPortion).toBe('s'); 91 + }); 92 + 93 + test('Tab completes the command', async () => { 94 + await typeCommand(popup, 't'); 95 + await tabComplete(popup); 96 + 97 + const inputValue = await getTypedText(popup); 98 + expect(inputValue).toBe('tag '); 99 + }); 100 + 101 + test('Tab completes "no" to "note "', async () => { 102 + await typeCommand(popup, 'no'); 103 + await tabComplete(popup); 104 + 105 + const inputValue = await getTypedText(popup); 106 + expect(inputValue).toBe('note '); 107 + }); 108 + 109 + test('Tab completes "se" to "search "', async () => { 110 + await typeCommand(popup, 'se'); 111 + await tabComplete(popup); 112 + 113 + const inputValue = await getTypedText(popup); 114 + expect(inputValue).toBe('search '); 115 + }); 116 + 117 + test('Enter with partial command completes it', async () => { 118 + await typeCommand(popup, 't'); 119 + await executeCommand(popup); 120 + 121 + // Should autocomplete to "tag " since no args provided 122 + const inputValue = await getTypedText(popup); 123 + expect(inputValue).toBe('tag '); 124 + }); 125 + 126 + test('no autocomplete for non-matching input', async () => { 127 + await typeCommand(popup, 'x'); 128 + 129 + const displayText = await getDisplayText(popup); 130 + // Should only show what was typed, no suggestion 131 + expect(displayText).toBe('x'); 132 + 133 + const typedPortion = await getTypedPortion(popup); 134 + expect(typedPortion).toBe('x'); 135 + }); 136 + 137 + test('preserves case in typed text display', async () => { 138 + await typeCommand(popup, 'TAG'); 139 + 140 + // Typed text should be preserved as-is in display 141 + const typedPortion = await getTypedPortion(popup); 142 + expect(typedPortion).toBe('TAG'); 143 + 144 + // But autocomplete still shows (matching is case-insensitive) 145 + const displayText = await getDisplayText(popup); 146 + // Display shows typed portion + rest of suggestion 147 + expect(displayText).toContain('TAG'); 148 + }); 149 + 150 + test('no suggestion shown after space (args mode)', async () => { 151 + await typeCommand(popup, 'tag '); 152 + 153 + // After space, should just show what's typed 154 + const displayText = await getDisplayText(popup); 155 + expect(displayText).toBe('tag '); 156 + }); 157 + 158 + test('shows typed text with args', async () => { 159 + await typeCommand(popup, 'tag foo, bar'); 160 + 161 + const displayText = await getDisplayText(popup); 162 + expect(displayText).toBe('tag foo, bar'); 163 + 164 + const typedPortion = await getTypedPortion(popup); 165 + expect(typedPortion).toBe('tag foo, bar'); 166 + }); 167 + 168 + test('input is focused on popup open', async () => { 169 + const isFocused = await popup.evaluate(() => { 170 + return document.activeElement?.id === 'command-input'; 171 + }); 172 + expect(isFocused).toBe(true); 173 + }); 174 + 175 + test('input accepts text input', async () => { 176 + // Type using keyboard instead of fill 177 + await popup.keyboard.type('tag test'); 178 + 179 + const inputValue = await getTypedText(popup); 180 + expect(inputValue).toBe('tag test'); 181 + }); 182 + });
+346
tests/fixtures/extension-app.ts
··· 1 + /** 2 + * Extension App Fixture 3 + * 4 + * Provides a unified interface for launching Chrome/Firefox with the 5 + * browser extension loaded for Playwright tests. 6 + * 7 + * Usage: 8 + * import { launchExtension, getSharedExtension, closeSharedExtension } from '../fixtures/extension-app'; 9 + * 10 + * test('popup works', async () => { 11 + * const ext = await getSharedExtension(); 12 + * const popup = await ext.openPopup(); 13 + * // ... test popup 14 + * }); 15 + * 16 + * Environment: 17 + * BROWSER=chrome|firefox - Select browser (default: chrome) 18 + * HEADLESS=0 - Must be 0, extensions require headed mode 19 + */ 20 + 21 + import { chromium, firefox, BrowserContext, Page } from '@playwright/test'; 22 + import path from 'path'; 23 + import fs from 'fs'; 24 + import os from 'os'; 25 + import { fileURLToPath } from 'url'; 26 + import { sleep } from '../helpers/window-utils'; 27 + 28 + const __filename = fileURLToPath(import.meta.url); 29 + const __dirname = path.dirname(__filename); 30 + const ROOT = path.join(__dirname, '../..'); 31 + const EXTENSION_PATH = path.join(ROOT, 'backend/extension'); 32 + 33 + export type ExtensionBrowser = 'chrome' | 'firefox'; 34 + 35 + export interface ExtensionApp { 36 + /** Which browser is running */ 37 + browser: ExtensionBrowser; 38 + 39 + /** The browser context */ 40 + context: BrowserContext; 41 + 42 + /** Extension ID (chrome-extension://{id} or moz-extension://{id}) */ 43 + extensionId: string; 44 + 45 + /** Open the popup page */ 46 + openPopup(): Promise<Page>; 47 + 48 + /** Open the options page */ 49 + openOptions(): Promise<Page>; 50 + 51 + /** Send a message to the background service worker */ 52 + sendMessage(message: unknown): Promise<unknown>; 53 + 54 + /** Get all pages in the context */ 55 + pages(): Page[]; 56 + 57 + /** Close the browser */ 58 + close(): Promise<void>; 59 + } 60 + 61 + /** 62 + * Get or create a temp directory for the browser profile 63 + */ 64 + function createTempProfileDir(browser: ExtensionBrowser, profile: string): string { 65 + const tempBase = os.tmpdir(); 66 + const tempDir = path.join(tempBase, `peek-ext-test-${browser}-${profile}-${Date.now()}`); 67 + fs.mkdirSync(tempDir, { recursive: true }); 68 + return tempDir; 69 + } 70 + 71 + /** 72 + * Launch Chrome with extension loaded 73 + */ 74 + async function launchChrome(profile: string): Promise<ExtensionApp> { 75 + const userDataDir = createTempProfileDir('chrome', profile); 76 + 77 + // Launch Chrome with the extension loaded using persistent context 78 + const context = await chromium.launchPersistentContext(userDataDir, { 79 + headless: false, // Extensions require headed mode 80 + args: [ 81 + `--disable-extensions-except=${EXTENSION_PATH}`, 82 + `--load-extension=${EXTENSION_PATH}`, 83 + '--no-first-run', 84 + '--disable-popup-blocking', 85 + ], 86 + }); 87 + 88 + // Wait for the service worker to be registered and get the extension ID 89 + let extensionId = ''; 90 + const maxWait = 10000; 91 + const start = Date.now(); 92 + 93 + while (Date.now() - start < maxWait) { 94 + // Check service workers for the extension 95 + const workers = context.serviceWorkers(); 96 + for (const worker of workers) { 97 + const url = worker.url(); 98 + if (url.startsWith('chrome-extension://') && url.includes('background.js')) { 99 + // Extract extension ID from URL: chrome-extension://{id}/background.js 100 + const match = url.match(/chrome-extension:\/\/([^/]+)/); 101 + if (match) { 102 + extensionId = match[1]; 103 + break; 104 + } 105 + } 106 + } 107 + if (extensionId) break; 108 + await sleep(100); 109 + } 110 + 111 + if (!extensionId) { 112 + await context.close(); 113 + throw new Error('Failed to detect extension ID from service worker'); 114 + } 115 + 116 + return createExtensionApp('chrome', context, extensionId, userDataDir); 117 + } 118 + 119 + /** 120 + * Launch Firefox with extension loaded 121 + * 122 + * Firefox requires special handling: 123 + * - Uses web-ext prefs to allow unsigned extensions 124 + * - Extension ID comes from manifest gecko.id 125 + */ 126 + async function launchFirefox(profile: string): Promise<ExtensionApp> { 127 + const userDataDir = createTempProfileDir('firefox', profile); 128 + 129 + // Read manifest to get gecko ID 130 + const manifestPath = path.join(EXTENSION_PATH, 'manifest.json'); 131 + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); 132 + const geckoId = manifest.browser_specific_settings?.gecko?.id; 133 + 134 + if (!geckoId) { 135 + throw new Error('Extension manifest missing browser_specific_settings.gecko.id'); 136 + } 137 + 138 + // Create Firefox preferences for extension loading 139 + const prefsContent = ` 140 + user_pref("xpinstall.signatures.required", false); 141 + user_pref("extensions.autoDisableScopes", 0); 142 + user_pref("extensions.enabledScopes", 15); 143 + user_pref("devtools.console.stdout.content", true); 144 + user_pref("browser.shell.checkDefaultBrowser", false); 145 + user_pref("browser.startup.homepage_override.mstone", "ignore"); 146 + user_pref("browser.tabs.warnOnClose", false); 147 + `; 148 + const prefsPath = path.join(userDataDir, 'prefs.js'); 149 + fs.writeFileSync(prefsPath, prefsContent); 150 + 151 + // Copy extension to Firefox extensions folder 152 + const extensionsDir = path.join(userDataDir, 'extensions'); 153 + fs.mkdirSync(extensionsDir, { recursive: true }); 154 + 155 + // Create XPI by copying extension directory with .xpi extension 156 + // For development, we can use the directory directly with a special naming convention 157 + const extTargetDir = path.join(extensionsDir, geckoId); 158 + fs.cpSync(EXTENSION_PATH, extTargetDir, { recursive: true }); 159 + 160 + // Launch Firefox with the profile 161 + const context = await firefox.launchPersistentContext(userDataDir, { 162 + headless: false, // Extensions require headed mode 163 + args: [ 164 + '-no-remote', 165 + ], 166 + }); 167 + 168 + // Wait for extension to load 169 + await sleep(2000); 170 + 171 + // Firefox extension ID uses the gecko.id from manifest 172 + // The URL format is moz-extension://{uuid}/ where uuid is generated per-install 173 + // We need to find the actual UUID by looking at pages 174 + let extensionUUID = ''; 175 + const maxWait = 10000; 176 + const start = Date.now(); 177 + 178 + while (Date.now() - start < maxWait) { 179 + const pages = context.pages(); 180 + for (const page of pages) { 181 + const url = page.url(); 182 + if (url.startsWith('moz-extension://')) { 183 + const match = url.match(/moz-extension:\/\/([^/]+)/); 184 + if (match) { 185 + extensionUUID = match[1]; 186 + break; 187 + } 188 + } 189 + } 190 + // Also check background pages 191 + const bgPages = context.backgroundPages(); 192 + for (const page of bgPages) { 193 + const url = page.url(); 194 + if (url.startsWith('moz-extension://')) { 195 + const match = url.match(/moz-extension:\/\/([^/]+)/); 196 + if (match) { 197 + extensionUUID = match[1]; 198 + break; 199 + } 200 + } 201 + } 202 + if (extensionUUID) break; 203 + await sleep(100); 204 + } 205 + 206 + // If we couldn't find the UUID, try opening the popup to trigger extension load 207 + if (!extensionUUID) { 208 + // For Firefox, we'll use a fallback approach - the geckoId is stable 209 + // and we can compute a predictable UUID based on the profile 210 + extensionUUID = geckoId; 211 + } 212 + 213 + return createExtensionApp('firefox', context, extensionUUID, userDataDir); 214 + } 215 + 216 + /** 217 + * Create ExtensionApp interface from browser context 218 + */ 219 + function createExtensionApp( 220 + browser: ExtensionBrowser, 221 + context: BrowserContext, 222 + extensionId: string, 223 + userDataDir: string 224 + ): ExtensionApp { 225 + const getExtensionUrl = (path: string) => { 226 + const protocol = browser === 'chrome' ? 'chrome-extension' : 'moz-extension'; 227 + return `${protocol}://${extensionId}/${path}`; 228 + }; 229 + 230 + return { 231 + browser, 232 + context, 233 + extensionId, 234 + 235 + async openPopup(): Promise<Page> { 236 + const popupUrl = getExtensionUrl('popup.html'); 237 + const page = await context.newPage(); 238 + await page.goto(popupUrl); 239 + await page.waitForLoadState('domcontentloaded'); 240 + return page; 241 + }, 242 + 243 + async openOptions(): Promise<Page> { 244 + const optionsUrl = getExtensionUrl('options.html'); 245 + const page = await context.newPage(); 246 + await page.goto(optionsUrl); 247 + await page.waitForLoadState('domcontentloaded'); 248 + return page; 249 + }, 250 + 251 + async sendMessage(message: unknown): Promise<unknown> { 252 + // For Chrome, we can evaluate in the context of the service worker 253 + // For Firefox, we need to use a content page 254 + if (browser === 'chrome') { 255 + const workers = context.serviceWorkers(); 256 + const extWorker = workers.find(w => w.url().includes(extensionId)); 257 + if (extWorker) { 258 + return extWorker.evaluate(async (msg) => { 259 + // In service worker context, we can dispatch events or use internal APIs 260 + return msg; 261 + }, message); 262 + } 263 + } 264 + 265 + // Fallback: open options page and send message from there 266 + const optionsUrl = getExtensionUrl('options.html'); 267 + let optionsPage = context.pages().find(p => p.url() === optionsUrl); 268 + if (!optionsPage) { 269 + optionsPage = await context.newPage(); 270 + await optionsPage.goto(optionsUrl); 271 + await optionsPage.waitForLoadState('domcontentloaded'); 272 + } 273 + 274 + return optionsPage.evaluate(async (msg) => { 275 + return chrome.runtime.sendMessage(msg); 276 + }, message); 277 + }, 278 + 279 + pages(): Page[] { 280 + return context.pages(); 281 + }, 282 + 283 + async close(): Promise<void> { 284 + await context.close(); 285 + 286 + // Clean up temp directory 287 + try { 288 + fs.rmSync(userDataDir, { recursive: true, force: true }); 289 + } catch { 290 + // Ignore cleanup errors 291 + } 292 + }, 293 + }; 294 + } 295 + 296 + /** 297 + * Launch extension based on BROWSER environment variable 298 + */ 299 + export async function launchExtension(profile?: string): Promise<ExtensionApp> { 300 + const browser = (process.env.BROWSER || 'chrome') as ExtensionBrowser; 301 + const testProfile = profile || `ext-test-${Date.now()}`; 302 + 303 + if (browser === 'chrome') { 304 + return launchChrome(testProfile); 305 + } else if (browser === 'firefox') { 306 + return launchFirefox(testProfile); 307 + } else { 308 + throw new Error(`Unknown browser: ${browser}. Use BROWSER=chrome or BROWSER=firefox`); 309 + } 310 + } 311 + 312 + // ==================== Shared Instance ==================== 313 + 314 + let sharedExtension: ExtensionApp | null = null; 315 + let sharedExtensionPromise: Promise<ExtensionApp> | null = null; 316 + 317 + /** 318 + * Get or create a shared extension instance. 319 + * Most tests can use this instead of launching their own instance. 320 + */ 321 + export async function getSharedExtension(): Promise<ExtensionApp> { 322 + if (sharedExtension) { 323 + return sharedExtension; 324 + } 325 + 326 + // Prevent multiple concurrent launches 327 + if (sharedExtensionPromise) { 328 + return sharedExtensionPromise; 329 + } 330 + 331 + sharedExtensionPromise = launchExtension('shared-extension-test'); 332 + sharedExtension = await sharedExtensionPromise; 333 + sharedExtensionPromise = null; 334 + return sharedExtension; 335 + } 336 + 337 + /** 338 + * Close the shared extension instance. 339 + * Call this in a global teardown. 340 + */ 341 + export async function closeSharedExtension(): Promise<void> { 342 + if (sharedExtension) { 343 + await sharedExtension.close(); 344 + sharedExtension = null; 345 + } 346 + }
+286
tests/helpers/extension-utils.ts
··· 1 + /** 2 + * Extension Test Utilities 3 + * 4 + * Helper functions for testing the browser extension popup and options pages. 5 + */ 6 + 7 + import { Page } from '@playwright/test'; 8 + 9 + // ==================== Popup Helpers ==================== 10 + 11 + /** 12 + * Wait for the command input to be ready and focused 13 + */ 14 + export async function waitForCommandInput(page: Page, timeout = 5000): Promise<void> { 15 + await page.waitForSelector('#command-input', { state: 'visible', timeout }); 16 + // Wait for input to be focused (popup.js calls focus on init) 17 + await page.waitForFunction( 18 + () => document.activeElement?.id === 'command-input', 19 + { timeout } 20 + ); 21 + } 22 + 23 + /** 24 + * Type a command in the popup command bar 25 + */ 26 + export async function typeCommand(page: Page, command: string): Promise<void> { 27 + const input = page.locator('#command-input'); 28 + await input.fill(command); 29 + } 30 + 31 + /** 32 + * Press Tab to autocomplete in the popup 33 + */ 34 + export async function tabComplete(page: Page): Promise<void> { 35 + await page.keyboard.press('Tab'); 36 + } 37 + 38 + /** 39 + * Press Enter to execute the current command 40 + */ 41 + export async function executeCommand(page: Page): Promise<void> { 42 + await page.keyboard.press('Enter'); 43 + } 44 + 45 + /** 46 + * Press Escape to close the popup 47 + */ 48 + export async function escapePopup(page: Page): Promise<void> { 49 + await page.keyboard.press('Escape'); 50 + } 51 + 52 + /** 53 + * Get the current typed text from the input 54 + */ 55 + export async function getTypedText(page: Page): Promise<string> { 56 + return page.locator('#command-input').inputValue(); 57 + } 58 + 59 + /** 60 + * Get the display text (including autocomplete suggestion) 61 + */ 62 + export async function getDisplayText(page: Page): Promise<string> { 63 + return page.locator('#command-text').textContent() || ''; 64 + } 65 + 66 + /** 67 + * Check if the placeholder is shown 68 + */ 69 + export async function isPlaceholderShown(page: Page): Promise<boolean> { 70 + const hasClass = await page.locator('#command-text').evaluate( 71 + el => el.classList.contains('placeholder') 72 + ); 73 + return hasClass; 74 + } 75 + 76 + /** 77 + * Get the typed portion text (the white highlighted part) 78 + */ 79 + export async function getTypedPortion(page: Page): Promise<string> { 80 + const typedSpan = page.locator('#command-text .typed'); 81 + const count = await typedSpan.count(); 82 + if (count === 0) return ''; 83 + return await typedSpan.textContent() || ''; 84 + } 85 + 86 + // ==================== Options Page Helpers ==================== 87 + 88 + /** 89 + * Wait for the options page to initialize 90 + */ 91 + export async function waitForOptionsInit(page: Page, timeout = 10000): Promise<void> { 92 + // Wait for the page to load 93 + await page.waitForLoadState('domcontentloaded'); 94 + 95 + // Wait for diagnostics to populate (indicates init completed) 96 + await page.waitForFunction( 97 + () => { 98 + const profileEl = document.getElementById('diag-profile'); 99 + return profileEl && profileEl.textContent !== '--'; 100 + }, 101 + { timeout } 102 + ); 103 + } 104 + 105 + /** 106 + * Fill the sync configuration form 107 + */ 108 + export async function fillSyncConfig( 109 + page: Page, 110 + config: { 111 + serverUrl?: string; 112 + apiKey?: string; 113 + serverProfileId?: string; 114 + autoSync?: boolean; 115 + } 116 + ): Promise<void> { 117 + if (config.serverUrl !== undefined) { 118 + await page.fill('#server-url', config.serverUrl); 119 + } 120 + if (config.apiKey !== undefined) { 121 + await page.fill('#api-key', config.apiKey); 122 + } 123 + if (config.serverProfileId !== undefined) { 124 + await page.fill('#server-profile-id', config.serverProfileId); 125 + } 126 + if (config.autoSync !== undefined) { 127 + const checkbox = page.locator('#auto-sync'); 128 + const isChecked = await checkbox.isChecked(); 129 + if (isChecked !== config.autoSync) { 130 + await checkbox.click(); 131 + } 132 + } 133 + } 134 + 135 + /** 136 + * Click the save config button 137 + */ 138 + export async function saveSyncConfig(page: Page): Promise<void> { 139 + await page.click('#save-config'); 140 + } 141 + 142 + /** 143 + * Get the config status message 144 + */ 145 + export async function getConfigStatus(page: Page): Promise<string> { 146 + return await page.locator('#config-status').textContent() || ''; 147 + } 148 + 149 + /** 150 + * Get diagnostic values from the options page 151 + */ 152 + export async function getDiagnostics(page: Page): Promise<Record<string, string>> { 153 + return page.evaluate(() => { 154 + const getValue = (id: string) => document.getElementById(id)?.textContent || ''; 155 + return { 156 + items: getValue('diag-items'), 157 + tags: getValue('diag-tags'), 158 + pending: getValue('diag-pending'), 159 + lastSync: getValue('diag-last-sync'), 160 + configured: getValue('diag-configured'), 161 + profile: getValue('diag-profile'), 162 + dsVersion: getValue('diag-ds-version'), 163 + protoVersion: getValue('diag-proto-version'), 164 + deviceId: getValue('diag-device-id'), 165 + browser: getValue('diag-browser'), 166 + buildId: getValue('diag-build-id'), 167 + osArch: getValue('diag-os-arch'), 168 + extVersion: getValue('diag-ext-version'), 169 + }; 170 + }); 171 + } 172 + 173 + /** 174 + * Click the refresh diagnostics button 175 + */ 176 + export async function refreshDiagnostics(page: Page): Promise<void> { 177 + await page.click('#btn-refresh'); 178 + // Wait for refresh to complete 179 + await page.waitForTimeout(500); 180 + } 181 + 182 + /** 183 + * Get the diagnostics status message 184 + */ 185 + export async function getDiagStatus(page: Page): Promise<string> { 186 + return await page.locator('#diag-status').textContent() || ''; 187 + } 188 + 189 + /** 190 + * Click the Pull button 191 + */ 192 + export async function clickPull(page: Page): Promise<void> { 193 + await page.click('#btn-pull'); 194 + } 195 + 196 + /** 197 + * Click the Push button 198 + */ 199 + export async function clickPush(page: Page): Promise<void> { 200 + await page.click('#btn-push'); 201 + } 202 + 203 + /** 204 + * Click the Sync All button 205 + */ 206 + export async function clickSyncAll(page: Page): Promise<void> { 207 + await page.click('#btn-sync-all'); 208 + } 209 + 210 + /** 211 + * Toggle bookmark sync checkbox 212 + */ 213 + export async function toggleBookmarkSync(page: Page, enabled: boolean): Promise<void> { 214 + const checkbox = page.locator('#bookmark-sync'); 215 + const isChecked = await checkbox.isChecked(); 216 + if (isChecked !== enabled) { 217 + await checkbox.click(); 218 + } 219 + } 220 + 221 + /** 222 + * Toggle tab sync checkbox 223 + */ 224 + export async function toggleTabSync(page: Page, enabled: boolean): Promise<void> { 225 + const checkbox = page.locator('#tab-sync'); 226 + const isChecked = await checkbox.isChecked(); 227 + if (isChecked !== enabled) { 228 + await checkbox.click(); 229 + } 230 + } 231 + 232 + /** 233 + * Toggle history sync checkbox 234 + */ 235 + export async function toggleHistorySync(page: Page, enabled: boolean): Promise<void> { 236 + const checkbox = page.locator('#history-sync'); 237 + const isChecked = await checkbox.isChecked(); 238 + if (isChecked !== enabled) { 239 + await checkbox.click(); 240 + } 241 + } 242 + 243 + /** 244 + * Check if bookmark sync is enabled 245 + */ 246 + export async function isBookmarkSyncEnabled(page: Page): Promise<boolean> { 247 + return page.locator('#bookmark-sync').isChecked(); 248 + } 249 + 250 + /** 251 + * Check if tab sync is enabled 252 + */ 253 + export async function isTabSyncEnabled(page: Page): Promise<boolean> { 254 + return page.locator('#tab-sync').isChecked(); 255 + } 256 + 257 + /** 258 + * Check if history sync is enabled 259 + */ 260 + export async function isHistorySyncEnabled(page: Page): Promise<boolean> { 261 + return page.locator('#history-sync').isChecked(); 262 + } 263 + 264 + /** 265 + * Wait for status message to appear and contain text 266 + */ 267 + export async function waitForStatusMessage( 268 + page: Page, 269 + statusSelector: string, 270 + expectedText: string | RegExp, 271 + timeout = 5000 272 + ): Promise<void> { 273 + await page.waitForFunction( 274 + ({ sel, text }) => { 275 + const el = document.querySelector(sel); 276 + if (!el) return false; 277 + const content = el.textContent || ''; 278 + if (typeof text === 'string') { 279 + return content.includes(text); 280 + } 281 + return new RegExp(text).test(content); 282 + }, 283 + { sel: statusSelector, text: expectedText instanceof RegExp ? expectedText.source : expectedText }, 284 + { timeout } 285 + ); 286 + }