Social Annotations in the Atmosphere
15
fork

Configure Feed

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

fix: wait for backend indexing before showing new annotations

- Replace optimistic UI with saving state that waits for backend confirmation
- Add retry logic to indexInBackend (3 retries with exponential backoff)
- Show spinner and disable save button while saving
- Add dev:all script to run full local stack
- Make dev:proxy explicit about using prod backend

+632 -92
+3 -2
package.json
··· 9 9 "zip": "wxt zip -b firefox && wxt zip -b chrome", 10 10 "build:landing": "vite build --config vite.landing.config.ts", 11 11 "build:proxy": "vite build --config vite.proxy.config.ts && vite build --config vite.proxy-inject.config.ts", 12 - "dev:proxy": "pnpm build:proxy && concurrently \"cd proxy/cors-proxy && npm run dev\" \"npx serve proxy/dist -l 8081\"", 13 - "dev:server": "pnpm run build:landing && cd server && air", 12 + "dev:proxy": "NODE_ENV=development VITE_BACKEND_URL=https://seams.so pnpm build:proxy && concurrently \"cd proxy/cors-proxy && npm run dev\" \"npx serve proxy/dist -l 8081\"", 13 + "dev:server": "NODE_ENV=development pnpm run build:landing && cd server && air", 14 + "dev:all": "NODE_ENV=development VITE_BACKEND_URL=http://127.0.0.1:8080 pnpm build:proxy && concurrently --names \"server,cors,proxy\" -c \"green,yellow,blue\" \"cd server && air\" \"cd proxy/cors-proxy && npm run dev\" \"npx serve proxy/dist -l 8081\"", 14 15 "test": "pnpm --filter @seams/core test", 15 16 "test:coverage": "pnpm --filter @seams/core test:coverage", 16 17 "test:watch": "pnpm --filter @seams/core test:watch",
+26
packages/core/src/components/sidebar-styles.ts
··· 80 80 background: ${FOREST_GREEN_DARK}; 81 81 } 82 82 83 + button:disabled { 84 + opacity: 0.7; 85 + cursor: not-allowed; 86 + } 87 + 88 + button:disabled:hover { 89 + background: ${FOREST_GREEN}; 90 + } 91 + 92 + /* Spinner for loading states */ 93 + .spinner { 94 + display: inline-block; 95 + width: 12px; 96 + height: 12px; 97 + border: 2px solid #ffffff; 98 + border-top-color: transparent; 99 + border-radius: 50%; 100 + animation: spin 0.8s linear infinite; 101 + margin-right: 6px; 102 + vertical-align: middle; 103 + } 104 + 105 + @keyframes spin { 106 + to { transform: rotate(360deg); } 107 + } 108 + 83 109 .annotation-form { 84 110 margin: 16px 0; 85 111 padding: 16px;
+145 -28
packages/core/src/pds/__tests__/pds.test.ts
··· 132 132 expect(callBody.record.target[0].source).toBe('https://example.com/page'); 133 133 }); 134 134 135 - it('indexes annotation in backend', async () => { 136 - mockAgentHandle.mockResolvedValue({ 137 - ok: true, 138 - json: async () => ({ 139 - uri: 'at://did:plc:test/col/abc', 140 - cid: 'bafycid', 141 - }), 142 - }); 143 - 144 - mockFetch.mockResolvedValue({ ok: true }); 145 - 146 - await client.createAnnotation(baseAnnotation); 147 - 148 - // Wait for async indexing 149 - await new Promise((r) => setTimeout(r, 0)); 150 - 151 - expect(mockFetch).toHaveBeenCalledWith( 152 - 'https://seams.so/api/annotations/index', 153 - expect.objectContaining({ 154 - method: 'POST', 155 - body: JSON.stringify({ 156 - uri: 'at://did:plc:test/col/abc', 157 - cid: 'bafycid', 158 - }), 159 - }) 160 - ); 161 - }); 162 - 163 135 it('throws on authentication failure', async () => { 164 136 mockOAuth.loadSession.mockResolvedValue(null); 165 137 ··· 362 334 363 335 expect(result).toEqual([]); 364 336 expect(console.error).toHaveBeenCalled(); 337 + }); 338 + }); 339 + 340 + describe('indexInBackend', () => { 341 + const testUri = 'at://did:plc:test/community.lexicon.annotation.annotation/abc123'; 342 + const testCid = 'bafycid123'; 343 + 344 + it('returns true on successful indexing', async () => { 345 + mockFetch.mockResolvedValue({ ok: true }); 346 + 347 + const result = await client.indexInBackend(testUri, testCid); 348 + 349 + expect(result).toBe(true); 350 + expect(mockFetch).toHaveBeenCalledWith( 351 + 'https://seams.so/api/annotations/index', 352 + expect.objectContaining({ 353 + method: 'POST', 354 + body: JSON.stringify({ uri: testUri, cid: testCid }), 355 + }) 356 + ); 357 + }); 358 + 359 + it('retries on failure with delays', async () => { 360 + vi.useFakeTimers(); 361 + 362 + // First two calls fail, third succeeds 363 + mockFetch 364 + .mockResolvedValueOnce({ ok: false, status: 500 }) 365 + .mockResolvedValueOnce({ ok: false, status: 500 }) 366 + .mockResolvedValueOnce({ ok: true }); 367 + 368 + const promise = client.indexInBackend(testUri, testCid); 369 + 370 + // First attempt fails immediately 371 + await vi.advanceTimersByTimeAsync(0); 372 + expect(mockFetch).toHaveBeenCalledTimes(1); 373 + 374 + // Wait for first retry delay (500ms) 375 + await vi.advanceTimersByTimeAsync(500); 376 + expect(mockFetch).toHaveBeenCalledTimes(2); 377 + 378 + // Wait for second retry delay (1000ms) 379 + await vi.advanceTimersByTimeAsync(1000); 380 + expect(mockFetch).toHaveBeenCalledTimes(3); 381 + 382 + const result = await promise; 383 + expect(result).toBe(true); 384 + 385 + vi.useRealTimers(); 386 + }); 387 + 388 + it('returns false after all retries exhausted', async () => { 389 + vi.useFakeTimers(); 390 + 391 + // All calls fail 392 + mockFetch.mockResolvedValue({ ok: false, status: 500 }); 393 + 394 + const promise = client.indexInBackend(testUri, testCid); 395 + 396 + // Advance through all retries 397 + await vi.advanceTimersByTimeAsync(0); // First attempt 398 + await vi.advanceTimersByTimeAsync(500); // First retry 399 + await vi.advanceTimersByTimeAsync(1000); // Second retry 400 + 401 + const result = await promise; 402 + expect(result).toBe(false); 403 + expect(mockFetch).toHaveBeenCalledTimes(3); 404 + expect(console.error).toHaveBeenCalledWith('[pds] All indexing retries exhausted'); 405 + 406 + vi.useRealTimers(); 407 + }); 408 + 409 + it('returns false on network error after retries', async () => { 410 + vi.useFakeTimers(); 411 + 412 + mockFetch.mockRejectedValue(new Error('Network error')); 413 + 414 + const promise = client.indexInBackend(testUri, testCid); 415 + 416 + // Advance through all retries 417 + await vi.advanceTimersByTimeAsync(0); 418 + await vi.advanceTimersByTimeAsync(500); 419 + await vi.advanceTimersByTimeAsync(1000); 420 + 421 + const result = await promise; 422 + expect(result).toBe(false); 423 + 424 + vi.useRealTimers(); 425 + }); 426 + 427 + it('respects custom maxRetries parameter', async () => { 428 + vi.useFakeTimers(); 429 + 430 + mockFetch.mockResolvedValue({ ok: false, status: 500 }); 431 + 432 + const promise = client.indexInBackend(testUri, testCid, 2); 433 + 434 + await vi.advanceTimersByTimeAsync(0); // First attempt 435 + await vi.advanceTimersByTimeAsync(500); // First retry (only retry since maxRetries=2) 436 + 437 + const result = await promise; 438 + expect(result).toBe(false); 439 + expect(mockFetch).toHaveBeenCalledTimes(2); 440 + 441 + vi.useRealTimers(); 442 + }); 443 + }); 444 + 445 + describe('createAnnotation without auto-indexing', () => { 446 + const baseAnnotation: Annotation = { 447 + uri: '', 448 + cid: '', 449 + value: { 450 + target: { 451 + url: 'https://example.com/page', 452 + selector: [ 453 + { 454 + $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 455 + exact: 'Selected text', 456 + prefix: 'before ', 457 + suffix: ' after', 458 + }, 459 + ], 460 + }, 461 + body: 'Test annotation', 462 + createdAt: '2024-01-01T12:00:00Z', 463 + }, 464 + }; 465 + 466 + it('does not automatically call indexInBackend', async () => { 467 + mockAgentHandle.mockResolvedValue({ 468 + ok: true, 469 + json: async () => ({ 470 + uri: 'at://did:plc:test/community.lexicon.annotation.annotation/abc123', 471 + cid: 'bafycid123', 472 + }), 473 + }); 474 + 475 + await client.createAnnotation(baseAnnotation); 476 + 477 + // Wait a tick to ensure no async indexing was triggered 478 + await new Promise((r) => setTimeout(r, 10)); 479 + 480 + // fetch should NOT have been called for indexing 481 + expect(mockFetch).not.toHaveBeenCalled(); 365 482 }); 366 483 }); 367 484 });
+33 -15
packages/core/src/pds/index.ts
··· 45 45 return { agent: new OAuthUserAgent(session), session }; 46 46 } 47 47 48 - private async indexInBackend(uri: string, cid: string) { 49 - try { 50 - await fetch(`${this.config.backendUrl}/api/annotations/index`, { 51 - method: 'POST', 52 - headers: { 'Content-Type': 'application/json' }, 53 - body: JSON.stringify({ uri, cid }), 54 - }); 55 - console.log('[pds] Annotation indexed in backend'); 56 - } catch (err) { 57 - console.error('[pds] Failed to index annotation in backend:', err); 48 + /** 49 + * Index an annotation in the backend with retry logic. 50 + * Returns true if indexing succeeded, false if all retries exhausted. 51 + */ 52 + async indexInBackend(uri: string, cid: string, maxRetries = 3): Promise<boolean> { 53 + const delays = [500, 1000, 2000]; // Exponential backoff 54 + 55 + for (let attempt = 0; attempt < maxRetries; attempt++) { 56 + try { 57 + const response = await fetch(`${this.config.backendUrl}/api/annotations/index`, { 58 + method: 'POST', 59 + headers: { 'Content-Type': 'application/json' }, 60 + body: JSON.stringify({ uri, cid }), 61 + }); 62 + 63 + if (response.ok) { 64 + console.log('[pds] Annotation indexed in backend'); 65 + return true; 66 + } 67 + 68 + console.warn(`[pds] Index attempt ${attempt + 1} failed: ${response.status}`); 69 + } catch (err) { 70 + console.warn(`[pds] Index attempt ${attempt + 1} error:`, err); 71 + } 72 + 73 + if (attempt < maxRetries - 1) { 74 + await new Promise(r => setTimeout(r, delays[attempt])); 75 + } 58 76 } 77 + 78 + console.error('[pds] All indexing retries exhausted'); 79 + return false; 59 80 } 60 81 61 82 private async request(agent: OAuthUserAgent, path: string, options: any, retryCount = 0): Promise<Response> { ··· 135 156 136 157 const result = await response.json(); 137 158 138 - // Index in backend 139 - // We do this optimistically/independently, but failure shouldn't block the return of the annotation 140 - this.indexInBackend(result.uri, result.cid).catch(err => { 141 - console.warn('[pds] Background indexing failed (non-fatal):', err); 142 - }); 159 + // Note: Caller is responsible for calling indexInBackend() if needed. 160 + // This allows the caller to await indexing and show proper loading state. 143 161 144 162 return { 145 163 ...annotation,
+37 -41
packages/core/src/sidebar/index.ts
··· 345 345 346 346 async createAnnotation(target: { source: string, selectors: any[] }, body: string) { 347 347 try { 348 - await this.pds.createAnnotation({ 348 + const created = await this.pds.createAnnotation({ 349 349 uri: '', // Will be populated by PDS 350 350 cid: '', 351 351 value: { ··· 358 358 }, 359 359 } as Annotation); 360 360 361 + // Index in backend (with retry) - wait for confirmation 362 + const indexed = await this.pds.indexInBackend(created.uri, created.cid); 363 + if (!indexed) { 364 + console.warn('[sidebar] Indexing failed, annotation may not appear immediately'); 365 + } 366 + 361 367 this.currentSelection = null; 362 368 this.updateSelectionUI(); 363 369 this.onSyncNeeded?.(); ··· 376 382 if (!this.currentSelection) return; 377 383 378 384 const annotationTextarea = this.container.querySelector('#annotation-text') as HTMLTextAreaElement; 385 + const saveBtn = this.container.querySelector('#save-btn') as HTMLButtonElement; 379 386 const body = annotationTextarea?.value.trim() || ''; 387 + const selection = this.currentSelection; 380 388 381 - // Optimistic UI update 382 - const tempUri = `temp:${Date.now()}`; 383 - const tempAnnotation: Annotation = { 384 - uri: tempUri, 385 - cid: 'pending', 386 - value: { 387 - target: { 388 - url: this.currentUrl, 389 - selector: this.currentSelection.selectors, 390 - }, 391 - body, 392 - createdAt: new Date().toISOString(), 393 - }, 394 - author: { 395 - did: 'you', // We might want to get the actual DID from session if available 396 - handle: 'you', 397 - } 398 - }; 399 - 400 - // Add to local list immediately 401 - this.pageAnnotations.unshift(tempAnnotation); 402 - this.renderAnnotations(); 403 - 404 - // Clear form immediately 405 - const originalSelection = this.currentSelection; 406 - this.currentSelection = null; 407 - this.updateSelectionUI(); 389 + // Show saving state 390 + if (saveBtn) { 391 + saveBtn.disabled = true; 392 + saveBtn.innerHTML = '<span class="spinner"></span> Saving...'; 393 + } 408 394 409 395 try { 396 + // 1. Create annotation on PDS 410 397 const created = await this.pds.createAnnotation({ 411 - uri: '', // Placeholder 398 + uri: '', 412 399 cid: '', 413 400 value: { 414 401 target: { 415 402 url: this.currentUrl, 416 - selector: originalSelection.selectors, 403 + selector: selection.selectors, 417 404 }, 418 405 body, 419 406 createdAt: new Date().toISOString(), 420 407 }, 421 408 } as Annotation); 422 409 423 - // Replace temp annotation with real one 424 - const index = this.pageAnnotations.findIndex(a => a.uri === tempUri); 425 - if (index !== -1) { 426 - this.pageAnnotations[index] = created; 410 + // 2. Index in backend (with retry) - wait for confirmation 411 + const indexed = await this.pds.indexInBackend(created.uri, created.cid); 412 + if (!indexed) { 413 + console.warn('[sidebar] Indexing failed, annotation may not appear immediately'); 414 + // Continue anyway - it's on the PDS, just not indexed yet 427 415 } 428 - this.renderAnnotations(); 416 + 417 + // 3. Clear form 418 + this.currentSelection = null; 419 + this.updateSelectionUI(); 420 + 421 + // 4. Sync to get confirmed data from backend 422 + // This will trigger storage update -> loadAnnotationsForCurrentUrl -> renderAnnotations 429 423 this.onSyncNeeded?.(); 424 + 430 425 } catch (error) { 431 426 console.error('[sidebar] Failed to create annotation:', error); 432 427 alert('Failed to save annotation'); 433 - // Remove temp annotation on failure 434 - this.pageAnnotations = this.pageAnnotations.filter(a => a.uri !== tempUri); 435 - this.renderAnnotations(); 436 - // Restore selection? 437 - this.currentSelection = originalSelection; 438 - this.updateSelectionUI(); 428 + // Restore form state on failure 439 429 if (annotationTextarea) annotationTextarea.value = body; 430 + } finally { 431 + // Reset button state 432 + if (saveBtn) { 433 + saveBtn.disabled = false; 434 + saveBtn.textContent = 'Save Annotation'; 435 + } 440 436 } 441 437 } 442 438
+7 -4
tests/e2e/proxy/create.spec.ts
··· 214 214 // Save the annotation 215 215 await sidebar.locator('#save-btn').click(); 216 216 217 - // Wait for annotation to be created 218 - await page.waitForTimeout(2000); 217 + // Wait for save to complete (button becomes enabled again after indexing) 218 + const saveBtn = sidebar.locator('#save-btn'); 219 + await expect(saveBtn).not.toBeDisabled({ timeout: 15000 }); 219 220 220 221 // Verify the annotation was created 221 222 const annotationCards = sidebar.locator('seams-annotation-card'); 222 - const count = await annotationCards.count(); 223 - expect(count).toBeGreaterThan(0); 223 + await expect(async () => { 224 + const count = await annotationCards.count(); 225 + expect(count).toBeGreaterThan(0); 226 + }).toPass({ timeout: 10000 }); 224 227 }); 225 228 226 229 test('clears selection when clicking cancel', async ({ page }) => {
+375
tests/e2e/proxy/saving-state.spec.ts
··· 1 + /** 2 + * Proxy client annotation saving state tests 3 + * 4 + * Tests that the annotation creation flow shows proper saving state: 5 + * 1. Shows spinner/saving state when save is clicked 6 + * 2. Waits for backend indexing before showing annotation 7 + * 3. Clears form after successful save 8 + * 4. Restores form on failure 9 + * 10 + * Prerequisites: 11 + * - Run with: pnpm test:e2e:proxy 12 + */ 13 + 14 + import { test, expect } from '@playwright/test'; 15 + import { 16 + PROXY_BASE_URL, 17 + navigateToProxiedUrl, 18 + getSidebar, 19 + waitForShellReady, 20 + getProxiedContent, 21 + waitForProxyHighlights, 22 + } from '../../helpers/proxy'; 23 + import { completePDSLogin } from '../../helpers/oauth-automation'; 24 + 25 + const TEST_HANDLE = process.env.TEST_HANDLE; 26 + const TEST_PASSWORD = process.env.TEST_PASSWORD; 27 + 28 + test.describe('Annotation Saving State', () => { 29 + test.skip( 30 + !process.env.RUN_PROXY_TESTS, 31 + 'Set RUN_PROXY_TESTS=1 to run proxy client tests' 32 + ); 33 + 34 + test.skip( 35 + !TEST_HANDLE || !TEST_PASSWORD, 36 + 'Set TEST_HANDLE and TEST_PASSWORD in tests/.env.test' 37 + ); 38 + 39 + /** 40 + * Helper to login with test account via the sidebar 41 + */ 42 + async function loginWithTestAccount(page: import('@playwright/test').Page) { 43 + if (!TEST_HANDLE || !TEST_PASSWORD) { 44 + throw new Error('TEST_HANDLE and TEST_PASSWORD must be set in tests/.env.test'); 45 + } 46 + 47 + const sidebar = getSidebar(page); 48 + 49 + // Click login trigger if visible 50 + const loginTrigger = sidebar.locator('#login-trigger-btn'); 51 + if (await loginTrigger.isVisible()) { 52 + await loginTrigger.click(); 53 + } 54 + 55 + // Wait for handle input to be visible 56 + const handleInput = sidebar.locator('#handle-input'); 57 + await handleInput.waitFor({ timeout: 5000 }); 58 + 59 + // Enter test handle 60 + await handleInput.fill(TEST_HANDLE); 61 + 62 + // Click login button - this will redirect the page to PDS auth 63 + const loginBtn = sidebar.locator('#login-btn'); 64 + await loginBtn.click(); 65 + 66 + // Wait for redirect to PDS auth page 67 + await page.waitForURL(/\/oauth\/authorize|\/oauth\/login/, { timeout: 30000 }); 68 + 69 + // Complete PDS login on the redirected page 70 + await completePDSLogin( 71 + page, 72 + { identifier: TEST_HANDLE, password: TEST_PASSWORD }, 73 + /sure\.seams\.so\/oauth-callback|127\.0\.0\.1:8081\/oauth-callback/ 74 + ); 75 + 76 + // Wait for redirect back to proxy 77 + await page.waitForURL(/127\.0\.0\.1:8081/, { timeout: 30000 }); 78 + 79 + // Wait for sidebar to be ready after redirect 80 + await page.waitForTimeout(2000); 81 + } 82 + 83 + /** 84 + * Helper to select text in the proxied content iframe 85 + */ 86 + async function selectTextInProxy( 87 + page: import('@playwright/test').Page, 88 + selector: string, 89 + startOffset: number, 90 + endOffset: number 91 + ): Promise<string> { 92 + const content = getProxiedContent(page); 93 + 94 + const selectedText = await content.locator(selector).first().evaluate( 95 + (el, { start, end }) => { 96 + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); 97 + const textNode = walker.nextNode(); 98 + 99 + if (!textNode) return ''; 100 + 101 + const nodeText = textNode.textContent || ''; 102 + const range = document.createRange(); 103 + range.setStart(textNode, Math.min(start, nodeText.length)); 104 + range.setEnd(textNode, Math.min(end, nodeText.length)); 105 + 106 + const selection = window.getSelection(); 107 + selection?.removeAllRanges(); 108 + selection?.addRange(range); 109 + 110 + document.dispatchEvent(new Event('selectionchange')); 111 + 112 + return nodeText.substring(start, Math.min(end, nodeText.length)); 113 + }, 114 + { start: startOffset, end: endOffset } 115 + ); 116 + 117 + await page.waitForTimeout(500); 118 + return selectedText; 119 + } 120 + 121 + test('shows saving state while creating annotation', async ({ page }) => { 122 + await navigateToProxiedUrl(page, 'https://example.com/'); 123 + await waitForShellReady(page); 124 + 125 + // Login first 126 + await loginWithTestAccount(page); 127 + 128 + // Wait for content to be ready 129 + await page.waitForFunction( 130 + () => { 131 + const iframe = document.querySelector('#content') as HTMLIFrameElement; 132 + return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/'); 133 + }, 134 + { timeout: 15000 } 135 + ); 136 + await page.waitForTimeout(1000); 137 + 138 + // Select text 139 + const selectedText = await selectTextInProxy(page, 'h1', 0, 10); 140 + expect(selectedText.length).toBeGreaterThan(0); 141 + 142 + await page.waitForTimeout(500); 143 + 144 + const sidebar = getSidebar(page); 145 + 146 + // Wait for annotation form 147 + const annotationForm = sidebar.locator('#annotation-form'); 148 + await annotationForm.waitFor({ timeout: 5000 }); 149 + 150 + // Fill in a note 151 + const textarea = sidebar.locator('#annotation-text'); 152 + await textarea.fill('Test saving state - ' + Date.now()); 153 + 154 + // Get the save button 155 + const saveBtn = sidebar.locator('#save-btn'); 156 + 157 + // Click save and immediately check for saving state 158 + const saveBtnPromise = saveBtn.click(); 159 + 160 + // Check that button shows saving state (disabled with spinner/text change) 161 + // We need to check quickly before the save completes 162 + await expect(saveBtn).toBeDisabled({ timeout: 1000 }); 163 + 164 + // Check for spinner or "Saving" text 165 + const buttonContent = await saveBtn.innerHTML(); 166 + expect(buttonContent.toLowerCase()).toContain('saving'); 167 + 168 + // Wait for save to complete 169 + await saveBtnPromise; 170 + 171 + // Button should be re-enabled after save completes 172 + await expect(saveBtn).not.toBeDisabled({ timeout: 10000 }); 173 + }); 174 + 175 + test('annotation appears in sidebar after save completes', async ({ page }) => { 176 + await navigateToProxiedUrl(page, 'https://example.com/'); 177 + await waitForShellReady(page); 178 + 179 + await loginWithTestAccount(page); 180 + 181 + await page.waitForFunction( 182 + () => { 183 + const iframe = document.querySelector('#content') as HTMLIFrameElement; 184 + return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/'); 185 + }, 186 + { timeout: 15000 } 187 + ); 188 + await page.waitForTimeout(1000); 189 + 190 + const sidebar = getSidebar(page); 191 + 192 + // Get initial annotation count 193 + const initialCount = await sidebar.locator('seams-annotation-card').count(); 194 + 195 + // Select text and save 196 + await selectTextInProxy(page, 'p', 0, 20); 197 + await page.waitForTimeout(500); 198 + 199 + const annotationForm = sidebar.locator('#annotation-form'); 200 + await annotationForm.waitFor({ timeout: 5000 }); 201 + 202 + const textarea = sidebar.locator('#annotation-text'); 203 + const uniqueNote = 'Test annotation appears - ' + Date.now(); 204 + await textarea.fill(uniqueNote); 205 + 206 + // Save and wait for completion 207 + await sidebar.locator('#save-btn').click(); 208 + 209 + // Wait for new annotation to appear in sidebar 210 + await expect(async () => { 211 + const newCount = await sidebar.locator('seams-annotation-card').count(); 212 + expect(newCount).toBeGreaterThan(initialCount); 213 + }).toPass({ timeout: 15000 }); 214 + 215 + // Verify the annotation card contains our note text 216 + const annotationCards = sidebar.locator('seams-annotation-card'); 217 + const lastCard = annotationCards.first(); // Most recent should be first 218 + await expect(lastCard).toContainText(uniqueNote.substring(0, 20)); 219 + }); 220 + 221 + test('highlight appears in content after save completes', async ({ page }) => { 222 + await navigateToProxiedUrl(page, 'https://example.com/'); 223 + await waitForShellReady(page); 224 + 225 + await loginWithTestAccount(page); 226 + 227 + await page.waitForFunction( 228 + () => { 229 + const iframe = document.querySelector('#content') as HTMLIFrameElement; 230 + return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/'); 231 + }, 232 + { timeout: 15000 } 233 + ); 234 + await page.waitForTimeout(1000); 235 + 236 + const content = getProxiedContent(page); 237 + const sidebar = getSidebar(page); 238 + 239 + // Get initial highlight count 240 + const initialHighlightCount = await content.locator('.seams-highlight').count(); 241 + 242 + // Select specific text that we can verify 243 + await selectTextInProxy(page, 'h1', 0, 7); // "Example" 244 + await page.waitForTimeout(500); 245 + 246 + const annotationForm = sidebar.locator('#annotation-form'); 247 + await annotationForm.waitFor({ timeout: 5000 }); 248 + 249 + // Save annotation 250 + await sidebar.locator('#save-btn').click(); 251 + 252 + // Wait for highlight to appear in content iframe 253 + await expect(async () => { 254 + const newHighlightCount = await content.locator('.seams-highlight').count(); 255 + expect(newHighlightCount).toBeGreaterThan(initialHighlightCount); 256 + }).toPass({ timeout: 15000 }); 257 + }); 258 + 259 + test('form is cleared after successful save', async ({ page }) => { 260 + await navigateToProxiedUrl(page, 'https://example.com/'); 261 + await waitForShellReady(page); 262 + 263 + await loginWithTestAccount(page); 264 + 265 + await page.waitForFunction( 266 + () => { 267 + const iframe = document.querySelector('#content') as HTMLIFrameElement; 268 + return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/'); 269 + }, 270 + { timeout: 15000 } 271 + ); 272 + await page.waitForTimeout(1000); 273 + 274 + const sidebar = getSidebar(page); 275 + 276 + // Select text 277 + await selectTextInProxy(page, 'p', 0, 15); 278 + await page.waitForTimeout(500); 279 + 280 + // Fill form 281 + const annotationForm = sidebar.locator('#annotation-form'); 282 + await annotationForm.waitFor({ timeout: 5000 }); 283 + 284 + const textarea = sidebar.locator('#annotation-text'); 285 + await textarea.fill('Test form clearing'); 286 + 287 + // Save 288 + await sidebar.locator('#save-btn').click(); 289 + 290 + // Wait for save to complete (button becomes enabled again) 291 + const saveBtn = sidebar.locator('#save-btn'); 292 + await expect(saveBtn).not.toBeDisabled({ timeout: 15000 }); 293 + 294 + // Form should be hidden 295 + await expect(annotationForm).not.toBeVisible({ timeout: 5000 }); 296 + 297 + // Textarea should be empty (if we show form again) 298 + // Select new text to show form 299 + await selectTextInProxy(page, 'h1', 0, 5); 300 + await page.waitForTimeout(500); 301 + await annotationForm.waitFor({ timeout: 5000 }); 302 + 303 + await expect(textarea).toHaveValue(''); 304 + }); 305 + 306 + test('consecutive annotations are all saved correctly', async ({ page }) => { 307 + // This tests the core bug: creating multiple annotations in succession 308 + await navigateToProxiedUrl(page, 'https://example.com/'); 309 + await waitForShellReady(page); 310 + 311 + await loginWithTestAccount(page); 312 + 313 + await page.waitForFunction( 314 + () => { 315 + const iframe = document.querySelector('#content') as HTMLIFrameElement; 316 + return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/'); 317 + }, 318 + { timeout: 15000 } 319 + ); 320 + await page.waitForTimeout(1000); 321 + 322 + const sidebar = getSidebar(page); 323 + const content = getProxiedContent(page); 324 + 325 + // Get initial counts 326 + const initialCardCount = await sidebar.locator('seams-annotation-card').count(); 327 + const initialHighlightCount = await content.locator('.seams-highlight').count(); 328 + 329 + // Create first annotation 330 + await selectTextInProxy(page, 'h1', 0, 7); 331 + await page.waitForTimeout(500); 332 + 333 + let annotationForm = sidebar.locator('#annotation-form'); 334 + await annotationForm.waitFor({ timeout: 5000 }); 335 + 336 + const note1 = 'First annotation - ' + Date.now(); 337 + await sidebar.locator('#annotation-text').fill(note1); 338 + await sidebar.locator('#save-btn').click(); 339 + 340 + // Wait for first annotation to complete 341 + await expect(sidebar.locator('#save-btn')).not.toBeDisabled({ timeout: 15000 }); 342 + 343 + // Verify first annotation appeared 344 + await expect(async () => { 345 + const cardCount = await sidebar.locator('seams-annotation-card').count(); 346 + expect(cardCount).toBe(initialCardCount + 1); 347 + }).toPass({ timeout: 10000 }); 348 + 349 + // Create second annotation 350 + await selectTextInProxy(page, 'p', 0, 10); 351 + await page.waitForTimeout(500); 352 + 353 + annotationForm = sidebar.locator('#annotation-form'); 354 + await annotationForm.waitFor({ timeout: 5000 }); 355 + 356 + const note2 = 'Second annotation - ' + Date.now(); 357 + await sidebar.locator('#annotation-text').fill(note2); 358 + await sidebar.locator('#save-btn').click(); 359 + 360 + // Wait for second annotation to complete 361 + await expect(sidebar.locator('#save-btn')).not.toBeDisabled({ timeout: 15000 }); 362 + 363 + // Verify BOTH annotations are visible 364 + await expect(async () => { 365 + const cardCount = await sidebar.locator('seams-annotation-card').count(); 366 + expect(cardCount).toBe(initialCardCount + 2); 367 + }).toPass({ timeout: 10000 }); 368 + 369 + // Verify BOTH highlights exist in content 370 + await expect(async () => { 371 + const highlightCount = await content.locator('.seams-highlight').count(); 372 + expect(highlightCount).toBe(initialHighlightCount + 2); 373 + }).toPass({ timeout: 10000 }); 374 + }); 375 + });
+6 -2
vite.proxy.shared.ts
··· 21 21 // For production, use environment variables or the actual client metadata URL 22 22 export const isProd = process.env.NODE_ENV === 'production'; 23 23 24 + // Backend URL configuration 25 + export const DEV_BACKEND_URL = `http://${DEV_HOST}:8080`; 26 + export const PROD_BACKEND_URL = 'https://seams.so'; 27 + 24 28 // CORS proxy URL configuration 25 29 export const DEV_CORS_PROXY_URL = `http://${DEV_HOST}:${DEV_PROXY_PORT}/proxy/`; 26 30 export const PROD_CORS_PROXY_URL = 'https://sure.seams.so:8082/proxy/'; ··· 38 42 process.env.VITE_OAUTH_SCOPE || OAUTH_SCOPE 39 43 ), 40 44 'import.meta.env.VITE_BACKEND_URL': JSON.stringify( 41 - process.env.VITE_BACKEND_URL || 'https://seams.so' 45 + process.env.VITE_BACKEND_URL || PROD_BACKEND_URL 42 46 ), 43 47 'import.meta.env.BACKEND_URL': JSON.stringify( 44 - process.env.BACKEND_URL || 'https://seams.so' 48 + process.env.BACKEND_URL || PROD_BACKEND_URL 45 49 ), 46 50 // CORS proxy URL for wabac.js live proxy 47 51 '__CORS_PROXY_URL__': JSON.stringify(