Social Annotations in the Atmosphere
15
fork

Configure Feed

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

more test fixes

+526 -236
.chainlink/issues.db

This is a binary file and will not be displayed.

+70
AGENTS.md
··· 219 219 | `packages/core/src/storage/adapter.ts` | Storage interface definition | 220 220 | `entrypoints/background.ts` | Extension background worker | 221 221 | `entrypoints/content.ts` | Content script entry | 222 + 223 + ## Task Planning with Chainlink 224 + 225 + This project uses [chainlink](https://github.com/anomalyco/chainlink) for issue tracking and task planning. **Always use chainlink when planning multi-step work.** 226 + 227 + ### When to Use Chainlink 228 + 229 + - Planning features that touch multiple parts of the codebase 230 + - Breaking down complex refactoring work 231 + - Tracking bugs and their dependencies 232 + - Any task with 3+ steps that need to be tracked 233 + 234 + ### Basic Commands 235 + 236 + ```bash 237 + chainlink list # List all open issues 238 + chainlink tree # Show issues as hierarchy 239 + chainlink ready # Show issues ready to work on (no blockers) 240 + chainlink blocked # Show blocked issues and their blockers 241 + ``` 242 + 243 + ### Creating Issues 244 + 245 + ```bash 246 + # Create a top-level issue 247 + chainlink create "Issue title" -d "Description" -p high 248 + 249 + # Create a subissue under a parent 250 + chainlink subissue <parent-id> "Subissue title" -d "Description" 251 + 252 + # Set dependencies (issue 8 is blocked by issue 7) 253 + chainlink block 8 7 254 + ``` 255 + 256 + ### Working on Issues 257 + 258 + ```bash 259 + chainlink show <id> # View issue details 260 + chainlink start <id> # Start timer for an issue 261 + chainlink stop # Stop current timer 262 + chainlink close <id> # Close completed issue 263 + chainlink comment <id> "Progress note" # Add a comment 264 + ``` 265 + 266 + ### Planning Workflow 267 + 268 + 1. **Create parent issue** with problem description and high-level solution 269 + 2. **Create subissues** for each discrete unit of work 270 + 3. **Set blocking relationships** to establish execution order 271 + 4. **Run `chainlink ready`** to see what can be worked on next 272 + 5. **Close issues** as they're completed to unblock dependent work 273 + 274 + ### Example: Multi-Component Feature 275 + 276 + ```bash 277 + # Create main feature issue 278 + chainlink create "Add dark mode support" -p high -d "Add theme switching across the app" 279 + 280 + # Create subissues for each component 281 + chainlink subissue 1 "Create theme config module" 282 + chainlink subissue 1 "Update extension UI components" 283 + chainlink subissue 1 "Update proxy UI components" 284 + chainlink subissue 1 "Add E2E tests for theme switching" 285 + 286 + # Set dependencies 287 + chainlink block 3 2 # Extension UI blocked by config 288 + chainlink block 4 2 # Proxy UI blocked by config 289 + chainlink block 5 3 # Tests blocked by extension UI 290 + chainlink block 5 4 # Tests blocked by proxy UI 291 + ```
+1 -1
package.json
··· 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": "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": "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 },
+1
packages/core/src/__mocks__/browser.ts
··· 103 103 sidePanel: { 104 104 open: vi.fn(async () => {}), 105 105 setOptions: vi.fn(async () => {}), 106 + setPanelBehavior: vi.fn(async () => {}), 106 107 }, 107 108 108 109 action: {
+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 + 82 92 it('registers context menu for Firefox when useContextMenu is true', async () => { 83 93 const firefoxWorker = new ExtensionBackgroundWorker({ 84 94 storage: mockStorage as unknown as StorageAdapter,
+4 -25
packages/core/src/content/mobile.ts
··· 1 + import { createAnnotateButton } from './ui'; 2 + 3 + // Wrapper for backward compatibility 1 4 export function createMobileAnnotateButton(x: number, y: number, onClick: () => void): HTMLElement { 2 - const btn = document.createElement('button'); 3 - btn.textContent = 'Annotate'; 4 - btn.className = 'seams-mobile-annotate-btn'; 5 - Object.assign(btn.style, { 6 - position: 'fixed', 7 - top: `${y + 8}px`, 8 - left: `${x}px`, 9 - transform: 'translateX(-50%)', 10 - zIndex: '2147483647', 11 - padding: '8px 16px', 12 - background: '#2d5016', // Forest green 13 - color: 'white', 14 - border: 'none', 15 - borderRadius: '2px', 16 - boxShadow: '0 2px 8px rgba(0,0,0,0.2)', 17 - fontSize: '14px', 18 - fontWeight: '600', 19 - cursor: 'pointer', 20 - }); 21 - 22 - btn.addEventListener('click', onClick); 23 - 24 - document.body.appendChild(btn); 25 - return btn; 5 + return createAnnotateButton(x, y, onClick); 26 6 } 27 - 28 7 29 8 export function createMobileSidebarToggle(onClick: () => void): HTMLElement { 30 9 const btn = document.createElement('button');
+28 -2
packages/core/src/content/ui.ts
··· 1 - import { createMobileAnnotateButton } from './mobile'; 1 + export function createAnnotateButton(x: number, y: number, onClick: () => void): HTMLElement { 2 + const btn = document.createElement('button'); 3 + btn.id = 'seams-annotate-btn'; 4 + btn.textContent = 'Annotate'; 5 + btn.className = 'seams-mobile-annotate-btn'; 6 + Object.assign(btn.style, { 7 + position: 'fixed', 8 + top: `${y + 8}px`, 9 + left: `${x}px`, 10 + transform: 'translateX(-50%)', 11 + zIndex: '2147483647', 12 + padding: '8px 16px', 13 + background: '#2d5016', // Forest green 14 + color: 'white', 15 + border: 'none', 16 + borderRadius: '2px', 17 + boxShadow: '0 2px 8px rgba(0,0,0,0.2)', 18 + fontSize: '14px', 19 + fontWeight: '600', 20 + cursor: 'pointer', 21 + }); 22 + 23 + btn.addEventListener('click', onClick); 24 + 25 + document.body.appendChild(btn); 26 + return btn; 27 + } 2 28 3 29 export interface AnnotationUIOptions { 4 30 onAnnotate: (data: { text: string; selectors: any[] }) => void; ··· 16 42 showButton(rect: DOMRect, text: string, selectors: any[], range?: Range) { 17 43 this.removeButton(); 18 44 19 - this.activeBtn = createMobileAnnotateButton( 45 + this.activeBtn = createAnnotateButton( 20 46 rect.left + rect.width / 2, 21 47 rect.bottom, 22 48 () => {
+31 -30
tests/e2e/extension/create.spec.ts
··· 16 16 import { test, expect } from '@playwright/test'; 17 17 import { 18 18 createExtensionContext, 19 + getExtensionId, 19 20 openSidePanel, 20 21 selectText, 21 22 loginWithTestAccount, ··· 53 54 const selectedText = await selectText(page, 'p', 0, 20); 54 55 expect(selectedText.length).toBeGreaterThan(0); 55 56 56 - // Wait for selection to propagate to sidebar 57 - await sidebarPage.waitForTimeout(500); 58 - 59 - // The annotation form should appear with the selected text 57 + // Wait for the annotation form to appear with the selected text 60 58 const annotationForm = sidebarPage.locator('#annotation-form'); 61 - await expect(annotationForm).toBeVisible(); 59 + await expect(annotationForm).toBeVisible({ timeout: 5000 }); 62 60 63 61 // Should show the selected text in blockquote 64 62 const quoteBlock = sidebarPage.locator('#selected-text blockquote'); ··· 71 69 // Save the annotation 72 70 await sidebarPage.locator('#save-btn').click(); 73 71 74 - // Wait for annotation to appear in the list 75 - await sidebarPage.waitForTimeout(2000); 76 - 77 - // Verify the annotation was created 72 + // Wait for our annotation to appear in the list 73 + const testAnnotationText = 'Integration test annotation'; 78 74 const annotationCards = sidebarPage.locator('seams-annotation-card'); 79 - const count = await annotationCards.count(); 80 - expect(count).toBeGreaterThan(0); 75 + const ourAnnotation = annotationCards.filter({ hasText: testAnnotationText }); 76 + await expect(ourAnnotation).toHaveCount(1, { timeout: 10000 }); 81 77 82 78 // Note: Cleanup of test annotations should happen after test suite 83 79 // via the test account management ··· 91 87 92 88 try { 93 89 const page = await context.newPage(); 90 + const extensionId = await getExtensionId(context); 91 + 94 92 await page.goto('https://example.com/'); 95 93 await page.waitForLoadState('networkidle'); 96 94 97 95 // Select text on the page 98 - await selectText(page, 'p', 5, 25); 96 + const selectedText = await selectText(page, 'p', 5, 25); 99 97 100 - // Wait for floating button to appear (Chrome only) 98 + // Wait for floating button to appear 101 99 const floatingBtn = page.locator('#seams-annotate-btn'); 100 + await floatingBtn.waitFor({ state: 'visible', timeout: 5000 }); 101 + 102 + // Set up listener BEFORE clicking - this is critical to avoid race condition 103 + const sidebarPromise = context.waitForEvent('page', { 104 + predicate: (p) => p.url().includes(extensionId) && p.url().includes('sidepanel.html'), 105 + timeout: 10000, 106 + }); 102 107 103 - try { 104 - await floatingBtn.waitFor({ timeout: 2000 }); 108 + // Click the floating button - triggers ACTIVATE_ANNOTATION which opens sidebar 109 + await floatingBtn.click(); 105 110 106 - // Click the floating button 107 - await floatingBtn.click(); 111 + // Wait for sidebar page to appear 112 + const sidebarPage = await sidebarPromise; 108 113 109 - // This should trigger the ACTIVATE_ANNOTATION message 110 - // which opens the sidebar 111 - await page.waitForTimeout(1000); 112 - } catch (e) { 113 - // Floating button may not appear if sidebar is already open 114 - // or on Firefox (which uses context menu instead) 115 - console.log( 116 - 'Floating button not visible - may be Firefox or sidebar already open' 117 - ); 118 - } 114 + // Verify the annotation form is visible 115 + const annotationForm = sidebarPage.locator('#annotation-form'); 116 + await expect(annotationForm).toBeVisible({ timeout: 5000 }); 117 + 118 + // Verify the selected text appears in the form 119 + const quoteBlock = sidebarPage.locator('#selected-text blockquote'); 120 + await expect(quoteBlock).toContainText(selectedText.substring(0, 10)); 119 121 } finally { 120 122 await context.close(); 121 123 } ··· 133 135 134 136 // Select text 135 137 await selectText(page, 'p', 0, 15); 136 - await sidebarPage.waitForTimeout(500); 137 138 138 - // Form should appear 139 + // Wait for form to appear 139 140 const annotationForm = sidebarPage.locator('#annotation-form'); 140 - await expect(annotationForm).toBeVisible(); 141 + await expect(annotationForm).toBeVisible({ timeout: 5000 }); 141 142 142 143 // Click cancel/clear button 143 144 const clearBtn = sidebarPage.locator('#clear-selection-btn');
+98 -68
tests/e2e/extension/highlights.spec.ts
··· 12 12 import { test, expect } from '@playwright/test'; 13 13 import { 14 14 createExtensionContext, 15 + openSidePanel, 16 + waitForAnnotations, 15 17 waitForHighlights, 16 18 } from '../../helpers/extension'; 17 19 ··· 22 24 ); 23 25 24 26 test('renders highlights on page with annotations', async () => { 25 - const context = await createExtensionContext(); 27 + const context = await createExtensionContext(true); 26 28 27 29 try { 28 30 const page = await context.newPage(); ··· 31 33 await page.goto('https://example.com/'); 32 34 await page.waitForLoadState('networkidle'); 33 35 34 - try { 35 - // Wait for highlights to appear 36 - await waitForHighlights(page, 1, 5000); 36 + // Wait for highlights to appear 37 + await waitForHighlights(page, 1, 5000); 37 38 38 - // Verify highlights have correct styling 39 - const highlights = page.locator('.seams-highlight'); 40 - const count = await highlights.count(); 41 - expect(count).toBeGreaterThan(0); 39 + // Verify highlights have correct styling 40 + const highlights = page.locator('.seams-highlight'); 41 + const count = await highlights.count(); 42 + expect(count).toBeGreaterThan(0); 42 43 43 - // Check that highlights are visible 44 - const firstHighlight = highlights.first(); 45 - await expect(firstHighlight).toBeVisible(); 44 + // Check that highlights are visible 45 + const firstHighlight = highlights.first(); 46 + await expect(firstHighlight).toBeVisible(); 46 47 47 - // Verify background color is yellowish 48 - const bgColor = await firstHighlight.evaluate((el) => 49 - getComputedStyle(el).backgroundColor 50 - ); 51 - expect(bgColor).toContain('255'); // Yellow has high R and G values 52 - } catch (e) { 53 - console.log('No highlights found - golden database may not be seeded'); 54 - } 48 + // Verify background color is yellowish (rgba with high R and G, low-ish B) 49 + const bgColor = await firstHighlight.evaluate((el) => 50 + getComputedStyle(el).backgroundColor 51 + ); 52 + // Parse rgb(R, G, B) or rgba(R, G, B, A) 53 + const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); 54 + expect(match).not.toBeNull(); 55 + const [, r, g, b] = match!.map(Number); 56 + // Yellow has high R (>200), high G (>200), variable B 57 + expect(r).toBeGreaterThan(200); 58 + expect(g).toBeGreaterThan(200); 55 59 } finally { 56 60 await context.close(); 57 61 } 58 62 }); 59 63 60 - test('clicking highlight scrolls annotation into view', async () => { 61 - const context = await createExtensionContext(); 64 + test('clicking annotation card in sidebar scrolls page to highlight', async () => { 65 + const context = await createExtensionContext(true); 62 66 63 67 try { 64 68 const page = await context.newPage(); 65 69 await page.goto('https://example.com/'); 66 70 await page.waitForLoadState('networkidle'); 67 71 68 - try { 69 - await waitForHighlights(page, 1, 5000); 72 + // Wait for highlights to render on the page 73 + await waitForHighlights(page, 1, 5000); 74 + 75 + // Open sidebar 76 + const sidebarPage = await openSidePanel(context, page); 77 + 78 + // Wait for annotations to load in sidebar 79 + await waitForAnnotations(sidebarPage, 1, 5000); 70 80 71 - const highlight = page.locator('.seams-highlight').first(); 81 + // Get the first highlight's position before clicking 82 + const highlight = page.locator('.seams-highlight').first(); 83 + const highlightId = await highlight.getAttribute('data-annotation-id'); 84 + 85 + // Scroll the page so the highlight is NOT in view 86 + await page.evaluate(() => { 87 + window.scrollTo(0, document.body.scrollHeight); 88 + }); 72 89 73 - // Click the highlight 74 - await highlight.click(); 90 + // Verify highlight is not in viewport 91 + const isInViewBefore = await highlight.evaluate((el) => { 92 + const rect = el.getBoundingClientRect(); 93 + return rect.top >= 0 && rect.bottom <= window.innerHeight; 94 + }); 95 + expect(isInViewBefore).toBe(false); 75 96 76 - // This should trigger sidebar to open or scroll to the annotation 77 - // The exact behavior depends on the implementation 78 - // For now, just verify the click doesn't error 97 + // Click the annotation card in the sidebar 98 + const annotationCard = sidebarPage.locator('seams-annotation-card').first(); 99 + await annotationCard.click(); 79 100 80 - expect(true).toBe(true); 81 - } catch (e) { 82 - console.log('No highlights found - golden database may not be seeded'); 83 - } 101 + // Wait for highlight to be scrolled into view 102 + await page.waitForFunction( 103 + (el) => { 104 + const rect = el.getBoundingClientRect(); 105 + return rect.top >= 0 && rect.bottom <= window.innerHeight; 106 + }, 107 + await highlight.elementHandle(), 108 + { timeout: 5000 } 109 + ); 110 + 111 + // Verify the highlight is now scrolled into view 112 + const isInViewAfter = await highlight.evaluate((el) => { 113 + const rect = el.getBoundingClientRect(); 114 + return rect.top >= 0 && rect.bottom <= window.innerHeight; 115 + }); 116 + expect(isInViewAfter).toBe(true); 84 117 } finally { 85 118 await context.close(); 86 119 } 87 120 }); 88 121 89 122 test('highlights persist across page navigation', async () => { 90 - const context = await createExtensionContext(); 123 + const context = await createExtensionContext(true); 91 124 92 125 try { 93 126 const page = await context.newPage(); ··· 105 138 await page.waitForLoadState('networkidle'); 106 139 107 140 // Highlights should reappear 108 - try { 109 - await waitForHighlights(page, 1, 5000); 110 - const highlights = page.locator('.seams-highlight'); 111 - const count = await highlights.count(); 112 - expect(count).toBeGreaterThan(0); 113 - } catch (e) { 114 - console.log('No highlights found - golden database may not be seeded'); 115 - } 141 + await waitForHighlights(page, 1, 5000); 142 + const highlights = page.locator('.seams-highlight'); 143 + const count = await highlights.count(); 144 + expect(count).toBeGreaterThan(0); 116 145 } finally { 117 146 await context.close(); 118 147 } ··· 123 152 // MutationObserver triggering loadAndRenderHighlights which modifies DOM 124 153 // which triggers MutationObserver again, causing infinite loop and memory exhaustion 125 154 126 - const context = await createExtensionContext(); 155 + const context = await createExtensionContext(true); 127 156 128 157 try { 129 158 const page = await context.newPage(); ··· 132 161 await page.goto('https://example.com/'); 133 162 await page.waitForLoadState('networkidle'); 134 163 135 - // Get initial memory usage (if available) 136 - const initialMetrics = await page.evaluate(() => { 137 - return (performance as any).memory?.usedJSHeapSize || 0; 138 - }); 164 + // MUST have highlights to test render loop behavior 165 + await waitForHighlights(page, 1, 5000); 166 + 167 + // Get initial highlight count 168 + const initialCount = await page.locator('.seams-highlight').count(); 169 + expect(initialCount).toBeGreaterThan(0); 139 170 140 171 // Simulate dynamic content changes that could trigger infinite loop 141 172 for (let i = 0; i < 10; i++) { ··· 145 176 div.className = 'dynamic-test-content'; 146 177 document.body.appendChild(div); 147 178 }, i); 148 - // Small delay between mutations 149 - await page.waitForTimeout(100); 150 179 } 151 180 152 - // Wait for any debounced re-renders to complete 153 - await page.waitForTimeout(1000); 181 + // Wait for highlight count to stabilize (no more re-renders happening) 182 + // If infinite loop exists, count will keep growing 183 + await page.waitForFunction( 184 + (expected) => { 185 + const count = document.querySelectorAll('.seams-highlight').length; 186 + // Store count and check again after a tick 187 + return new Promise(resolve => { 188 + setTimeout(() => { 189 + const newCount = document.querySelectorAll('.seams-highlight').length; 190 + // Count should be stable (not growing) and within tolerance 191 + resolve(newCount === count && newCount <= expected * 2); 192 + }, 500); 193 + }); 194 + }, 195 + initialCount, 196 + { timeout: 10000 } 197 + ); 154 198 155 - // Check that memory hasn't grown excessively (sign of infinite loop) 156 - const finalMetrics = await page.evaluate(() => { 157 - return (performance as any).memory?.usedJSHeapSize || 0; 158 - }); 159 - 160 - // If both metrics are available, memory shouldn't have grown more than 50MB 161 - // (arbitrary threshold - infinite loop would grow by hundreds of MB) 162 - if (initialMetrics > 0 && finalMetrics > 0) { 163 - const growth = finalMetrics - initialMetrics; 164 - expect(growth).toBeLessThan(50 * 1024 * 1024); // 50MB 165 - } 166 - 167 - // Verify the page is still responsive (not stuck in loop) 168 - const isResponsive = await page.evaluate(() => { 169 - return document.body !== null; 170 - }); 171 - expect(isResponsive).toBe(true); 199 + // Verify highlights weren't duplicated excessively 200 + const finalCount = await page.locator('.seams-highlight').count(); 201 + expect(finalCount).toBeLessThanOrEqual(initialCount * 2); 172 202 173 203 // Clean up test elements 174 204 await page.evaluate(() => {
+62 -71
tests/e2e/extension/sidebar.spec.ts
··· 9 9 10 10 import { test, expect } from '@playwright/test'; 11 11 import { 12 - createExtensionContext, 13 - openSidePanel, 14 - waitForAnnotations, 12 + createExtensionContext, 13 + openSidePanel, 14 + waitForAnnotations, 15 15 } from '../../helpers/extension'; 16 16 17 17 test.describe('Extension Sidebar', () => { 18 - test.skip( 19 - !process.env.RUN_EXTENSION_TESTS, 20 - 'Set RUN_EXTENSION_TESTS=1 to run extension tests' 21 - ); 18 + test.skip( 19 + !process.env.RUN_EXTENSION_TESTS, 20 + 'Set RUN_EXTENSION_TESTS=1 to run extension tests' 21 + ); 22 22 23 - test('displays sidebar with login prompt when not authenticated', async () => { 24 - const context = await createExtensionContext(); 23 + test('displays sidebar with login prompt when not authenticated', async () => { 24 + const context = await createExtensionContext(); 25 25 26 - try { 27 - const page = await context.newPage(); 28 - await page.goto('https://example.com/'); 26 + try { 27 + const page = await context.newPage(); 28 + await page.goto('https://example.com/'); 29 29 30 - // Open sidebar 31 - const sidebarPage = await openSidePanel(context, page); 30 + // Open sidebar 31 + const sidebarPage = await openSidePanel(context, page); 32 32 33 - // Should show login trigger or login form 34 - const loginTrigger = sidebarPage.locator('#login-trigger-btn'); 35 - const loginForm = sidebarPage.locator('#handle-input'); 33 + // Should show login trigger or login form 34 + const loginTrigger = sidebarPage.locator('#login-trigger-btn'); 35 + const loginForm = sidebarPage.locator('#handle-input'); 36 36 37 - const hasLoginUI = 38 - (await loginTrigger.isVisible()) || (await loginForm.isVisible()); 39 - expect(hasLoginUI).toBe(true); 37 + const hasLoginUI = 38 + (await loginTrigger.isVisible()) || (await loginForm.isVisible()); 39 + expect(hasLoginUI).toBe(true); 40 40 41 - // Should show "Log in to create annotations" message 42 - await expect(sidebarPage.locator('text=Log in to create')).toBeVisible(); 43 - } finally { 44 - await context.close(); 45 - } 46 - }); 41 + // Should show "Log in to create annotations" message 42 + await expect(sidebarPage.locator('text=Log in to create')).toBeVisible(); 43 + } finally { 44 + await context.close(); 45 + } 46 + }); 47 47 48 - test('displays annotations from golden database', async () => { 49 - const context = await createExtensionContext(); 48 + test('displays annotations from golden database', async () => { 49 + const context = await createExtensionContext(true); 50 50 51 - try { 52 - const page = await context.newPage(); 51 + try { 52 + const page = await context.newPage(); 53 53 54 - // Navigate to a URL that has annotations in the golden database 55 - await page.goto('https://example.com/'); 56 - await page.waitForLoadState('networkidle'); 54 + // Navigate to a URL that has annotations in the golden database 55 + await page.goto('https://example.com/'); 56 + await page.waitForLoadState('networkidle'); 57 57 58 - // Open sidebar 59 - const sidebarPage = await openSidePanel(context, page); 58 + // Open sidebar 59 + const sidebarPage = await openSidePanel(context, page); 60 60 61 - // Wait for annotations to load 62 - try { 63 - await waitForAnnotations(sidebarPage, 1, 5000); 61 + // Wait for annotations to load 62 + await waitForAnnotations(sidebarPage, 1, 5000); 64 63 65 - // Verify annotation cards are present 66 - const annotationCards = sidebarPage.locator('seams-annotation-card'); 67 - const count = await annotationCards.count(); 68 - expect(count).toBeGreaterThan(0); 69 - } catch (e) { 70 - // No annotations in golden DB yet - this is expected until DB is seeded 71 - console.log( 72 - 'No annotations found - golden database may not be seeded yet' 73 - ); 74 - } 75 - } finally { 76 - await context.close(); 77 - } 78 - }); 64 + // Verify annotation cards are present 65 + const annotationCards = sidebarPage.locator('seams-annotation-card'); 66 + const count = await annotationCards.count(); 67 + expect(count).toBeGreaterThan(0); 68 + } finally { 69 + await context.close(); 70 + } 71 + }); 79 72 80 - test('shows empty state when no annotations exist for page', async () => { 81 - const context = await createExtensionContext(); 73 + test('shows empty state when no annotations exist for page', async () => { 74 + const context = await createExtensionContext(); 82 75 83 - try { 84 - const page = await context.newPage(); 76 + try { 77 + const page = await context.newPage(); 85 78 86 - // Navigate to a URL with no annotations 87 - await page.goto('https://example.com/no-annotations-page'); 88 - await page.waitForLoadState('networkidle'); 79 + // Navigate to a URL with no annotations 80 + await page.goto('https://example.com/no-annotations-page'); 81 + await page.waitForLoadState('networkidle'); 89 82 90 - // Open sidebar 91 - const sidebarPage = await openSidePanel(context, page); 83 + // Open sidebar 84 + const sidebarPage = await openSidePanel(context, page); 92 85 93 - // Wait a bit for any annotations to load 94 - await sidebarPage.waitForTimeout(2000); 86 + // Wait for empty state to appear (sidebar finishes loading and shows empty state) 87 + await expect( 88 + sidebarPage.locator('text=No annotations yet') 89 + ).toBeVisible({ timeout: 5000 }); 90 + } finally { 91 + await context.close(); 92 + } 93 + }); 95 94 96 - // Should show empty state message 97 - await expect( 98 - sidebarPage.locator('text=No annotations yet') 99 - ).toBeVisible(); 100 - } finally { 101 - await context.close(); 102 - } 103 - }); 104 95 });
+174 -31
tests/helpers/extension.ts
··· 7 7 import { copyFileSync, mkdirSync, existsSync } from 'fs'; 8 8 import { handleExtensionOAuthPopup } from './oauth-automation'; 9 9 10 + // Enable Playwright to attach to Chrome side panel targets 11 + // See: https://github.com/microsoft/playwright/issues/26693 12 + process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1'; 13 + 10 14 const EXTENSION_PATH = path.resolve(__dirname, '../../.output/chrome-mv3'); 11 15 const GOLDEN_DB_PATH = path.resolve(__dirname, '../fixtures/golden-db/annotations.db'); 12 16 const SERVER_DB_PATH = path.resolve(__dirname, '../../server/db/annotations.db'); 13 17 14 18 /** 15 19 * Creates a browser context with the Seams extension loaded 20 + * @param requireGoldenDb - If true, throws if golden database is missing. Default: false. 16 21 */ 17 - export async function createExtensionContext(): Promise<BrowserContext> { 22 + export async function createExtensionContext(requireGoldenDb: boolean = false): Promise<BrowserContext> { 18 23 // Verify extension is built 19 24 if (!existsSync(EXTENSION_PATH)) { 20 25 throw new Error( ··· 22 27 ); 23 28 } 24 29 25 - // Copy golden database to server location for tests 30 + // Copy golden database to server location for tests (if it exists) 26 31 if (existsSync(GOLDEN_DB_PATH)) { 27 32 mkdirSync(path.dirname(SERVER_DB_PATH), { recursive: true }); 28 33 copyFileSync(GOLDEN_DB_PATH, SERVER_DB_PATH); 29 - console.log('[test] Copied golden database to server location'); 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 + ); 30 39 } 31 40 32 41 // Launch Chrome with extension ··· 37 46 `--load-extension=${EXTENSION_PATH}`, 38 47 '--no-first-run', 39 48 '--disable-default-apps', 49 + '--start-maximized', // Needed for proper side panel viewport with PW_CHROMIUM_ATTACH_TO_OTHER 40 50 ], 41 51 }); 42 52 ··· 68 78 } 69 79 70 80 /** 71 - * Opens the Seams sidebar panel 72 - * Note: Chrome sidePanel API requires user gesture or action click 81 + * Opens the Seams sidebar panel using the real floating button flow 82 + * 83 + * This tests the actual user interaction: 84 + * 1. Select text on the page to trigger the floating "Annotate" button 85 + * 2. Click the floating button, which sends ACTIVATE_ANNOTATION to background 86 + * 3. Background worker calls browser.sidePanel.open() 87 + * 4. Wait for the side panel page to appear via context.waitForEvent('page') 88 + * 5. Wait for seams-sidebar component to initialize 89 + * 6. Clear the text selection 73 90 */ 74 91 export async function openSidePanel( 75 92 context: BrowserContext, ··· 77 94 ): Promise<Page> { 78 95 const extensionId = await getExtensionId(context); 79 96 80 - // The sidebar URL format for Chrome extensions 81 - const sidebarUrl = `chrome-extension://${extensionId}/sidepanel.html`; 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(); 112 + 113 + // 5. Wait for the side panel page to appear 114 + const sidebarPage = await sidebarPromise; 115 + 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 + ); 82 136 83 - // Open sidebar in a new page (for testing purposes) 84 - // In real usage, this would be opened via the extension action 85 - const sidebarPage = await context.newPage(); 86 - await sidebarPage.goto(sidebarUrl); 137 + // 7. Clear the text selection on the content page 138 + await page.evaluate(() => window.getSelection()?.removeAllRanges()); 87 139 88 140 return sidebarPage; 89 141 } 90 142 91 143 /** 92 144 * Waits for annotations to appear in the sidebar 93 - * Note: The sidebar uses Shadow DOM, so we need to query inside the shadow root 145 + * 146 + * The sidebar structure is: 147 + * - <seams-sidebar> (custom element with Shadow DOM) 148 + * - #sidebar-root (container created by web component) 149 + * - .sidebar (rendered by Sidebar class) 150 + * - #annotations (list container) 151 + * - <seams-annotation-card> elements 94 152 */ 95 153 export async function waitForAnnotations( 96 154 sidebarPage: Page, 97 155 minCount: number = 1, 98 156 timeout: number = 10000 99 157 ): Promise<void> { 100 - // Playwright's waitForSelector pierces Shadow DOM automatically 101 - await sidebarPage.waitForSelector('seams-sidebar seams-annotation-card', { 102 - timeout, 103 - state: 'attached', 104 - }); 105 - 106 - // For waitForFunction, we need to manually traverse shadow roots 158 + // Wait for annotation cards to appear inside the shadow DOM 107 159 await sidebarPage.waitForFunction( 108 160 (count) => { 109 161 const sidebarEl = document.querySelector('seams-sidebar'); 110 162 if (!sidebarEl?.shadowRoot) return false; 111 163 112 164 // Query inside the sidebar's shadow root 113 - const container = sidebarEl.shadowRoot.querySelector('#sidebar-container'); 114 - if (!container) return false; 165 + // The structure is: shadowRoot -> #sidebar-root -> .sidebar -> #annotations -> seams-annotation-card 166 + const annotationsContainer = sidebarEl.shadowRoot.querySelector('#annotations'); 167 + if (!annotationsContainer) return false; 115 168 116 - const cards = container.querySelectorAll('seams-annotation-card'); 169 + const cards = annotationsContainer.querySelectorAll('seams-annotation-card'); 117 170 return cards.length >= count; 118 171 }, 119 172 minCount, ··· 145 198 } 146 199 147 200 /** 148 - * Selects text on a page 201 + * Selects text on a page and returns both the text and mock selectors 149 202 */ 150 203 export async function selectText( 151 204 page: Page, ··· 158 211 const element = document.querySelector(selector); 159 212 if (!element) throw new Error(`Element not found: ${selector}`); 160 213 161 - const textNode = element.firstChild; 162 - if (!textNode || textNode.nodeType !== Node.TEXT_NODE) { 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) { 163 225 throw new Error(`No text node found in: ${selector}`); 164 226 } 165 227 ··· 167 229 const end = endOffset ?? text.length; 168 230 169 231 const range = document.createRange(); 170 - range.setStart(textNode, startOffset); 232 + range.setStart(textNode, Math.min(startOffset, text.length)); 171 233 range.setEnd(textNode, Math.min(end, text.length)); 172 234 173 235 const selection = window.getSelection(); ··· 186 248 } 187 249 188 250 /** 251 + * Sends a text selection to the sidebar 252 + * 253 + * This simulates what the content script does when text is selected: 254 + * it sends a SELECTION_CHANGED message to the sidebar with the text and selectors. 255 + * 256 + * Since we can't easily generate real W3C selectors in the test context, 257 + * we create mock selectors that are sufficient for testing the UI flow. 258 + */ 259 + export async function sendSelectionToSidebar( 260 + sidebarPage: Page, 261 + text: string, 262 + sourceUrl: string 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 + ); 282 + } 283 + 284 + /** 189 285 * Login to Seams with a test account 286 + * 287 + * This function: 288 + * 1. Triggers the OAuth flow from the sidebar 289 + * 2. Automates the PDS login form in the popup 290 + * 3. Waits for the extension to process the OAuth callback 291 + * 4. Verifies login succeeded by checking both storage and UI 292 + * 293 + * IMPORTANT: The popup closing does NOT mean login succeeded. We MUST verify 294 + * the session exists in storage as the authoritative success check. 190 295 */ 191 296 export async function loginWithTestAccount( 192 297 context: BrowserContext, ··· 214 319 const handleInput = sidebarPage.locator('#handle-input'); 215 320 await handleInput.fill(handle); 216 321 217 - // Click login button 322 + // Click login button - this opens the OAuth popup 218 323 await sidebarPage.locator('#login-btn').click(); 219 324 220 - // Wait for OAuth to complete 221 - await popupPromise; 325 + // Wait for OAuth popup automation to complete 326 + const result = await popupPromise; 327 + 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 + 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 + } 357 + 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 + } 222 364 223 - // Verify login succeeded 224 - await sidebarPage.waitForSelector('#profile-avatar', { timeout: 30000 }); 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 }); 225 368 }
+43 -8
tests/helpers/oauth-automation.ts
··· 16 16 * 2. Fill identifier and password 17 17 * 3. Click "Authorize" button on consent screen 18 18 * 19 + * Note: This function does NOT wait for the redirect to complete because 20 + * browser.identity.launchWebAuthFlow captures the redirect URL and closes 21 + * the popup immediately. The popup may close before we can observe the URL change. 22 + * 19 23 * @param page - Playwright page on the PDS auth URL 20 24 * @param credentials - User credentials 21 - * @param callbackUrlPattern - Regex to match the OAuth callback URL (default: extension callback) 22 25 */ 23 26 export async function completePDSLogin( 24 27 page: Page, 25 - credentials: { identifier: string; password: string }, 26 - callbackUrlPattern: RegExp = /seams\.so\/oauth\/callback/ 28 + credentials: { identifier: string; password: string } 27 29 ): Promise<void> { 28 30 // Wait for PDS authorize page to load (React app hydration) 29 31 await page.waitForLoadState('networkidle'); ··· 68 70 await authorizeBtn.waitFor({ state: 'visible', timeout: 10000 }); 69 71 await authorizeBtn.click(); 70 72 71 - // Wait for redirect back to seams callback 72 - await page.waitForURL(callbackUrlPattern, { timeout: 30000 }); 73 + // Don't wait for redirect - the popup will be closed by Chrome's launchWebAuthFlow 74 + // after it captures the redirect URL. The extension will process the callback asynchronously. 75 + } 76 + 77 + export interface OAuthPopupResult { 78 + /** Whether the OAuth flow completed (popup closed). Does NOT mean login succeeded. */ 79 + completed: boolean; 80 + /** Error message if the flow failed before completion */ 81 + error?: string; 73 82 } 74 83 75 84 /** 76 85 * Wait for OAuth popup and automate login 86 + * 87 + * Returns a result indicating whether authorization was completed. 88 + * IMPORTANT: Callers MUST verify login success separately by checking 89 + * for session in storage. The popup closing does NOT guarantee success - 90 + * it could close due to: user denial, PDS error, or Chrome capturing redirect. 77 91 */ 78 92 export async function handleExtensionOAuthPopup( 79 93 context: BrowserContext, 80 94 credentials: { identifier: string; password: string } 81 - ): Promise<void> { 95 + ): Promise<OAuthPopupResult> { 82 96 // browser.identity.launchWebAuthFlow creates a popup 83 97 const popup = await context.waitForEvent('page'); 84 - await completePDSLogin(popup, credentials); 85 - // Popup closes automatically after redirect is captured 98 + 99 + try { 100 + await completePDSLogin(popup, credentials); 101 + 102 + // If we get here, we clicked authorize and the popup is still open 103 + // Wait briefly for Chrome to capture the redirect and close the popup 104 + await popup.waitForEvent('close', { timeout: 5000 }).catch(() => { 105 + // Popup didn't close within timeout - might be an error page 106 + }); 107 + 108 + return { completed: true }; 109 + } catch (error: any) { 110 + // Popup closed during login flow 111 + if (error.message?.includes('Target page, context or browser has been closed')) { 112 + // Popup closed - but we don't know why. Could be: 113 + // 1. Success: Chrome captured redirect URL 114 + // 2. Failure: User denied, PDS error, etc. 115 + // Caller MUST verify by checking for session in storage 116 + return { completed: true }; // "completed" means flow finished, not succeeded 117 + } 118 + 119 + return { completed: false, error: error.message }; 120 + } 86 121 }
+4
tests/playwright.config.ts
··· 48 48 testMatch: /extension\/.*\.spec\.ts/, 49 49 use: { 50 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, 51 55 // Extension testing requires special launch options 52 56 // These are handled in the test fixtures 53 57 ...(systemChromium && {