Social Annotations in the Atmosphere
15
fork

Configure Feed

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

whole bunch of fixes

+556 -648
.chainlink/issues.db

This is a binary file and will not be displayed.

+11 -3
AGENTS.md
··· 131 131 import { configureOAuth } from "@atcute/oauth-browser-client"; 132 132 import type { OAuthSession } from "@atcute/oauth-browser-client"; 133 133 134 - // Workspace imports 135 - import { BrowserStorageAdapter } from '@seams/core'; 134 + // Workspace imports - use subpath imports to avoid side effects 135 + import { BrowserStorageAdapter } from '@seams/core/storage'; 136 + import { normalizeUrl } from '@seams/core/utils'; 136 137 137 - // Relative imports for internal modules 138 + // Relative imports for internal modules (within packages/core) 138 139 import type { StorageAdapter } from '../storage/adapter'; 139 140 ``` 141 + 142 + **IMPORTANT**: Do NOT import from `@seams/core` directly - it imports `actor-typeahead` which auto-registers a web component and can break builds. Always use subpath imports: 143 + - `@seams/core/storage` - Storage adapters 144 + - `@seams/core/background` - Background worker logic 145 + - `@seams/core/content` - Content script logic 146 + - `@seams/core/sidebar` - Sidebar UI logic 147 + - `@seams/core/utils` - Utilities (normalizeUrl, highlights, selectors) 140 148 141 149 ### Error Handling 142 150 ```typescript
+2 -1
entrypoints/background.ts
··· 1 - import { BrowserStorageAdapter, ExtensionBackgroundWorker } from '@seams/core'; 1 + import { BrowserStorageAdapter } from '@seams/core/storage'; 2 + import { ExtensionBackgroundWorker } from '@seams/core/background'; 2 3 3 4 const BACKEND_URL = import.meta.env.BACKEND_URL || 'http://localhost:8080'; 4 5 // Firefox needs context menu since it can't open sidebar from content script click
+3 -1
entrypoints/content.ts
··· 1 - import { BrowserStorageAdapter, ExtensionContentScript, generateSelectors, applyHighlights, clearHighlights } from '@seams/core'; 1 + import { BrowserStorageAdapter } from '@seams/core/storage'; 2 + import { ExtensionContentScript } from '@seams/core/content'; 3 + import { generateSelectors, applyHighlights, clearHighlights } from '@seams/core/utils'; 2 4 3 5 // Chrome supports opening sidepanel from content script click via sendMessage 4 6 // Firefox does not preserve user gesture through messages
+8
entrypoints/sidepanel/index.html
··· 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>Seams</title> 7 + <style> 8 + html, body, #app { 9 + margin: 0; 10 + padding: 0; 11 + height: 100%; 12 + overflow: hidden; 13 + } 14 + </style> 7 15 </head> 8 16 <body> 9 17 <div id="app"></div>
+2 -2
entrypoints/sidepanel/main.ts
··· 28 28 backendUrl: import.meta.env.VITE_BACKEND_URL || import.meta.env.BACKEND_URL || 'https://seams.so', 29 29 }, 30 30 }; 31 - sidebar.onSyncNeeded = () => { 32 - browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 31 + sidebar.onSyncNeeded = async () => { 32 + await browser.runtime.sendMessage({ type: 'SYNC_CACHE' }); 33 33 }; 34 34 35 35 // Add to DOM - this triggers initialization
+2 -2
package.json
··· 4 4 "description": "Web annotation extension built on AT Protocol", 5 5 "main": "index.js", 6 6 "scripts": { 7 - "dev": "wxt", 7 + "dev": "concurrently \"pnpm dev:server\" \"wxt\"", 8 8 "build": "wxt build", 9 9 "zip": "wxt zip -b firefox && wxt zip -b chrome", 10 10 "build:landing": "vite build --config vite.landing.config.ts", ··· 18 18 "test:server": "cd server && go test -v ./...", 19 19 "test:server:coverage": "cd server && go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html", 20 20 "test:e2e": "playwright test", 21 - "test:e2e:extension": "RUN_EXTENSION_TESTS=1 node --env-file=tests/.env.test ./node_modules/@playwright/test/cli.js test --config=tests/playwright.config.ts --project=chrome-extension", 21 + "test:e2e:extension": "wxt build -m development && pnpm build:landing && RUN_EXTENSION_TESTS=1 node --env-file=tests/.env.test ./node_modules/@playwright/test/cli.js test --config=tests/playwright.config.ts --project=chrome-extension", 22 22 "test:e2e:proxy": "RUN_PROXY_TESTS=1 node --env-file=tests/.env.test ./node_modules/@playwright/test/cli.js test --config=tests/playwright.config.ts --project=chrome-proxy", 23 23 "test:all": "pnpm test && pnpm test:server" 24 24 },
+2 -1
packages/core/package.json
··· 8 8 "./storage": "./src/storage/index.ts", 9 9 "./background": "./src/background/index.ts", 10 10 "./content": "./src/content/index.ts", 11 - "./sidebar": "./src/sidebar/index.ts" 11 + "./sidebar": "./src/sidebar/index.ts", 12 + "./utils": "./src/utils/index.ts" 12 13 }, 13 14 "scripts": { 14 15 "test": "vitest run",
-10
packages/core/src/background/__tests__/extension.test.ts
··· 79 79 expect(browser.runtime.onMessage.addListener).toHaveBeenCalled(); 80 80 }); 81 81 82 - it('sets panel behavior to open on action click', async () => { 83 - await worker.start(); 84 - 85 - // Verify setPanelBehavior is called with openPanelOnActionClick: true 86 - // This ensures clicking the extension icon opens the side panel 87 - expect(browser.sidePanel.setPanelBehavior).toHaveBeenCalledWith({ 88 - openPanelOnActionClick: true, 89 - }); 90 - }); 91 - 92 82 it('registers context menu for Firefox when useContextMenu is true', async () => { 93 83 const firefoxWorker = new ExtensionBackgroundWorker({ 94 84 storage: mockStorage as unknown as StorageAdapter,
+14 -6
packages/core/src/background/extension.ts
··· 112 112 browser.runtime.onMessage.addListener((message: any, sender: any, sendResponse: any) => { 113 113 if (message.type === 'SYNC_CACHE') { 114 114 console.log('[background] SYNC_CACHE requested - triggering fetch for active tab'); 115 - browser.tabs.query({ active: true, currentWindow: true }).then((tabs: any[]) => { 116 - if (tabs[0]?.url) { 117 - const normalized = normalizeUrl(tabs[0].url); 118 - this.worker.syncAnnotationsForUrl(normalized); 115 + // Use async IIFE and return true to keep message channel open 116 + (async () => { 117 + try { 118 + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); 119 + if (tabs[0]?.url) { 120 + const normalized = normalizeUrl(tabs[0].url); 121 + await this.worker.syncAnnotationsForUrl(normalized); 122 + } 123 + sendResponse({ success: true }); 124 + } catch (error) { 125 + console.error('[background] SYNC_CACHE failed:', error); 126 + sendResponse({ success: false, error: String(error) }); 119 127 } 120 - }); 121 - sendResponse({ success: true }); 128 + })(); 129 + return true; // Keep message channel open for async response 122 130 } else if (message.type === 'ACTIVATE_ANNOTATION') { 123 131 console.log('[background] ACTIVATE_ANNOTATION received'); 124 132 if (sender.tab?.id) {
+7
packages/core/src/components/sidebar.ts
··· 149 149 } 150 150 151 151 /** 152 + * Get the current URL (property getter for test compatibility) 153 + */ 154 + get currentUrl(): string { 155 + return this._sidebar?.getCurrentUrl() || ''; 156 + } 157 + 158 + /** 152 159 * Get the current URL 153 160 */ 154 161 getCurrentUrl(): string {
-161
packages/core/src/content/__tests__/base.test.ts
··· 404 404 }) 405 405 ); 406 406 }); 407 - 408 - // NOTE: Full MutationObserver debounce behavior is tested in E2E tests 409 - // as happy-dom doesn't fully simulate MutationObserver callbacks. 410 - // See tests/e2e/extension/highlights.spec.ts for integration tests. 411 - // 412 - // The Firefox memory bug (infinite loop from applyHighlights triggering 413 - // MutationObserver which calls loadAndRenderHighlights) is mitigated by: 414 - // 1. 500ms debounce in setupDomObserver (base.ts:120-124) 415 - // 2. clearHighlights before applyHighlights (base.ts:48) 416 - // 3. Filtering out mutations to our own elements (base.ts:112-131) 417 - 418 - it('ignores mutations to seams-highlight elements (regression test for infinite loop)', async () => { 419 - // Regression test: Adding highlight spans should NOT trigger re-render 420 - // Previously, MutationObserver would fire when we added highlights, 421 - // causing loadAndRenderHighlights() to be called again in an infinite loop 422 - 423 - const annotations = [ 424 - { 425 - uri: 'test:1', 426 - value: { target: { url: 'https://example.com/page' }, body: 'Test' }, 427 - }, 428 - ]; 429 - mockAdapter.storage.get.mockResolvedValue(annotations); 430 - 431 - await contentScript.start(); 432 - 433 - // Reset after initial render 434 - mockAdapter.clearHighlights.mockClear(); 435 - mockAdapter.applyHighlights.mockClear(); 436 - 437 - // Simulate adding our own highlight elements (what applyHighlights does) 438 - const highlightSpan = document.createElement('span'); 439 - highlightSpan.className = 'seams-highlight'; 440 - highlightSpan.textContent = 'Highlighted text'; 441 - document.body.appendChild(highlightSpan); 442 - 443 - // Wait for debounce timeout 444 - await vi.advanceTimersByTimeAsync(600); 445 - 446 - // Should NOT trigger a re-render since it's our own element 447 - expect(mockAdapter.clearHighlights).not.toHaveBeenCalled(); 448 - expect(mockAdapter.applyHighlights).not.toHaveBeenCalled(); 449 - 450 - // Cleanup 451 - highlightSpan.remove(); 452 - }); 453 - 454 - it('ignores mutations to seams-popover elements (regression test for infinite loop)', async () => { 455 - // Regression test: Adding/modifying popover elements should NOT trigger re-render 456 - 457 - const annotations = [ 458 - { 459 - uri: 'test:1', 460 - value: { target: { url: 'https://example.com/page' }, body: 'Test' }, 461 - }, 462 - ]; 463 - mockAdapter.storage.get.mockResolvedValue(annotations); 464 - 465 - await contentScript.start(); 466 - 467 - // Reset after initial render 468 - mockAdapter.clearHighlights.mockClear(); 469 - mockAdapter.applyHighlights.mockClear(); 470 - 471 - // Simulate adding a popover element 472 - const popover = document.createElement('div'); 473 - popover.className = 'seams-popover'; 474 - popover.textContent = 'Popover content'; 475 - document.body.appendChild(popover); 476 - 477 - // Wait for debounce timeout 478 - await vi.advanceTimersByTimeAsync(600); 479 - 480 - // Should NOT trigger a re-render since it's our own element 481 - expect(mockAdapter.clearHighlights).not.toHaveBeenCalled(); 482 - expect(mockAdapter.applyHighlights).not.toHaveBeenCalled(); 483 - 484 - // Cleanup 485 - popover.remove(); 486 - }); 487 - 488 - it('ignores mutations inside seams-highlight elements (regression test for infinite loop)', async () => { 489 - // Regression test: Mutations inside our highlight elements should be ignored 490 - 491 - const annotations = [ 492 - { 493 - uri: 'test:1', 494 - value: { target: { url: 'https://example.com/page' }, body: 'Test' }, 495 - }, 496 - ]; 497 - mockAdapter.storage.get.mockResolvedValue(annotations); 498 - 499 - await contentScript.start(); 500 - 501 - // Add a highlight element first (without triggering observer for this test setup) 502 - const highlightSpan = document.createElement('span'); 503 - highlightSpan.className = 'seams-highlight'; 504 - document.body.appendChild(highlightSpan); 505 - 506 - // Wait for any pending mutations to settle 507 - await vi.advanceTimersByTimeAsync(600); 508 - 509 - // Reset after setup 510 - mockAdapter.clearHighlights.mockClear(); 511 - mockAdapter.applyHighlights.mockClear(); 512 - 513 - // Now add content inside the highlight (simulating dynamic content) 514 - const innerSpan = document.createElement('span'); 515 - innerSpan.textContent = 'Inner content'; 516 - highlightSpan.appendChild(innerSpan); 517 - 518 - // Wait for debounce timeout 519 - await vi.advanceTimersByTimeAsync(600); 520 - 521 - // Should NOT trigger a re-render since mutation is inside our element 522 - expect(mockAdapter.clearHighlights).not.toHaveBeenCalled(); 523 - expect(mockAdapter.applyHighlights).not.toHaveBeenCalled(); 524 - 525 - // Cleanup 526 - highlightSpan.remove(); 527 - }); 528 - 529 - // NOTE: This behavior is tested in E2E tests (tests/e2e/proxy/highlights.spec.ts) 530 - // because happy-dom doesn't properly simulate MutationObserver disconnect/reconnect. 531 - // The 'highlights update when navigating to different URL' E2E test validates this behavior. 532 - it.skip('still triggers re-render for non-seams DOM mutations', async () => { 533 - // Verify that legitimate DOM changes still trigger re-renders 534 - 535 - const annotations = [ 536 - { 537 - uri: 'test:1', 538 - value: { target: { url: 'https://example.com/page' }, body: 'Test' }, 539 - }, 540 - ]; 541 - mockAdapter.storage.get.mockResolvedValue(annotations); 542 - 543 - await contentScript.start(); 544 - 545 - // Allow requestAnimationFrame to reconnect observer after initial render 546 - // Need to run pending RAF callbacks 547 - await vi.runAllTimersAsync(); 548 - 549 - // Reset after initial render 550 - mockAdapter.clearHighlights.mockClear(); 551 - mockAdapter.applyHighlights.mockClear(); 552 - 553 - // Simulate a regular DOM change (like SPA navigation or lazy loading) 554 - const regularDiv = document.createElement('div'); 555 - regularDiv.className = 'page-content'; 556 - regularDiv.textContent = 'New page content'; 557 - document.body.appendChild(regularDiv); 558 - 559 - // Wait for debounce timeout 560 - await vi.advanceTimersByTimeAsync(600); 561 - 562 - // SHOULD trigger a re-render for regular content changes 563 - expect(mockAdapter.clearHighlights).toHaveBeenCalledTimes(1); 564 - 565 - // Cleanup 566 - regularDiv.remove(); 567 - }); 568 407 }); 569 408 });
+97 -7
packages/core/src/content/base.ts
··· 3 3 import type { Annotation } from '../types'; 4 4 import { normalizeUrl } from '../utils'; 5 5 6 + /** 7 + * Known extension class/id patterns to ignore in mutation observer. 8 + * These are browser extensions that may modify the DOM during user interactions 9 + * (like text selection) but shouldn't trigger highlight re-renders. 10 + */ 11 + const IGNORABLE_PATTERNS = [ 12 + /^seams-/, // Our own elements 13 + /^tridactyl/i, // Tridactyl (Firefox Vim) 14 + /^vimium-/, // Vimium (Chrome Vim) 15 + /^grammarly-/, // Grammarly 16 + /^_Ej/, // Grammarly internal classes 17 + /^lp-/, // LastPass 18 + /^lpx-/, // LastPass 19 + /^1p-/, // 1Password 20 + /^ublock-/, // uBlock Origin 21 + ]; 22 + 23 + /** 24 + * Data attributes used by browser extensions that indicate an element should be ignored. 25 + */ 26 + const IGNORABLE_DATA_ATTRS = [ 27 + 'data-gramm', 28 + 'data-gramm-id', 29 + 'data-lpignore', 30 + 'data-1p-ignore', 31 + ]; 32 + 33 + /** 34 + * Check if a node should be ignored by the mutation observer. 35 + * This includes our own seams UI elements and elements from other browser extensions. 36 + * Walks up the DOM tree looking for ignorable patterns. 37 + */ 38 + function isIgnorableElement(node: Node): boolean { 39 + // Check if element is in shadow DOM (extensions often use this for isolation) 40 + if (node.getRootNode() !== document) { 41 + return true; 42 + } 43 + 44 + let current: Node | null = node; 45 + 46 + while (current && current !== document.body) { 47 + if (current instanceof Element) { 48 + // Check class names against all patterns 49 + const className = current.className; 50 + if (className && typeof className === 'string') { 51 + const classes = className.split(/\s+/); 52 + for (const cls of classes) { 53 + for (const pattern of IGNORABLE_PATTERNS) { 54 + if (pattern.test(cls)) { 55 + return true; 56 + } 57 + } 58 + } 59 + } 60 + 61 + // Check id against all patterns 62 + const id = current.id; 63 + if (id) { 64 + for (const pattern of IGNORABLE_PATTERNS) { 65 + if (pattern.test(id)) { 66 + return true; 67 + } 68 + } 69 + } 70 + 71 + // Check for extension data attributes 72 + for (const attr of IGNORABLE_DATA_ATTRS) { 73 + if (current.hasAttribute(attr)) { 74 + return true; 75 + } 76 + } 77 + } 78 + 79 + current = current.parentNode; 80 + } 81 + 82 + return false; 83 + } 84 + 6 85 export interface ContentScriptAdapter { 7 86 storage: StorageAdapter; 8 87 getCurrentUrl: () => string; ··· 27 106 async start(): Promise<void> { 28 107 console.log('[content] BaseContentScript starting...'); 29 108 30 - // Set up selection tracking IMMEDIATELY - don't block on annotations 31 109 this.setupSelectionTracking(); 32 - 33 - // Watch for DOM changes (SPAs/lazy loading) 34 110 this.setupDomObserver(); 35 - 36 - // Listen for storage changes (for future updates) 37 111 this.adapter.storage.onChange(({ key }) => { 38 112 if (key === 'annotations') { 39 113 console.log('[content] Annotations cache updated, re-rendering'); ··· 41 115 } 42 116 }); 43 117 44 - // Load highlights in background - don't block 45 118 this.loadAndRenderHighlights().catch(err => { 46 119 console.error('[content] Failed to load initial highlights:', err); 47 120 }); ··· 126 199 private setupDomObserver() { 127 200 this.domObserver = new MutationObserver((mutations) => { 128 201 let shouldRender = false; 202 + 129 203 for (const mutation of mutations) { 130 - if (mutation.addedNodes.length > 0 || mutation.type === 'characterData') { 204 + // Skip mutations inside seams elements or browser extension elements 205 + if (isIgnorableElement(mutation.target)) { 206 + continue; 207 + } 208 + 209 + if (mutation.type === 'characterData') { 131 210 shouldRender = true; 132 211 break; 212 + } 213 + 214 + if (mutation.addedNodes.length > 0) { 215 + // Check if any added node is NOT a seams/extension element 216 + for (const node of mutation.addedNodes) { 217 + if (!isIgnorableElement(node)) { 218 + shouldRender = true; 219 + break; 220 + } 221 + } 222 + if (shouldRender) break; 133 223 } 134 224 } 135 225
+24 -27
packages/core/src/content/proxy.ts
··· 11 11 applyHighlights: (annotations: Annotation[], storage: StorageAdapter) => void; 12 12 clearHighlights: () => void; 13 13 generateSelectors: (selection: Selection, root: Element) => any[]; 14 - onAnnotate: (data: { text: string; selectors: any[] }) => void; 15 - onSelectionChange?: (selection: { text: string; selectors: any[] } | null) => void; 14 + onAnnotate: (data: { text: string; selectors: any[] }) => void; 15 + onSelectionChange?: (selection: { text: string; selectors: any[] } | null) => void; 16 16 } 17 17 18 18 export class ProxyContentScript extends BaseContentScript { 19 - private uiManager: AnnotationUIManager; 19 + private uiManager: AnnotationUIManager; 20 20 21 21 constructor(options: ProxyContentScriptOptions) { 22 - // Detect mobile - consistent with extension 23 - const isMobile = window.innerWidth <= 768 || navigator.userAgent.includes('Android') || navigator.userAgent.includes('iPhone'); 24 - 22 + const isMobile = window.innerWidth <= 768 || navigator.userAgent.includes('Android') || navigator.userAgent.includes('iPhone'); 23 + 25 24 const adapter: ContentScriptAdapter = { 26 25 storage: options.storage, 27 26 getCurrentUrl: options.getCurrentUrl, ··· 29 28 clearHighlights: options.clearHighlights, 30 29 generateSelectors: options.generateSelectors || ((_s, _r) => []), 31 30 notifySelectionChange: (selection) => { 32 - // Notify listener (sidebar) 33 - if (options.onSelectionChange) { 34 - options.onSelectionChange(selection); 35 - } 31 + // Notify listener (sidebar) 32 + if (options.onSelectionChange) { 33 + options.onSelectionChange(selection); 34 + } 36 35 37 - // Handle floating UI 38 - if (selection && selection.text) { 39 - // Calculate selection rect 40 - const domSelection = window.getSelection(); 41 - if (domSelection && domSelection.rangeCount > 0) { 42 - const range = domSelection.getRangeAt(0); 43 - const rect = range.getBoundingClientRect(); 44 - // Pass range for scroll tracking - button will follow selection 45 - this.uiManager.showButton(rect, selection.text, selection.selectors, range); 46 - } 47 - } else { 48 - this.uiManager.removeButton(); 49 - } 36 + if (selection && selection.text) { 37 + const domSelection = window.getSelection(); 38 + if (domSelection && domSelection.rangeCount > 0) { 39 + const range = domSelection.getRangeAt(0); 40 + const rect = range.getBoundingClientRect(); 41 + // Pass range for scroll tracking - button will follow selection 42 + this.uiManager.showButton(rect, selection.text, selection.selectors, range); 43 + } 44 + } else { 45 + this.uiManager.removeButton(); 46 + } 50 47 } 51 48 }; 52 49 super(adapter); 53 50 54 - this.uiManager = new AnnotationUIManager({ 55 - isMobile, 56 - onAnnotate: options.onAnnotate 57 - }); 51 + this.uiManager = new AnnotationUIManager({ 52 + isMobile, 53 + onAnnotate: options.onAnnotate 54 + }); 58 55 } 59 56 }
-40
packages/core/src/sidebar/__tests__/utils.test.ts
··· 1 - import { describe, it, expect } from 'vitest'; 2 - import { normalizeUrl } from '../utils'; 3 - 4 - describe('sidebar normalizeUrl', () => { 5 - it('removes hash fragments', () => { 6 - expect(normalizeUrl('https://example.com/page#section')).toBe( 7 - 'https://example.com/page' 8 - ); 9 - }); 10 - 11 - it('removes trailing slashes from paths', () => { 12 - expect(normalizeUrl('https://example.com/page/')).toBe( 13 - 'https://example.com/page' 14 - ); 15 - }); 16 - 17 - it('preserves root path trailing slash', () => { 18 - expect(normalizeUrl('https://example.com/')).toBe('https://example.com/'); 19 - }); 20 - 21 - it('preserves query parameters', () => { 22 - expect(normalizeUrl('https://example.com/page?query=1')).toBe( 23 - 'https://example.com/page?query=1' 24 - ); 25 - }); 26 - 27 - it('handles URLs with both hash and trailing slash', () => { 28 - expect(normalizeUrl('https://example.com/page/#section')).toBe( 29 - 'https://example.com/page' 30 - ); 31 - }); 32 - 33 - it('returns original string for invalid URLs', () => { 34 - expect(normalizeUrl('not-a-url')).toBe('not-a-url'); 35 - }); 36 - 37 - it('handles empty string', () => { 38 - expect(normalizeUrl('')).toBe(''); 39 - }); 40 - });
+8 -7
packages/core/src/sidebar/index.ts
··· 5 5 import { PDSClient as PDSClientImpl } from '../pds'; 6 6 import type { Annotation, PendingAnnotation } from '../types'; 7 7 import { UIState } from './ui-state'; 8 - import { normalizeUrl } from './utils'; 8 + import { normalizeUrl } from '../utils'; 9 9 import { escapeHtml } from '../utils/sanitize'; 10 10 11 11 import { registerComponents } from '../components'; ··· 21 21 }; 22 22 } 23 23 24 - export type SyncCallback = () => void; 24 + export type SyncCallback = () => Promise<void> | void; 25 25 26 26 // Storage key and expiration for pending annotations 27 27 const PENDING_ANNOTATION_KEY = 'seams_pending_annotation'; ··· 356 356 </div> 357 357 <div class="profile-menu"> 358 358 ${this.session ? ` 359 - <img id="profile-avatar" style="display: ${profile?.avatar ? 'block' : 'none'}; width: 40px; height: 40px; border-radius: 50%; cursor: pointer;" ${profile?.avatar ? `src="${profile.avatar}"` : ''} /> 359 + <img id="profile-avatar" style="width: 40px; height: 40px; border-radius: 50%; cursor: pointer;" src="${profile?.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(this.session?.info?.sub || 'user')}`}" /> 360 360 <div id="profile-dropdown" class="profile-dropdown" style="display: none;"> 361 361 <button id="logout-btn">Logout</button> 362 362 </div> ··· 548 548 this.updateSelectionUI(); 549 549 this.clearPendingAnnotation(); 550 550 551 - // 4. Sync to get confirmed data from backend 552 - // This will trigger storage update -> loadAnnotationsForCurrentUrl -> renderAnnotations 553 - this.onSyncNeeded?.(); 551 + // 4. Sync to get confirmed data from backend and WAIT for completion 552 + // This ensures the annotation appears before we clear the saving state 553 + if (this.onSyncNeeded) { 554 + await this.onSyncNeeded(); 555 + } 554 556 555 557 } catch (error) { 556 558 console.error('[sidebar] Failed to create annotation:', error); ··· 670 672 } 671 673 672 674 export { UIState } from './ui-state'; 673 - export { normalizeUrl } from './utils';
-14
packages/core/src/sidebar/utils.ts
··· 1 - export function normalizeUrl(url: string): string { 2 - try { 3 - const parsed = new URL(url); 4 - parsed.hash = ''; 5 - let path = parsed.pathname; 6 - if (path.endsWith('/') && path !== '/') { 7 - path = path.slice(0, -1); 8 - } 9 - parsed.pathname = path; 10 - return parsed.toString(); 11 - } catch { 12 - return url; 13 - } 14 - }
+11
packages/core/src/storage/browser.ts
··· 43 43 } 44 44 45 45 async set(key: string, value: any): Promise<void> { 46 + const oldValue = await this.get(key); 46 47 await browser.storage.local.set({ [key]: value }); 48 + 49 + // Notify local listeners immediately (same behavior as WebStorageAdapter) 50 + // This ensures the calling context gets immediate notification without waiting 51 + // for the browser.storage.onChanged event, which can have timing issues. 52 + // Note: browser.storage.onChanged WILL also fire, potentially causing duplicate 53 + // notifications in the same context. However, this is safe because: 54 + // 1. The background worker (writer) doesn't register onChange listeners 55 + // 2. Sidepanel/content (readers) only listen, they don't write 56 + const change: StorageChange = { key, newValue: value, oldValue }; 57 + this.listeners.forEach(callback => callback(change)); 47 58 } 48 59 49 60 onChange(callback: (change: StorageChange) => void): void {
+1 -17
proxy/src/main.ts
··· 2 2 // This runs inside the wabac.js proxied iframe which cannot access localStorage 3 3 // Uses PostMessageStorageAdapter to request data from shell 4 4 import { PostMessageStorageAdapter, ProxyContentScript, applyHighlights, clearHighlights, generateSelectors, isAllowedOrigin } from '@seams/core'; 5 + import { normalizeUrl } from '@seams/core/utils'; 5 6 6 7 console.log('[seams-client] Seams client loaded!'); 7 8 ··· 57 58 }, shellOrigin); 58 59 } 59 60 }); 60 - 61 - function normalizeUrl(url: string): string { 62 - try { 63 - const parsed = new URL(url); 64 - // Remove fragment 65 - parsed.hash = ''; 66 - // Remove trailing slash 67 - let path = parsed.pathname; 68 - if (path.endsWith('/') && path !== '/') { 69 - path = path.slice(0, -1); 70 - } 71 - parsed.pathname = path; 72 - return parsed.toString(); 73 - } catch { 74 - return url; 75 - } 76 - } 77 61 78 62 function getActualUrl(): string { 79 63 const proxyUrl = window.location.href;
+3 -3
proxy/src/shell.ts
··· 197 197 backendUrl: BACKEND_URL, 198 198 }, 199 199 }; 200 - sidebarEl.onSyncNeeded = () => { 201 - // Sync callback - directly call backgroundWorker 200 + sidebarEl.onSyncNeeded = async () => { 201 + // Sync callback - directly call backgroundWorker and WAIT for completion 202 202 console.log('[shell] Sidebar requested sync'); 203 - backgroundWorker.forceSync(); 203 + await backgroundWorker.forceSync(); 204 204 }; 205 205 206 206 // Add to DOM - this triggers connectedCallback and initialization
server/server

This is a binary file and will not be displayed.

+256 -237
tests/helpers/extension.ts
··· 11 11 // See: https://github.com/microsoft/playwright/issues/26693 12 12 process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'; 13 13 14 - const EXTENSION_PATH = path.resolve(__dirname, '../../.output/chrome-mv3'); 14 + const EXTENSION_PATH = path.resolve(__dirname, '../../.output/chrome-mv3-dev'); 15 15 const GOLDEN_DB_PATH = path.resolve(__dirname, '../fixtures/golden-db/annotations.db'); 16 16 const SERVER_DB_PATH = path.resolve(__dirname, '../../server/db/annotations.db'); 17 17 ··· 20 20 * @param requireGoldenDb - If true, throws if golden database is missing. Default: false. 21 21 */ 22 22 export async function createExtensionContext(requireGoldenDb: boolean = false): Promise<BrowserContext> { 23 - // Verify extension is built 24 - if (!existsSync(EXTENSION_PATH)) { 25 - throw new Error( 26 - `Extension not found at ${EXTENSION_PATH}. Run 'pnpm build' first.` 27 - ); 28 - } 23 + // Verify extension is built 24 + if (!existsSync(EXTENSION_PATH)) { 25 + throw new Error( 26 + `Extension not found at ${EXTENSION_PATH}. Run 'pnpm build' first.` 27 + ); 28 + } 29 29 30 - // Copy golden database to server location for tests (if it exists) 31 - if (existsSync(GOLDEN_DB_PATH)) { 32 - mkdirSync(path.dirname(SERVER_DB_PATH), { recursive: true }); 33 - copyFileSync(GOLDEN_DB_PATH, SERVER_DB_PATH); 34 - } else if (requireGoldenDb) { 35 - throw new Error( 36 - `Golden database not found at ${GOLDEN_DB_PATH}. ` + 37 - `This test requires pre-seeded annotations. See tests/fixtures/GOLDEN_DATA.md for setup instructions.` 38 - ); 39 - } 30 + // Copy golden database to server location for tests (if it exists) 31 + if (existsSync(GOLDEN_DB_PATH)) { 32 + mkdirSync(path.dirname(SERVER_DB_PATH), { recursive: true }); 33 + copyFileSync(GOLDEN_DB_PATH, SERVER_DB_PATH); 34 + } else if (requireGoldenDb) { 35 + throw new Error( 36 + `Golden database not found at ${GOLDEN_DB_PATH}. ` + 37 + `This test requires pre-seeded annotations. See tests/fixtures/GOLDEN_DATA.md for setup instructions.` 38 + ); 39 + } 40 40 41 - // Launch Chrome with extension 42 - const context = await chromium.launchPersistentContext('', { 43 - headless: false, // Extensions require headed mode 44 - args: [ 45 - `--disable-extensions-except=${EXTENSION_PATH}`, 46 - `--load-extension=${EXTENSION_PATH}`, 47 - '--no-first-run', 48 - '--disable-default-apps', 49 - '--start-maximized', // Needed for proper side panel viewport with PW_CHROMIUM_ATTACH_TO_OTHER 50 - ], 51 - }); 41 + // Launch Chrome with extension 42 + const context = await chromium.launchPersistentContext('', { 43 + headless: false, // Extensions require headed mode 44 + args: [ 45 + `--disable-extensions-except=${EXTENSION_PATH}`, 46 + `--load-extension=${EXTENSION_PATH}`, 47 + '--no-first-run', 48 + '--disable-default-apps', 49 + '--start-maximized', // Needed for proper side panel viewport with PW_CHROMIUM_ATTACH_TO_OTHER 50 + ], 51 + }); 52 52 53 - return context; 53 + return context; 54 54 } 55 55 56 56 /** 57 57 * Gets the extension ID from the service worker URL 58 58 */ 59 59 export async function getExtensionId(context: BrowserContext): Promise<string> { 60 - // Wait for service worker to be registered 61 - const workers = context.serviceWorkers(); 62 - if (workers.length === 0) { 63 - await context.waitForEvent('serviceworker'); 64 - } 60 + // Wait for service worker to be registered 61 + const workers = context.serviceWorkers(); 62 + if (workers.length === 0) { 63 + await context.waitForEvent('serviceworker'); 64 + } 65 65 66 - const [worker] = context.serviceWorkers(); 67 - if (!worker) { 68 - throw new Error('Extension service worker not found'); 69 - } 66 + const [worker] = context.serviceWorkers(); 67 + if (!worker) { 68 + throw new Error('Extension service worker not found'); 69 + } 70 70 71 - // Extract extension ID from URL like: chrome-extension://abc123/background.js 72 - const match = worker.url().match(/chrome-extension:\/\/([^/]+)/); 73 - if (!match) { 74 - throw new Error('Could not extract extension ID from service worker URL'); 75 - } 71 + // Extract extension ID from URL like: chrome-extension://abc123/background.js 72 + const match = worker.url().match(/chrome-extension:\/\/([^/]+)/); 73 + if (!match) { 74 + throw new Error('Could not extract extension ID from service worker URL'); 75 + } 76 76 77 - return match[1]; 77 + return match[1]; 78 78 } 79 79 80 80 /** ··· 89 89 * 6. Clear the text selection 90 90 */ 91 91 export async function openSidePanel( 92 - context: BrowserContext, 93 - page: Page 92 + context: BrowserContext, 93 + page: Page 94 94 ): Promise<Page> { 95 - const extensionId = await getExtensionId(context); 95 + const extensionId = await getExtensionId(context); 96 96 97 - // 1. Select text to trigger floating button 98 - await selectText(page, 'p', 0, 10); 97 + // 1. Select text to trigger floating button 98 + await selectText(page, 'p', 0, 10); 99 + 100 + // 2. Wait for floating button to appear 101 + const floatingBtn = page.locator('#seams-annotate-btn'); 102 + await floatingBtn.waitFor({ state: 'visible', timeout: 5000 }); 103 + 104 + // 3. Set up listener for side panel page BEFORE clicking 105 + const sidebarPromise = context.waitForEvent('page', { 106 + predicate: (p: Page) => p.url().includes(extensionId) && p.url().includes('sidepanel.html'), 107 + timeout: 10000, 108 + }); 109 + 110 + // 4. Click floating button - triggers ACTIVATE_ANNOTATION → sidePanel.open() 111 + await floatingBtn.click(); 99 112 100 - // 2. Wait for floating button to appear 101 - const floatingBtn = page.locator('#seams-annotate-btn'); 102 - await floatingBtn.waitFor({ state: 'visible', timeout: 5000 }); 113 + // 5. Wait for the side panel page to appear 114 + const sidebarPage = await sidebarPromise; 103 115 104 - // 3. Set up listener for side panel page BEFORE clicking 105 - const sidebarPromise = context.waitForEvent('page', { 106 - predicate: (p: Page) => p.url().includes(extensionId) && p.url().includes('sidepanel.html'), 107 - timeout: 10000, 108 - }); 116 + // 6. Wait for seams-sidebar component to be fully initialized 117 + // This replaces the arbitrary 500ms sleep with actual readiness checks 118 + await sidebarPage.waitForSelector('seams-sidebar', { state: 'attached', timeout: 10000 }); 109 119 110 - // 4. Click floating button - triggers ACTIVATE_ANNOTATION → sidePanel.open() 111 - await floatingBtn.click(); 120 + // Wait for shadow DOM to be ready AND for the sidebar to have loaded its URL 121 + await sidebarPage.waitForFunction( 122 + () => { 123 + const sidebarEl = document.querySelector('seams-sidebar') as any; 124 + if (!sidebarEl?.shadowRoot) return false; 112 125 113 - // 5. Wait for the side panel page to appear 114 - const sidebarPage = await sidebarPromise; 126 + // Check that #sidebar-root exists (component initialized) 127 + const root = sidebarEl.shadowRoot.querySelector('#sidebar-root'); 128 + if (!root) return false; 115 129 116 - // 6. Wait for seams-sidebar component to be fully initialized 117 - // This replaces the arbitrary 500ms sleep with actual readiness checks 118 - await sidebarPage.waitForSelector('seams-sidebar', { state: 'attached', timeout: 10000 }); 119 - 120 - // Wait for shadow DOM to be ready AND for the sidebar to have loaded its URL 121 - await sidebarPage.waitForFunction( 122 - () => { 123 - const sidebarEl = document.querySelector('seams-sidebar') as any; 124 - if (!sidebarEl?.shadowRoot) return false; 125 - 126 - // Check that #sidebar-root exists (component initialized) 127 - const root = sidebarEl.shadowRoot.querySelector('#sidebar-root'); 128 - if (!root) return false; 129 - 130 - // Check that the sidebar has a currentUrl set (means it queried the active tab) 131 - // This is the key signal that initialization is complete 132 - return typeof sidebarEl.currentUrl === 'string' && sidebarEl.currentUrl.length > 0; 133 - }, 134 - { timeout: 10000 } 135 - ); 130 + // Check that the sidebar has a currentUrl set (means it queried the active tab) 131 + // This is the key signal that initialization is complete 132 + return typeof sidebarEl.currentUrl === 'string' && sidebarEl.currentUrl.length > 0; 133 + }, 134 + { timeout: 10000 } 135 + ); 136 136 137 - // 7. Clear the text selection on the content page 138 - await page.evaluate(() => window.getSelection()?.removeAllRanges()); 137 + // 7. Clear the text selection on the content page 138 + await page.evaluate(() => window.getSelection()?.removeAllRanges()); 139 139 140 - return sidebarPage; 140 + return sidebarPage; 141 141 } 142 142 143 143 /** ··· 151 151 * - <seams-annotation-card> elements 152 152 */ 153 153 export async function waitForAnnotations( 154 - sidebarPage: Page, 155 - minCount: number = 1, 156 - timeout: number = 10000 154 + sidebarPage: Page, 155 + minCount: number = 1, 156 + timeout: number = 10000 157 157 ): Promise<void> { 158 - // Wait for annotation cards to appear inside the shadow DOM 159 - await sidebarPage.waitForFunction( 160 - (count) => { 161 - const sidebarEl = document.querySelector('seams-sidebar'); 162 - if (!sidebarEl?.shadowRoot) return false; 163 - 164 - // Query inside the sidebar's shadow root 165 - // The structure is: shadowRoot -> #sidebar-root -> .sidebar -> #annotations -> seams-annotation-card 166 - const annotationsContainer = sidebarEl.shadowRoot.querySelector('#annotations'); 167 - if (!annotationsContainer) return false; 168 - 169 - const cards = annotationsContainer.querySelectorAll('seams-annotation-card'); 170 - return cards.length >= count; 171 - }, 172 - minCount, 173 - { timeout } 174 - ); 158 + // Wait for annotation cards to appear inside the shadow DOM 159 + await sidebarPage.waitForFunction( 160 + (count) => { 161 + const sidebarEl = document.querySelector('seams-sidebar'); 162 + if (!sidebarEl?.shadowRoot) return false; 163 + 164 + // Query inside the sidebar's shadow root 165 + // The structure is: shadowRoot -> #sidebar-root -> .sidebar -> #annotations -> seams-annotation-card 166 + const annotationsContainer = sidebarEl.shadowRoot.querySelector('#annotations'); 167 + if (!annotationsContainer) return false; 168 + 169 + const cards = annotationsContainer.querySelectorAll('seams-annotation-card'); 170 + return cards.length >= count; 171 + }, 172 + minCount, 173 + { timeout } 174 + ); 175 175 } 176 176 177 177 /** 178 178 * Waits for highlights to appear on the content page 179 179 */ 180 180 export async function waitForHighlights( 181 - page: Page, 182 - minCount: number = 1, 183 - timeout: number = 10000 181 + page: Page, 182 + minCount: number = 1, 183 + timeout: number = 10000 184 184 ): Promise<void> { 185 - await page.waitForSelector('.seams-highlight', { 186 - timeout, 187 - state: 'attached', 188 - }); 185 + await page.waitForSelector('.seams-highlight', { 186 + timeout, 187 + state: 'attached', 188 + }); 189 189 190 - await page.waitForFunction( 191 - (count) => { 192 - const highlights = document.querySelectorAll('.seams-highlight'); 193 - return highlights.length >= count; 194 - }, 195 - minCount, 196 - { timeout } 197 - ); 190 + await page.waitForFunction( 191 + (count) => { 192 + const highlights = document.querySelectorAll('.seams-highlight'); 193 + return highlights.length >= count; 194 + }, 195 + minCount, 196 + { timeout } 197 + ); 198 198 } 199 199 200 200 /** 201 201 * Selects text on a page and returns both the text and mock selectors 202 202 */ 203 203 export async function selectText( 204 - page: Page, 205 - selector: string, 206 - startOffset: number = 0, 207 - endOffset?: number 204 + page: Page, 205 + selector: string, 206 + startOffset: number = 0, 207 + endOffset?: number 208 208 ): Promise<string> { 209 - const selectedText = await page.evaluate( 210 - ({ selector, startOffset, endOffset }) => { 211 - const element = document.querySelector(selector); 212 - if (!element) throw new Error(`Element not found: ${selector}`); 209 + const selectedText = await page.evaluate( 210 + ({ selector, startOffset, endOffset }) => { 211 + const element = document.querySelector(selector); 212 + if (!element) throw new Error(`Element not found: ${selector}`); 213 213 214 - // Find the first text node (may not be firstChild if there's whitespace/comments) 215 - let textNode: Node | null = null; 216 - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); 217 - while (walker.nextNode()) { 218 - if (walker.currentNode.textContent?.trim()) { 219 - textNode = walker.currentNode; 220 - break; 221 - } 222 - } 223 - 224 - if (!textNode) { 225 - throw new Error(`No text node found in: ${selector}`); 226 - } 214 + // Find the first text node (may not be firstChild if there's whitespace/comments) 215 + let textNode: Node | null = null; 216 + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); 217 + while (walker.nextNode()) { 218 + if (walker.currentNode.textContent?.trim()) { 219 + textNode = walker.currentNode; 220 + break; 221 + } 222 + } 227 223 228 - const text = textNode.textContent || ''; 229 - const end = endOffset ?? text.length; 224 + if (!textNode) { 225 + throw new Error(`No text node found in: ${selector}`); 226 + } 230 227 231 - const range = document.createRange(); 232 - range.setStart(textNode, Math.min(startOffset, text.length)); 233 - range.setEnd(textNode, Math.min(end, text.length)); 228 + const text = textNode.textContent || ''; 229 + const end = endOffset ?? text.length; 234 230 235 - const selection = window.getSelection(); 236 - selection?.removeAllRanges(); 237 - selection?.addRange(range); 231 + const range = document.createRange(); 232 + range.setStart(textNode, Math.min(startOffset, text.length)); 233 + range.setEnd(textNode, Math.min(end, text.length)); 234 + 235 + const selection = window.getSelection(); 236 + selection?.removeAllRanges(); 237 + selection?.addRange(range); 238 238 239 - return selection?.toString() || ''; 240 - }, 241 - { selector, startOffset, endOffset } 242 - ); 239 + return selection?.toString() || ''; 240 + }, 241 + { selector, startOffset, endOffset } 242 + ); 243 243 244 - // Trigger selection change event 245 - await page.dispatchEvent('body', 'mouseup'); 244 + // Trigger selection change event 245 + await page.dispatchEvent('body', 'mouseup'); 246 246 247 - return selectedText; 247 + return selectedText; 248 248 } 249 249 250 250 /** ··· 257 257 * we create mock selectors that are sufficient for testing the UI flow. 258 258 */ 259 259 export async function sendSelectionToSidebar( 260 - sidebarPage: Page, 261 - text: string, 262 - sourceUrl: string 260 + sidebarPage: Page, 261 + text: string, 262 + sourceUrl: string 263 263 ): Promise<void> { 264 - await sidebarPage.evaluate( 265 - ({ text, sourceUrl }) => { 266 - const sidebarEl = document.querySelector('seams-sidebar') as any; 267 - if (sidebarEl?.setSelection) { 268 - // Create mock selectors similar to what the real content script generates 269 - const selectors = [ 270 - { 271 - $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 272 - exact: text, 273 - prefix: '', 274 - suffix: '', 275 - }, 276 - ]; 277 - sidebarEl.setSelection({ text, selectors }); 278 - } 279 - }, 280 - { text, sourceUrl } 281 - ); 264 + await sidebarPage.evaluate( 265 + ({ text, sourceUrl }) => { 266 + const sidebarEl = document.querySelector('seams-sidebar') as any; 267 + if (sidebarEl?.setSelection) { 268 + // Create mock selectors similar to what the real content script generates 269 + const selectors = [ 270 + { 271 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 272 + exact: text, 273 + prefix: '', 274 + suffix: '', 275 + }, 276 + ]; 277 + sidebarEl.setSelection({ text, selectors }); 278 + } 279 + }, 280 + { text, sourceUrl } 281 + ); 282 282 } 283 283 284 284 /** ··· 294 294 * the session exists in storage as the authoritative success check. 295 295 */ 296 296 export async function loginWithTestAccount( 297 - context: BrowserContext, 298 - sidebarPage: Page, 299 - handle: string 297 + context: BrowserContext, 298 + sidebarPage: Page, 299 + handle: string 300 300 ): Promise<void> { 301 - const password = process.env.TEST_PASSWORD; 302 - if (!password) { 303 - throw new Error('TEST_PASSWORD not set in tests/.env.test'); 304 - } 301 + const password = process.env.TEST_PASSWORD; 302 + if (!password) { 303 + throw new Error('TEST_PASSWORD not set in tests/.env.test'); 304 + } 305 305 306 - // Set up popup handler BEFORE triggering login 307 - const popupPromise = handleExtensionOAuthPopup(context, { 308 - identifier: handle, 309 - password, 310 - }); 306 + // Set up popup handler BEFORE triggering login 307 + const popupPromise = handleExtensionOAuthPopup(context, { 308 + identifier: handle, 309 + password, 310 + }); 311 311 312 - // Click login trigger if not already showing login form 313 - const loginTrigger = sidebarPage.locator('#login-trigger-btn'); 314 - if (await loginTrigger.isVisible()) { 315 - await loginTrigger.click(); 316 - } 312 + // Click login trigger if not already showing login form 313 + const loginTrigger = sidebarPage.locator('#login-trigger-btn'); 314 + if (await loginTrigger.isVisible()) { 315 + await loginTrigger.click(); 316 + } 317 + 318 + // Fill handle input 319 + const handleInput = sidebarPage.locator('#handle-input'); 320 + await handleInput.fill(handle); 317 321 318 - // Fill handle input 319 - const handleInput = sidebarPage.locator('#handle-input'); 320 - await handleInput.fill(handle); 322 + // Click login button - this opens the OAuth popup 323 + await sidebarPage.locator('#login-btn').click(); 321 324 322 - // Click login button - this opens the OAuth popup 323 - await sidebarPage.locator('#login-btn').click(); 325 + // Wait for OAuth popup automation to complete 326 + const result = await popupPromise; 324 327 325 - // Wait for OAuth popup automation to complete 326 - const result = await popupPromise; 328 + if (!result.completed) { 329 + throw new Error(`OAuth flow failed before completion: ${result.error}`); 330 + } 331 + 332 + // CRITICAL: The popup completing does NOT mean login succeeded. 333 + // We MUST verify the session exists in storage - this is the ONLY authoritative check. 334 + 335 + // Capture console messages for debugging 336 + const consoleMessages: string[] = []; 337 + sidebarPage.on('console', msg => { 338 + consoleMessages.push(`[${msg.type()}] ${msg.text()}`); 339 + }); 340 + 341 + let sessionExists = false; 342 + try { 343 + sessionExists = await sidebarPage.waitForFunction( 344 + () => { 345 + return new Promise((resolve) => { 346 + // Access chrome.storage.local to check for session 347 + // @ts-ignore - chrome is available in extension context 348 + if (typeof chrome !== 'undefined' && chrome.storage?.local) { 349 + chrome.storage.local.get('seams-oauth-session', (result: any) => { 350 + const session = result?.['seams-oauth-session']; 351 + resolve(session !== null && session !== undefined); 352 + }); 353 + } else { 354 + // Fallback: check if we're in a context where chrome isn't available 355 + resolve(false); 356 + } 357 + }); 358 + }, 359 + { timeout: 30000 } 360 + ).then(() => true); 361 + } catch { 362 + sessionExists = false; 363 + } 327 364 328 - if (!result.completed) { 329 - throw new Error(`OAuth flow failed before completion: ${result.error}`); 330 - } 365 + if (!sessionExists) { 366 + throw new Error( 367 + 'OAuth popup completed but no session found in storage. ' + 368 + 'Authorization may have been denied or PDS returned an error.' 369 + ); 370 + } 331 371 332 - // CRITICAL: The popup completing does NOT mean login succeeded. 333 - // We MUST verify the session exists in storage - this is the ONLY authoritative check. 334 - let sessionExists = false; 335 - try { 336 - sessionExists = await sidebarPage.waitForFunction( 337 - () => { 338 - return new Promise((resolve) => { 339 - // Access chrome.storage.local to check for session 340 - // @ts-ignore - chrome is available in extension context 341 - if (typeof chrome !== 'undefined' && chrome.storage?.local) { 342 - chrome.storage.local.get('seams-oauth-session', (result: any) => { 343 - const session = result?.['seams-oauth-session']; 344 - resolve(session !== null && session !== undefined); 345 - }); 346 - } else { 347 - // Fallback: check if we're in a context where chrome isn't available 348 - resolve(false); 349 - } 350 - }); 351 - }, 352 - { timeout: 30000 } 353 - ).then(() => true); 354 - } catch { 355 - sessionExists = false; 356 - } 372 + // Also verify the UI shows logged-in state (profile avatar) 373 + // This confirms the sidebar has re-rendered with the session 374 + try { 375 + await sidebarPage.waitForSelector('#profile-avatar', { timeout: 10000 }); 376 + } catch (error) { 377 + // Dump console messages for debugging 378 + console.error('[loginWithTestAccount] Console messages from sidebar:'); 379 + consoleMessages.forEach(msg => console.error(' ', msg)); 357 380 358 - if (!sessionExists) { 359 - throw new Error( 360 - 'OAuth popup completed but no session found in storage. ' + 361 - 'Authorization may have been denied or PDS returned an error.' 362 - ); 363 - } 381 + // Also capture current page content for debugging 382 + const authStatus = await sidebarPage.locator('#auth-status').textContent().catch(() => 'N/A'); 383 + console.error('[loginWithTestAccount] Auth status text:', authStatus); 364 384 365 - // Also verify the UI shows logged-in state (profile avatar) 366 - // This confirms the sidebar has re-rendered with the session 367 - await sidebarPage.waitForSelector('#profile-avatar', { timeout: 10000 }); 385 + throw error; 386 + } 368 387 }
+103 -103
tests/playwright.config.ts
··· 4 4 5 5 // Root directory of the project (parent of tests/) 6 6 const ROOT_DIR = path.join(__dirname, '..'); 7 - const EXTENSION_PATH = path.join(ROOT_DIR, '.output/chrome-mv3'); 7 + const EXTENSION_PATH = path.join(ROOT_DIR, '.output/chrome-mv3-dev'); 8 8 const SERVER_DIR = path.join(ROOT_DIR, 'server'); 9 9 const PROXY_DIR = path.join(ROOT_DIR, 'proxy'); 10 10 11 11 // Detect system chromium for NixOS compatibility 12 12 function getSystemChromium(): string | undefined { 13 - const candidates = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']; 14 - for (const cmd of candidates) { 15 - try { 16 - const result = execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim(); 17 - if (result) return result; 18 - } catch { 19 - // Command not found, try next 20 - } 21 - } 22 - return undefined; 13 + const candidates = ['chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']; 14 + for (const cmd of candidates) { 15 + try { 16 + const result = execSync(`which ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim(); 17 + if (result) return result; 18 + } catch { 19 + // Command not found, try next 20 + } 21 + } 22 + return undefined; 23 23 } 24 24 25 25 const systemChromium = getSystemChromium(); 26 26 27 27 export default defineConfig({ 28 - testDir: './e2e', 29 - timeout: 60000, 30 - fullyParallel: false, // Extensions need sequential execution 31 - forbidOnly: !!process.env.CI, 32 - retries: process.env.CI ? 2 : 0, 33 - workers: 1, // Extension tests need single worker 34 - reporter: [['html', { outputFolder: '../playwright-report' }], ['list']], 28 + testDir: './e2e', 29 + timeout: 60000, 30 + fullyParallel: false, // Extensions need sequential execution 31 + forbidOnly: !!process.env.CI, 32 + retries: process.env.CI ? 2 : 0, 33 + workers: 1, // Extension tests need single worker 34 + reporter: [['html', { outputFolder: '../playwright-report' }], ['list']], 35 35 36 - // Global setup verifies all servers are healthy before running any tests 37 - globalSetup: require.resolve('./global-setup'), 36 + // Global setup verifies all servers are healthy before running any tests 37 + globalSetup: require.resolve('./global-setup'), 38 38 39 - use: { 40 - trace: 'on-first-retry', 41 - screenshot: 'only-on-failure', 42 - video: 'retain-on-failure', 43 - }, 39 + use: { 40 + trace: 'on-first-retry', 41 + screenshot: 'only-on-failure', 42 + video: 'retain-on-failure', 43 + }, 44 44 45 - projects: [ 46 - { 47 - name: 'chrome-extension', 48 - testMatch: /extension\/.*\.spec\.ts/, 49 - use: { 50 - ...devices['Desktop Chrome'], 51 - // Override viewport for side panel testing with PW_CHROMIUM_ATTACH_TO_OTHER 52 - // See: https://github.com/microsoft/playwright/issues/26693 53 - viewport: null, 54 - deviceScaleFactor: undefined, 55 - // Extension testing requires special launch options 56 - // These are handled in the test fixtures 57 - ...(systemChromium && { 58 - launchOptions: { 59 - executablePath: systemChromium, 60 - }, 61 - }), 62 - }, 63 - }, 64 - { 65 - name: 'chrome-proxy', 66 - testMatch: /proxy\/.*\.spec\.ts/, 67 - use: { 68 - ...devices['Desktop Chrome'], 69 - baseURL: 'http://127.0.0.1:8081', 70 - ...(systemChromium && { 71 - launchOptions: { 72 - executablePath: systemChromium, 73 - }, 74 - }), 75 - }, 76 - }, 77 - // Firefox scaffolding - requires additional setup (see tests/FIREFOX_SETUP.md) 78 - // Disabled by default - Firefox extension testing needs different infrastructure 79 - // { 80 - // name: 'firefox-extension', 81 - // testMatch: /extension\/.*\.spec\.ts/, 82 - // use: { 83 - // ...devices['Desktop Firefox'], 84 - // }, 85 - // }, 86 - ], 45 + projects: [ 46 + { 47 + name: 'chrome-extension', 48 + testMatch: /extension\/.*\.spec\.ts/, 49 + use: { 50 + ...devices['Desktop Chrome'], 51 + // Override viewport for side panel testing with PW_CHROMIUM_ATTACH_TO_OTHER 52 + // See: https://github.com/microsoft/playwright/issues/26693 53 + viewport: null, 54 + deviceScaleFactor: undefined, 55 + // Extension testing requires special launch options 56 + // These are handled in the test fixtures 57 + ...(systemChromium && { 58 + launchOptions: { 59 + executablePath: systemChromium, 60 + }, 61 + }), 62 + }, 63 + }, 64 + { 65 + name: 'chrome-proxy', 66 + testMatch: /proxy\/.*\.spec\.ts/, 67 + use: { 68 + ...devices['Desktop Chrome'], 69 + baseURL: 'http://127.0.0.1:8081', 70 + ...(systemChromium && { 71 + launchOptions: { 72 + executablePath: systemChromium, 73 + }, 74 + }), 75 + }, 76 + }, 77 + // Firefox scaffolding - requires additional setup (see tests/FIREFOX_SETUP.md) 78 + // Disabled by default - Firefox extension testing needs different infrastructure 79 + // { 80 + // name: 'firefox-extension', 81 + // testMatch: /extension\/.*\.spec\.ts/, 82 + // use: { 83 + // ...devices['Desktop Firefox'], 84 + // }, 85 + // }, 86 + ], 87 87 88 - // Servers for integration tests 89 - // Only start proxy servers when running proxy tests 90 - // Set USE_EXTERNAL_PROXY=1 to skip starting proxy servers (e.g., when testing Docker container) 91 - webServer: [ 92 - // Backend server (Go) - needed for both extension and proxy tests 93 - { 94 - command: 'go run ./cmd/server', 95 - cwd: SERVER_DIR, 96 - url: 'http://localhost:8080/health', 97 - reuseExistingServer: !process.env.CI, 98 - timeout: 30000, 99 - }, 100 - // Proxy servers (for proxy tests only, skip if USE_EXTERNAL_PROXY is set) 101 - ...(process.env.RUN_PROXY_TESTS && !process.env.USE_EXTERNAL_PROXY 102 - ? [ 103 - { 104 - command: 'npx serve -p 8081 dist', 105 - cwd: PROXY_DIR, 106 - url: 'http://127.0.0.1:8081', 107 - reuseExistingServer: !process.env.CI, 108 - timeout: 30000, 109 - }, 110 - // CORS proxy (for proxy tests) - use /healthz endpoint for reliable health check 111 - { 112 - command: 'npx tsx cors-proxy/index.ts', 113 - cwd: PROXY_DIR, 114 - url: 'http://127.0.0.1:8082/healthz', 115 - reuseExistingServer: !process.env.CI, 116 - timeout: 30000, 117 - env: { 118 - PORT: '8082', 119 - }, 120 - }, 121 - ] 122 - : []), 123 - ], 88 + // Servers for integration tests 89 + // Only start proxy servers when running proxy tests 90 + // Set USE_EXTERNAL_PROXY=1 to skip starting proxy servers (e.g., when testing Docker container) 91 + webServer: [ 92 + // Backend server (Go) - needed for both extension and proxy tests 93 + { 94 + command: 'air', 95 + cwd: SERVER_DIR, 96 + url: 'http://localhost:8080/health', 97 + reuseExistingServer: !process.env.CI, 98 + timeout: 30000, 99 + }, 100 + // Proxy servers (for proxy tests only, skip if USE_EXTERNAL_PROXY is set) 101 + ...(process.env.RUN_PROXY_TESTS && !process.env.USE_EXTERNAL_PROXY 102 + ? [ 103 + { 104 + command: 'npx serve -p 8081 dist', 105 + cwd: PROXY_DIR, 106 + url: 'http://127.0.0.1:8081', 107 + reuseExistingServer: !process.env.CI, 108 + timeout: 30000, 109 + }, 110 + // CORS proxy (for proxy tests) - use /healthz endpoint for reliable health check 111 + { 112 + command: 'npx tsx cors-proxy/index.ts', 113 + cwd: PROXY_DIR, 114 + url: 'http://127.0.0.1:8082/healthz', 115 + reuseExistingServer: !process.env.CI, 116 + timeout: 30000, 117 + env: { 118 + PORT: '8082', 119 + }, 120 + }, 121 + ] 122 + : []), 123 + ], 124 124 });
+2 -6
wxt.config.ts
··· 46 46 ...(env.browser === 'chrome' ? ['sidePanel'] : ['menus']), 47 47 ], 48 48 host_permissions: ['<all_urls>', 'https://seams.so/*'], 49 - content_scripts: [ 50 - { 51 - matches: ['<all_urls>'], 52 - js: ['content-scripts/content.js'], 53 - }, 54 - ], 49 + // Note: content_scripts are auto-generated by WXT from entrypoints/content.ts 50 + // Do NOT manually define them here or they will be duplicated 55 51 action: { 56 52 default_title: 'Open Seams', 57 53 },