···219219| `packages/core/src/storage/adapter.ts` | Storage interface definition |
220220| `entrypoints/background.ts` | Extension background worker |
221221| `entrypoints/content.ts` | Content script entry |
222222+223223+## Task Planning with Chainlink
224224+225225+This project uses [chainlink](https://github.com/anomalyco/chainlink) for issue tracking and task planning. **Always use chainlink when planning multi-step work.**
226226+227227+### When to Use Chainlink
228228+229229+- Planning features that touch multiple parts of the codebase
230230+- Breaking down complex refactoring work
231231+- Tracking bugs and their dependencies
232232+- Any task with 3+ steps that need to be tracked
233233+234234+### Basic Commands
235235+236236+```bash
237237+chainlink list # List all open issues
238238+chainlink tree # Show issues as hierarchy
239239+chainlink ready # Show issues ready to work on (no blockers)
240240+chainlink blocked # Show blocked issues and their blockers
241241+```
242242+243243+### Creating Issues
244244+245245+```bash
246246+# Create a top-level issue
247247+chainlink create "Issue title" -d "Description" -p high
248248+249249+# Create a subissue under a parent
250250+chainlink subissue <parent-id> "Subissue title" -d "Description"
251251+252252+# Set dependencies (issue 8 is blocked by issue 7)
253253+chainlink block 8 7
254254+```
255255+256256+### Working on Issues
257257+258258+```bash
259259+chainlink show <id> # View issue details
260260+chainlink start <id> # Start timer for an issue
261261+chainlink stop # Stop current timer
262262+chainlink close <id> # Close completed issue
263263+chainlink comment <id> "Progress note" # Add a comment
264264+```
265265+266266+### Planning Workflow
267267+268268+1. **Create parent issue** with problem description and high-level solution
269269+2. **Create subissues** for each discrete unit of work
270270+3. **Set blocking relationships** to establish execution order
271271+4. **Run `chainlink ready`** to see what can be worked on next
272272+5. **Close issues** as they're completed to unblock dependent work
273273+274274+### Example: Multi-Component Feature
275275+276276+```bash
277277+# Create main feature issue
278278+chainlink create "Add dark mode support" -p high -d "Add theme switching across the app"
279279+280280+# Create subissues for each component
281281+chainlink subissue 1 "Create theme config module"
282282+chainlink subissue 1 "Update extension UI components"
283283+chainlink subissue 1 "Update proxy UI components"
284284+chainlink subissue 1 "Add E2E tests for theme switching"
285285+286286+# Set dependencies
287287+chainlink block 3 2 # Extension UI blocked by config
288288+chainlink block 4 2 # Proxy UI blocked by config
289289+chainlink block 5 3 # Tests blocked by extension UI
290290+chainlink block 5 4 # Tests blocked by proxy UI
291291+```
+1-1
package.json
···1818 "test:server": "cd server && go test -v ./...",
1919 "test:server:coverage": "cd server && go test -coverprofile=coverage.out ./... && go tool cover -html=coverage.out -o coverage.html",
2020 "test:e2e": "playwright test",
2121- "test:e2e:extension": "node --env-file=tests/.env.test ./node_modules/@playwright/test/cli.js test --config=tests/playwright.config.ts --project=chrome-extension",
2121+ "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",
2222 "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",
2323 "test:all": "pnpm test && pnpm test:server"
2424 },
···7979 expect(browser.runtime.onMessage.addListener).toHaveBeenCalled();
8080 });
81818282+ it('sets panel behavior to open on action click', async () => {
8383+ await worker.start();
8484+8585+ // Verify setPanelBehavior is called with openPanelOnActionClick: true
8686+ // This ensures clicking the extension icon opens the side panel
8787+ expect(browser.sidePanel.setPanelBehavior).toHaveBeenCalledWith({
8888+ openPanelOnActionClick: true,
8989+ });
9090+ });
9191+8292 it('registers context menu for Firefox when useContextMenu is true', async () => {
8393 const firefoxWorker = new ExtensionBackgroundWorker({
8494 storage: mockStorage as unknown as StorageAdapter,
···1616import { test, expect } from '@playwright/test';
1717import {
1818 createExtensionContext,
1919+ getExtensionId,
1920 openSidePanel,
2021 selectText,
2122 loginWithTestAccount,
···5354 const selectedText = await selectText(page, 'p', 0, 20);
5455 expect(selectedText.length).toBeGreaterThan(0);
55565656- // Wait for selection to propagate to sidebar
5757- await sidebarPage.waitForTimeout(500);
5858-5959- // The annotation form should appear with the selected text
5757+ // Wait for the annotation form to appear with the selected text
6058 const annotationForm = sidebarPage.locator('#annotation-form');
6161- await expect(annotationForm).toBeVisible();
5959+ await expect(annotationForm).toBeVisible({ timeout: 5000 });
62606361 // Should show the selected text in blockquote
6462 const quoteBlock = sidebarPage.locator('#selected-text blockquote');
···7169 // Save the annotation
7270 await sidebarPage.locator('#save-btn').click();
73717474- // Wait for annotation to appear in the list
7575- await sidebarPage.waitForTimeout(2000);
7676-7777- // Verify the annotation was created
7272+ // Wait for our annotation to appear in the list
7373+ const testAnnotationText = 'Integration test annotation';
7874 const annotationCards = sidebarPage.locator('seams-annotation-card');
7979- const count = await annotationCards.count();
8080- expect(count).toBeGreaterThan(0);
7575+ const ourAnnotation = annotationCards.filter({ hasText: testAnnotationText });
7676+ await expect(ourAnnotation).toHaveCount(1, { timeout: 10000 });
81778278 // Note: Cleanup of test annotations should happen after test suite
8379 // via the test account management
···91879288 try {
9389 const page = await context.newPage();
9090+ const extensionId = await getExtensionId(context);
9191+9492 await page.goto('https://example.com/');
9593 await page.waitForLoadState('networkidle');
96949795 // Select text on the page
9898- await selectText(page, 'p', 5, 25);
9696+ const selectedText = await selectText(page, 'p', 5, 25);
9997100100- // Wait for floating button to appear (Chrome only)
9898+ // Wait for floating button to appear
10199 const floatingBtn = page.locator('#seams-annotate-btn');
100100+ await floatingBtn.waitFor({ state: 'visible', timeout: 5000 });
101101+102102+ // Set up listener BEFORE clicking - this is critical to avoid race condition
103103+ const sidebarPromise = context.waitForEvent('page', {
104104+ predicate: (p) => p.url().includes(extensionId) && p.url().includes('sidepanel.html'),
105105+ timeout: 10000,
106106+ });
102107103103- try {
104104- await floatingBtn.waitFor({ timeout: 2000 });
108108+ // Click the floating button - triggers ACTIVATE_ANNOTATION which opens sidebar
109109+ await floatingBtn.click();
105110106106- // Click the floating button
107107- await floatingBtn.click();
111111+ // Wait for sidebar page to appear
112112+ const sidebarPage = await sidebarPromise;
108113109109- // This should trigger the ACTIVATE_ANNOTATION message
110110- // which opens the sidebar
111111- await page.waitForTimeout(1000);
112112- } catch (e) {
113113- // Floating button may not appear if sidebar is already open
114114- // or on Firefox (which uses context menu instead)
115115- console.log(
116116- 'Floating button not visible - may be Firefox or sidebar already open'
117117- );
118118- }
114114+ // Verify the annotation form is visible
115115+ const annotationForm = sidebarPage.locator('#annotation-form');
116116+ await expect(annotationForm).toBeVisible({ timeout: 5000 });
117117+118118+ // Verify the selected text appears in the form
119119+ const quoteBlock = sidebarPage.locator('#selected-text blockquote');
120120+ await expect(quoteBlock).toContainText(selectedText.substring(0, 10));
119121 } finally {
120122 await context.close();
121123 }
···133135134136 // Select text
135137 await selectText(page, 'p', 0, 15);
136136- await sidebarPage.waitForTimeout(500);
137138138138- // Form should appear
139139+ // Wait for form to appear
139140 const annotationForm = sidebarPage.locator('#annotation-form');
140140- await expect(annotationForm).toBeVisible();
141141+ await expect(annotationForm).toBeVisible({ timeout: 5000 });
141142142143 // Click cancel/clear button
143144 const clearBtn = sidebarPage.locator('#clear-selection-btn');
+98-68
tests/e2e/extension/highlights.spec.ts
···1212import { test, expect } from '@playwright/test';
1313import {
1414 createExtensionContext,
1515+ openSidePanel,
1616+ waitForAnnotations,
1517 waitForHighlights,
1618} from '../../helpers/extension';
1719···2224 );
23252426 test('renders highlights on page with annotations', async () => {
2525- const context = await createExtensionContext();
2727+ const context = await createExtensionContext(true);
26282729 try {
2830 const page = await context.newPage();
···3133 await page.goto('https://example.com/');
3234 await page.waitForLoadState('networkidle');
33353434- try {
3535- // Wait for highlights to appear
3636- await waitForHighlights(page, 1, 5000);
3636+ // Wait for highlights to appear
3737+ await waitForHighlights(page, 1, 5000);
37383838- // Verify highlights have correct styling
3939- const highlights = page.locator('.seams-highlight');
4040- const count = await highlights.count();
4141- expect(count).toBeGreaterThan(0);
3939+ // Verify highlights have correct styling
4040+ const highlights = page.locator('.seams-highlight');
4141+ const count = await highlights.count();
4242+ expect(count).toBeGreaterThan(0);
42434343- // Check that highlights are visible
4444- const firstHighlight = highlights.first();
4545- await expect(firstHighlight).toBeVisible();
4444+ // Check that highlights are visible
4545+ const firstHighlight = highlights.first();
4646+ await expect(firstHighlight).toBeVisible();
46474747- // Verify background color is yellowish
4848- const bgColor = await firstHighlight.evaluate((el) =>
4949- getComputedStyle(el).backgroundColor
5050- );
5151- expect(bgColor).toContain('255'); // Yellow has high R and G values
5252- } catch (e) {
5353- console.log('No highlights found - golden database may not be seeded');
5454- }
4848+ // Verify background color is yellowish (rgba with high R and G, low-ish B)
4949+ const bgColor = await firstHighlight.evaluate((el) =>
5050+ getComputedStyle(el).backgroundColor
5151+ );
5252+ // Parse rgb(R, G, B) or rgba(R, G, B, A)
5353+ const match = bgColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
5454+ expect(match).not.toBeNull();
5555+ const [, r, g, b] = match!.map(Number);
5656+ // Yellow has high R (>200), high G (>200), variable B
5757+ expect(r).toBeGreaterThan(200);
5858+ expect(g).toBeGreaterThan(200);
5559 } finally {
5660 await context.close();
5761 }
5862 });
59636060- test('clicking highlight scrolls annotation into view', async () => {
6161- const context = await createExtensionContext();
6464+ test('clicking annotation card in sidebar scrolls page to highlight', async () => {
6565+ const context = await createExtensionContext(true);
62666367 try {
6468 const page = await context.newPage();
6569 await page.goto('https://example.com/');
6670 await page.waitForLoadState('networkidle');
67716868- try {
6969- await waitForHighlights(page, 1, 5000);
7272+ // Wait for highlights to render on the page
7373+ await waitForHighlights(page, 1, 5000);
7474+7575+ // Open sidebar
7676+ const sidebarPage = await openSidePanel(context, page);
7777+7878+ // Wait for annotations to load in sidebar
7979+ await waitForAnnotations(sidebarPage, 1, 5000);
70807171- const highlight = page.locator('.seams-highlight').first();
8181+ // Get the first highlight's position before clicking
8282+ const highlight = page.locator('.seams-highlight').first();
8383+ const highlightId = await highlight.getAttribute('data-annotation-id');
8484+8585+ // Scroll the page so the highlight is NOT in view
8686+ await page.evaluate(() => {
8787+ window.scrollTo(0, document.body.scrollHeight);
8888+ });
72897373- // Click the highlight
7474- await highlight.click();
9090+ // Verify highlight is not in viewport
9191+ const isInViewBefore = await highlight.evaluate((el) => {
9292+ const rect = el.getBoundingClientRect();
9393+ return rect.top >= 0 && rect.bottom <= window.innerHeight;
9494+ });
9595+ expect(isInViewBefore).toBe(false);
75967676- // This should trigger sidebar to open or scroll to the annotation
7777- // The exact behavior depends on the implementation
7878- // For now, just verify the click doesn't error
9797+ // Click the annotation card in the sidebar
9898+ const annotationCard = sidebarPage.locator('seams-annotation-card').first();
9999+ await annotationCard.click();
791008080- expect(true).toBe(true);
8181- } catch (e) {
8282- console.log('No highlights found - golden database may not be seeded');
8383- }
101101+ // Wait for highlight to be scrolled into view
102102+ await page.waitForFunction(
103103+ (el) => {
104104+ const rect = el.getBoundingClientRect();
105105+ return rect.top >= 0 && rect.bottom <= window.innerHeight;
106106+ },
107107+ await highlight.elementHandle(),
108108+ { timeout: 5000 }
109109+ );
110110+111111+ // Verify the highlight is now scrolled into view
112112+ const isInViewAfter = await highlight.evaluate((el) => {
113113+ const rect = el.getBoundingClientRect();
114114+ return rect.top >= 0 && rect.bottom <= window.innerHeight;
115115+ });
116116+ expect(isInViewAfter).toBe(true);
84117 } finally {
85118 await context.close();
86119 }
87120 });
8812189122 test('highlights persist across page navigation', async () => {
9090- const context = await createExtensionContext();
123123+ const context = await createExtensionContext(true);
9112492125 try {
93126 const page = await context.newPage();
···105138 await page.waitForLoadState('networkidle');
106139107140 // Highlights should reappear
108108- try {
109109- await waitForHighlights(page, 1, 5000);
110110- const highlights = page.locator('.seams-highlight');
111111- const count = await highlights.count();
112112- expect(count).toBeGreaterThan(0);
113113- } catch (e) {
114114- console.log('No highlights found - golden database may not be seeded');
115115- }
141141+ await waitForHighlights(page, 1, 5000);
142142+ const highlights = page.locator('.seams-highlight');
143143+ const count = await highlights.count();
144144+ expect(count).toBeGreaterThan(0);
116145 } finally {
117146 await context.close();
118147 }
···123152 // MutationObserver triggering loadAndRenderHighlights which modifies DOM
124153 // which triggers MutationObserver again, causing infinite loop and memory exhaustion
125154126126- const context = await createExtensionContext();
155155+ const context = await createExtensionContext(true);
127156128157 try {
129158 const page = await context.newPage();
···132161 await page.goto('https://example.com/');
133162 await page.waitForLoadState('networkidle');
134163135135- // Get initial memory usage (if available)
136136- const initialMetrics = await page.evaluate(() => {
137137- return (performance as any).memory?.usedJSHeapSize || 0;
138138- });
164164+ // MUST have highlights to test render loop behavior
165165+ await waitForHighlights(page, 1, 5000);
166166+167167+ // Get initial highlight count
168168+ const initialCount = await page.locator('.seams-highlight').count();
169169+ expect(initialCount).toBeGreaterThan(0);
139170140171 // Simulate dynamic content changes that could trigger infinite loop
141172 for (let i = 0; i < 10; i++) {
···145176 div.className = 'dynamic-test-content';
146177 document.body.appendChild(div);
147178 }, i);
148148- // Small delay between mutations
149149- await page.waitForTimeout(100);
150179 }
151180152152- // Wait for any debounced re-renders to complete
153153- await page.waitForTimeout(1000);
181181+ // Wait for highlight count to stabilize (no more re-renders happening)
182182+ // If infinite loop exists, count will keep growing
183183+ await page.waitForFunction(
184184+ (expected) => {
185185+ const count = document.querySelectorAll('.seams-highlight').length;
186186+ // Store count and check again after a tick
187187+ return new Promise(resolve => {
188188+ setTimeout(() => {
189189+ const newCount = document.querySelectorAll('.seams-highlight').length;
190190+ // Count should be stable (not growing) and within tolerance
191191+ resolve(newCount === count && newCount <= expected * 2);
192192+ }, 500);
193193+ });
194194+ },
195195+ initialCount,
196196+ { timeout: 10000 }
197197+ );
154198155155- // Check that memory hasn't grown excessively (sign of infinite loop)
156156- const finalMetrics = await page.evaluate(() => {
157157- return (performance as any).memory?.usedJSHeapSize || 0;
158158- });
159159-160160- // If both metrics are available, memory shouldn't have grown more than 50MB
161161- // (arbitrary threshold - infinite loop would grow by hundreds of MB)
162162- if (initialMetrics > 0 && finalMetrics > 0) {
163163- const growth = finalMetrics - initialMetrics;
164164- expect(growth).toBeLessThan(50 * 1024 * 1024); // 50MB
165165- }
166166-167167- // Verify the page is still responsive (not stuck in loop)
168168- const isResponsive = await page.evaluate(() => {
169169- return document.body !== null;
170170- });
171171- expect(isResponsive).toBe(true);
199199+ // Verify highlights weren't duplicated excessively
200200+ const finalCount = await page.locator('.seams-highlight').count();
201201+ expect(finalCount).toBeLessThanOrEqual(initialCount * 2);
172202173203 // Clean up test elements
174204 await page.evaluate(() => {
+62-71
tests/e2e/extension/sidebar.spec.ts
···991010import { test, expect } from '@playwright/test';
1111import {
1212- createExtensionContext,
1313- openSidePanel,
1414- waitForAnnotations,
1212+ createExtensionContext,
1313+ openSidePanel,
1414+ waitForAnnotations,
1515} from '../../helpers/extension';
16161717test.describe('Extension Sidebar', () => {
1818- test.skip(
1919- !process.env.RUN_EXTENSION_TESTS,
2020- 'Set RUN_EXTENSION_TESTS=1 to run extension tests'
2121- );
1818+ test.skip(
1919+ !process.env.RUN_EXTENSION_TESTS,
2020+ 'Set RUN_EXTENSION_TESTS=1 to run extension tests'
2121+ );
22222323- test('displays sidebar with login prompt when not authenticated', async () => {
2424- const context = await createExtensionContext();
2323+ test('displays sidebar with login prompt when not authenticated', async () => {
2424+ const context = await createExtensionContext();
25252626- try {
2727- const page = await context.newPage();
2828- await page.goto('https://example.com/');
2626+ try {
2727+ const page = await context.newPage();
2828+ await page.goto('https://example.com/');
29293030- // Open sidebar
3131- const sidebarPage = await openSidePanel(context, page);
3030+ // Open sidebar
3131+ const sidebarPage = await openSidePanel(context, page);
32323333- // Should show login trigger or login form
3434- const loginTrigger = sidebarPage.locator('#login-trigger-btn');
3535- const loginForm = sidebarPage.locator('#handle-input');
3333+ // Should show login trigger or login form
3434+ const loginTrigger = sidebarPage.locator('#login-trigger-btn');
3535+ const loginForm = sidebarPage.locator('#handle-input');
36363737- const hasLoginUI =
3838- (await loginTrigger.isVisible()) || (await loginForm.isVisible());
3939- expect(hasLoginUI).toBe(true);
3737+ const hasLoginUI =
3838+ (await loginTrigger.isVisible()) || (await loginForm.isVisible());
3939+ expect(hasLoginUI).toBe(true);
40404141- // Should show "Log in to create annotations" message
4242- await expect(sidebarPage.locator('text=Log in to create')).toBeVisible();
4343- } finally {
4444- await context.close();
4545- }
4646- });
4141+ // Should show "Log in to create annotations" message
4242+ await expect(sidebarPage.locator('text=Log in to create')).toBeVisible();
4343+ } finally {
4444+ await context.close();
4545+ }
4646+ });
47474848- test('displays annotations from golden database', async () => {
4949- const context = await createExtensionContext();
4848+ test('displays annotations from golden database', async () => {
4949+ const context = await createExtensionContext(true);
50505151- try {
5252- const page = await context.newPage();
5151+ try {
5252+ const page = await context.newPage();
53535454- // Navigate to a URL that has annotations in the golden database
5555- await page.goto('https://example.com/');
5656- await page.waitForLoadState('networkidle');
5454+ // Navigate to a URL that has annotations in the golden database
5555+ await page.goto('https://example.com/');
5656+ await page.waitForLoadState('networkidle');
57575858- // Open sidebar
5959- const sidebarPage = await openSidePanel(context, page);
5858+ // Open sidebar
5959+ const sidebarPage = await openSidePanel(context, page);
60606161- // Wait for annotations to load
6262- try {
6363- await waitForAnnotations(sidebarPage, 1, 5000);
6161+ // Wait for annotations to load
6262+ await waitForAnnotations(sidebarPage, 1, 5000);
64636565- // Verify annotation cards are present
6666- const annotationCards = sidebarPage.locator('seams-annotation-card');
6767- const count = await annotationCards.count();
6868- expect(count).toBeGreaterThan(0);
6969- } catch (e) {
7070- // No annotations in golden DB yet - this is expected until DB is seeded
7171- console.log(
7272- 'No annotations found - golden database may not be seeded yet'
7373- );
7474- }
7575- } finally {
7676- await context.close();
7777- }
7878- });
6464+ // Verify annotation cards are present
6565+ const annotationCards = sidebarPage.locator('seams-annotation-card');
6666+ const count = await annotationCards.count();
6767+ expect(count).toBeGreaterThan(0);
6868+ } finally {
6969+ await context.close();
7070+ }
7171+ });
79728080- test('shows empty state when no annotations exist for page', async () => {
8181- const context = await createExtensionContext();
7373+ test('shows empty state when no annotations exist for page', async () => {
7474+ const context = await createExtensionContext();
82758383- try {
8484- const page = await context.newPage();
7676+ try {
7777+ const page = await context.newPage();
85788686- // Navigate to a URL with no annotations
8787- await page.goto('https://example.com/no-annotations-page');
8888- await page.waitForLoadState('networkidle');
7979+ // Navigate to a URL with no annotations
8080+ await page.goto('https://example.com/no-annotations-page');
8181+ await page.waitForLoadState('networkidle');
89829090- // Open sidebar
9191- const sidebarPage = await openSidePanel(context, page);
8383+ // Open sidebar
8484+ const sidebarPage = await openSidePanel(context, page);
92859393- // Wait a bit for any annotations to load
9494- await sidebarPage.waitForTimeout(2000);
8686+ // Wait for empty state to appear (sidebar finishes loading and shows empty state)
8787+ await expect(
8888+ sidebarPage.locator('text=No annotations yet')
8989+ ).toBeVisible({ timeout: 5000 });
9090+ } finally {
9191+ await context.close();
9292+ }
9393+ });
95949696- // Should show empty state message
9797- await expect(
9898- sidebarPage.locator('text=No annotations yet')
9999- ).toBeVisible();
100100- } finally {
101101- await context.close();
102102- }
103103- });
10495});
+174-31
tests/helpers/extension.ts
···77import { copyFileSync, mkdirSync, existsSync } from 'fs';
88import { handleExtensionOAuthPopup } from './oauth-automation';
991010+// Enable Playwright to attach to Chrome side panel targets
1111+// See: https://github.com/microsoft/playwright/issues/26693
1212+process.env.PW_CHROMIUM_ATTACH_TO_OTHER = '1';
1313+1014const EXTENSION_PATH = path.resolve(__dirname, '../../.output/chrome-mv3');
1115const GOLDEN_DB_PATH = path.resolve(__dirname, '../fixtures/golden-db/annotations.db');
1216const SERVER_DB_PATH = path.resolve(__dirname, '../../server/db/annotations.db');
13171418/**
1519 * Creates a browser context with the Seams extension loaded
2020+ * @param requireGoldenDb - If true, throws if golden database is missing. Default: false.
1621 */
1717-export async function createExtensionContext(): Promise<BrowserContext> {
2222+export async function createExtensionContext(requireGoldenDb: boolean = false): Promise<BrowserContext> {
1823 // Verify extension is built
1924 if (!existsSync(EXTENSION_PATH)) {
2025 throw new Error(
···2227 );
2328 }
24292525- // Copy golden database to server location for tests
3030+ // Copy golden database to server location for tests (if it exists)
2631 if (existsSync(GOLDEN_DB_PATH)) {
2732 mkdirSync(path.dirname(SERVER_DB_PATH), { recursive: true });
2833 copyFileSync(GOLDEN_DB_PATH, SERVER_DB_PATH);
2929- console.log('[test] Copied golden database to server location');
3434+ } else if (requireGoldenDb) {
3535+ throw new Error(
3636+ `Golden database not found at ${GOLDEN_DB_PATH}. ` +
3737+ `This test requires pre-seeded annotations. See tests/fixtures/GOLDEN_DATA.md for setup instructions.`
3838+ );
3039 }
31403241 // Launch Chrome with extension
···3746 `--load-extension=${EXTENSION_PATH}`,
3847 '--no-first-run',
3948 '--disable-default-apps',
4949+ '--start-maximized', // Needed for proper side panel viewport with PW_CHROMIUM_ATTACH_TO_OTHER
4050 ],
4151 });
4252···6878}
69797080/**
7171- * Opens the Seams sidebar panel
7272- * Note: Chrome sidePanel API requires user gesture or action click
8181+ * Opens the Seams sidebar panel using the real floating button flow
8282+ *
8383+ * This tests the actual user interaction:
8484+ * 1. Select text on the page to trigger the floating "Annotate" button
8585+ * 2. Click the floating button, which sends ACTIVATE_ANNOTATION to background
8686+ * 3. Background worker calls browser.sidePanel.open()
8787+ * 4. Wait for the side panel page to appear via context.waitForEvent('page')
8888+ * 5. Wait for seams-sidebar component to initialize
8989+ * 6. Clear the text selection
7390 */
7491export async function openSidePanel(
7592 context: BrowserContext,
···7794): Promise<Page> {
7895 const extensionId = await getExtensionId(context);
79968080- // The sidebar URL format for Chrome extensions
8181- const sidebarUrl = `chrome-extension://${extensionId}/sidepanel.html`;
9797+ // 1. Select text to trigger floating button
9898+ await selectText(page, 'p', 0, 10);
9999+100100+ // 2. Wait for floating button to appear
101101+ const floatingBtn = page.locator('#seams-annotate-btn');
102102+ await floatingBtn.waitFor({ state: 'visible', timeout: 5000 });
103103+104104+ // 3. Set up listener for side panel page BEFORE clicking
105105+ const sidebarPromise = context.waitForEvent('page', {
106106+ predicate: (p: Page) => p.url().includes(extensionId) && p.url().includes('sidepanel.html'),
107107+ timeout: 10000,
108108+ });
109109+110110+ // 4. Click floating button - triggers ACTIVATE_ANNOTATION → sidePanel.open()
111111+ await floatingBtn.click();
112112+113113+ // 5. Wait for the side panel page to appear
114114+ const sidebarPage = await sidebarPromise;
115115+116116+ // 6. Wait for seams-sidebar component to be fully initialized
117117+ // This replaces the arbitrary 500ms sleep with actual readiness checks
118118+ await sidebarPage.waitForSelector('seams-sidebar', { state: 'attached', timeout: 10000 });
119119+120120+ // Wait for shadow DOM to be ready AND for the sidebar to have loaded its URL
121121+ await sidebarPage.waitForFunction(
122122+ () => {
123123+ const sidebarEl = document.querySelector('seams-sidebar') as any;
124124+ if (!sidebarEl?.shadowRoot) return false;
125125+126126+ // Check that #sidebar-root exists (component initialized)
127127+ const root = sidebarEl.shadowRoot.querySelector('#sidebar-root');
128128+ if (!root) return false;
129129+130130+ // Check that the sidebar has a currentUrl set (means it queried the active tab)
131131+ // This is the key signal that initialization is complete
132132+ return typeof sidebarEl.currentUrl === 'string' && sidebarEl.currentUrl.length > 0;
133133+ },
134134+ { timeout: 10000 }
135135+ );
821368383- // Open sidebar in a new page (for testing purposes)
8484- // In real usage, this would be opened via the extension action
8585- const sidebarPage = await context.newPage();
8686- await sidebarPage.goto(sidebarUrl);
137137+ // 7. Clear the text selection on the content page
138138+ await page.evaluate(() => window.getSelection()?.removeAllRanges());
8713988140 return sidebarPage;
89141}
9014291143/**
92144 * Waits for annotations to appear in the sidebar
9393- * Note: The sidebar uses Shadow DOM, so we need to query inside the shadow root
145145+ *
146146+ * The sidebar structure is:
147147+ * - <seams-sidebar> (custom element with Shadow DOM)
148148+ * - #sidebar-root (container created by web component)
149149+ * - .sidebar (rendered by Sidebar class)
150150+ * - #annotations (list container)
151151+ * - <seams-annotation-card> elements
94152 */
95153export async function waitForAnnotations(
96154 sidebarPage: Page,
97155 minCount: number = 1,
98156 timeout: number = 10000
99157): Promise<void> {
100100- // Playwright's waitForSelector pierces Shadow DOM automatically
101101- await sidebarPage.waitForSelector('seams-sidebar seams-annotation-card', {
102102- timeout,
103103- state: 'attached',
104104- });
105105-106106- // For waitForFunction, we need to manually traverse shadow roots
158158+ // Wait for annotation cards to appear inside the shadow DOM
107159 await sidebarPage.waitForFunction(
108160 (count) => {
109161 const sidebarEl = document.querySelector('seams-sidebar');
110162 if (!sidebarEl?.shadowRoot) return false;
111163112164 // Query inside the sidebar's shadow root
113113- const container = sidebarEl.shadowRoot.querySelector('#sidebar-container');
114114- if (!container) return false;
165165+ // The structure is: shadowRoot -> #sidebar-root -> .sidebar -> #annotations -> seams-annotation-card
166166+ const annotationsContainer = sidebarEl.shadowRoot.querySelector('#annotations');
167167+ if (!annotationsContainer) return false;
115168116116- const cards = container.querySelectorAll('seams-annotation-card');
169169+ const cards = annotationsContainer.querySelectorAll('seams-annotation-card');
117170 return cards.length >= count;
118171 },
119172 minCount,
···145198}
146199147200/**
148148- * Selects text on a page
201201+ * Selects text on a page and returns both the text and mock selectors
149202 */
150203export async function selectText(
151204 page: Page,
···158211 const element = document.querySelector(selector);
159212 if (!element) throw new Error(`Element not found: ${selector}`);
160213161161- const textNode = element.firstChild;
162162- if (!textNode || textNode.nodeType !== Node.TEXT_NODE) {
214214+ // Find the first text node (may not be firstChild if there's whitespace/comments)
215215+ let textNode: Node | null = null;
216216+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
217217+ while (walker.nextNode()) {
218218+ if (walker.currentNode.textContent?.trim()) {
219219+ textNode = walker.currentNode;
220220+ break;
221221+ }
222222+ }
223223+224224+ if (!textNode) {
163225 throw new Error(`No text node found in: ${selector}`);
164226 }
165227···167229 const end = endOffset ?? text.length;
168230169231 const range = document.createRange();
170170- range.setStart(textNode, startOffset);
232232+ range.setStart(textNode, Math.min(startOffset, text.length));
171233 range.setEnd(textNode, Math.min(end, text.length));
172234173235 const selection = window.getSelection();
···186248}
187249188250/**
251251+ * Sends a text selection to the sidebar
252252+ *
253253+ * This simulates what the content script does when text is selected:
254254+ * it sends a SELECTION_CHANGED message to the sidebar with the text and selectors.
255255+ *
256256+ * Since we can't easily generate real W3C selectors in the test context,
257257+ * we create mock selectors that are sufficient for testing the UI flow.
258258+ */
259259+export async function sendSelectionToSidebar(
260260+ sidebarPage: Page,
261261+ text: string,
262262+ sourceUrl: string
263263+): Promise<void> {
264264+ await sidebarPage.evaluate(
265265+ ({ text, sourceUrl }) => {
266266+ const sidebarEl = document.querySelector('seams-sidebar') as any;
267267+ if (sidebarEl?.setSelection) {
268268+ // Create mock selectors similar to what the real content script generates
269269+ const selectors = [
270270+ {
271271+ $type: 'community.lexicon.annotation.annotation#textQuoteSelector',
272272+ exact: text,
273273+ prefix: '',
274274+ suffix: '',
275275+ },
276276+ ];
277277+ sidebarEl.setSelection({ text, selectors });
278278+ }
279279+ },
280280+ { text, sourceUrl }
281281+ );
282282+}
283283+284284+/**
189285 * Login to Seams with a test account
286286+ *
287287+ * This function:
288288+ * 1. Triggers the OAuth flow from the sidebar
289289+ * 2. Automates the PDS login form in the popup
290290+ * 3. Waits for the extension to process the OAuth callback
291291+ * 4. Verifies login succeeded by checking both storage and UI
292292+ *
293293+ * IMPORTANT: The popup closing does NOT mean login succeeded. We MUST verify
294294+ * the session exists in storage as the authoritative success check.
190295 */
191296export async function loginWithTestAccount(
192297 context: BrowserContext,
···214319 const handleInput = sidebarPage.locator('#handle-input');
215320 await handleInput.fill(handle);
216321217217- // Click login button
322322+ // Click login button - this opens the OAuth popup
218323 await sidebarPage.locator('#login-btn').click();
219324220220- // Wait for OAuth to complete
221221- await popupPromise;
325325+ // Wait for OAuth popup automation to complete
326326+ const result = await popupPromise;
327327+328328+ if (!result.completed) {
329329+ throw new Error(`OAuth flow failed before completion: ${result.error}`);
330330+ }
331331+332332+ // CRITICAL: The popup completing does NOT mean login succeeded.
333333+ // We MUST verify the session exists in storage - this is the ONLY authoritative check.
334334+ let sessionExists = false;
335335+ try {
336336+ sessionExists = await sidebarPage.waitForFunction(
337337+ () => {
338338+ return new Promise((resolve) => {
339339+ // Access chrome.storage.local to check for session
340340+ // @ts-ignore - chrome is available in extension context
341341+ if (typeof chrome !== 'undefined' && chrome.storage?.local) {
342342+ chrome.storage.local.get('seams-oauth-session', (result: any) => {
343343+ const session = result?.['seams-oauth-session'];
344344+ resolve(session !== null && session !== undefined);
345345+ });
346346+ } else {
347347+ // Fallback: check if we're in a context where chrome isn't available
348348+ resolve(false);
349349+ }
350350+ });
351351+ },
352352+ { timeout: 30000 }
353353+ ).then(() => true);
354354+ } catch {
355355+ sessionExists = false;
356356+ }
357357+358358+ if (!sessionExists) {
359359+ throw new Error(
360360+ 'OAuth popup completed but no session found in storage. ' +
361361+ 'Authorization may have been denied or PDS returned an error.'
362362+ );
363363+ }
222364223223- // Verify login succeeded
224224- await sidebarPage.waitForSelector('#profile-avatar', { timeout: 30000 });
365365+ // Also verify the UI shows logged-in state (profile avatar)
366366+ // This confirms the sidebar has re-rendered with the session
367367+ await sidebarPage.waitForSelector('#profile-avatar', { timeout: 10000 });
225368}
+43-8
tests/helpers/oauth-automation.ts
···1616 * 2. Fill identifier and password
1717 * 3. Click "Authorize" button on consent screen
1818 *
1919+ * Note: This function does NOT wait for the redirect to complete because
2020+ * browser.identity.launchWebAuthFlow captures the redirect URL and closes
2121+ * the popup immediately. The popup may close before we can observe the URL change.
2222+ *
1923 * @param page - Playwright page on the PDS auth URL
2024 * @param credentials - User credentials
2121- * @param callbackUrlPattern - Regex to match the OAuth callback URL (default: extension callback)
2225 */
2326export async function completePDSLogin(
2427 page: Page,
2525- credentials: { identifier: string; password: string },
2626- callbackUrlPattern: RegExp = /seams\.so\/oauth\/callback/
2828+ credentials: { identifier: string; password: string }
2729): Promise<void> {
2830 // Wait for PDS authorize page to load (React app hydration)
2931 await page.waitForLoadState('networkidle');
···6870 await authorizeBtn.waitFor({ state: 'visible', timeout: 10000 });
6971 await authorizeBtn.click();
70727171- // Wait for redirect back to seams callback
7272- await page.waitForURL(callbackUrlPattern, { timeout: 30000 });
7373+ // Don't wait for redirect - the popup will be closed by Chrome's launchWebAuthFlow
7474+ // after it captures the redirect URL. The extension will process the callback asynchronously.
7575+}
7676+7777+export interface OAuthPopupResult {
7878+ /** Whether the OAuth flow completed (popup closed). Does NOT mean login succeeded. */
7979+ completed: boolean;
8080+ /** Error message if the flow failed before completion */
8181+ error?: string;
7382}
74837584/**
7685 * Wait for OAuth popup and automate login
8686+ *
8787+ * Returns a result indicating whether authorization was completed.
8888+ * IMPORTANT: Callers MUST verify login success separately by checking
8989+ * for session in storage. The popup closing does NOT guarantee success -
9090+ * it could close due to: user denial, PDS error, or Chrome capturing redirect.
7791 */
7892export async function handleExtensionOAuthPopup(
7993 context: BrowserContext,
8094 credentials: { identifier: string; password: string }
8181-): Promise<void> {
9595+): Promise<OAuthPopupResult> {
8296 // browser.identity.launchWebAuthFlow creates a popup
8397 const popup = await context.waitForEvent('page');
8484- await completePDSLogin(popup, credentials);
8585- // Popup closes automatically after redirect is captured
9898+9999+ try {
100100+ await completePDSLogin(popup, credentials);
101101+102102+ // If we get here, we clicked authorize and the popup is still open
103103+ // Wait briefly for Chrome to capture the redirect and close the popup
104104+ await popup.waitForEvent('close', { timeout: 5000 }).catch(() => {
105105+ // Popup didn't close within timeout - might be an error page
106106+ });
107107+108108+ return { completed: true };
109109+ } catch (error: any) {
110110+ // Popup closed during login flow
111111+ if (error.message?.includes('Target page, context or browser has been closed')) {
112112+ // Popup closed - but we don't know why. Could be:
113113+ // 1. Success: Chrome captured redirect URL
114114+ // 2. Failure: User denied, PDS error, etc.
115115+ // Caller MUST verify by checking for session in storage
116116+ return { completed: true }; // "completed" means flow finished, not succeeded
117117+ }
118118+119119+ return { completed: false, error: error.message };
120120+ }
86121}
+4
tests/playwright.config.ts
···4848 testMatch: /extension\/.*\.spec\.ts/,
4949 use: {
5050 ...devices['Desktop Chrome'],
5151+ // Override viewport for side panel testing with PW_CHROMIUM_ATTACH_TO_OTHER
5252+ // See: https://github.com/microsoft/playwright/issues/26693
5353+ viewport: null,
5454+ deviceScaleFactor: undefined,
5155 // Extension testing requires special launch options
5256 // These are handled in the test fixtures
5357 ...(systemChromium && {