···7373pnpm test:e2e:proxy # Run proxy e2e tests (starts proxy servers)
7474```
75757676-E2E tests are in `tests/e2e/` and require:
7676+E2E Extension tests are in `tests/e2e/extension` and require:
7777- Built extension in `.output/chrome-mv3` (`pnpm build` first)
7878- Go backend server (started automatically by Playwright)
7979- Test credentials in `tests/.env.test`
8080+8181+E2E Proxy tests are in `tests/e2e/proxy` and require:
8282+- To be run with `pnpm test:e2e:proxy`
8383+- It will start all the necessary servers and wrappers
80848185E2E tests cover:
8286- Extension sidebar display and navigation
-96
Dockerfile.proxy
···11-# Dockerfile for Proxy (wabac.js-based)
22-# Multi-stage build: Node.js for building, then slim runtime
33-44-# Build stage - compile proxy with Node
55-FROM node:22-alpine AS builder
66-77-RUN apk add --no-cache bash
88-RUN npm install -g pnpm
99-1010-WORKDIR /build
1111-1212-# Copy workspace config
1313-COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
1414-COPY packages/ ./packages/
1515-COPY proxy/ ./proxy/
1616-COPY vite.proxy.config.ts vite.proxy-inject.config.ts vite.proxy.shared.ts tsconfig.json ./
1717-COPY scripts/postbuild-proxy.sh ./scripts/
1818-1919-# Install dependencies
2020-RUN pnpm install --frozen-lockfile
2121-2222-# Install proxy dependencies (for wabac.js sw.js)
2323-WORKDIR /build/proxy
2424-RUN npm install
2525-2626-# Build proxy with HMAC secret from build-time secret
2727-# The secret is mounted at /run/secrets/CORS_PROXY_HMAC_SECRET during build
2828-WORKDIR /build
2929-RUN --mount=type=secret,id=CORS_PROXY_HMAC_SECRET \
3030- VITE_CORS_PROXY_HMAC_SECRET=$(cat /run/secrets/CORS_PROXY_HMAC_SECRET 2>/dev/null || echo "") \
3131- pnpm build:proxy
3232-3333-# Runtime stage - Node.js + Caddy
3434-FROM node:22-alpine
3535-3636-RUN apk add --no-cache bash curl ca-certificates
3737-3838-# Install Caddy
3939-RUN curl -L "https://caddyserver.com/api/download?os=linux&arch=amd64&arm=" -o /usr/local/bin/caddy \
4040- && chmod +x /usr/local/bin/caddy
4141-4242-WORKDIR /app
4343-4444-# Copy built static files
4545-COPY --from=builder /build/proxy/dist/ ./dist/
4646-4747-# Copy CORS proxy source and install dependencies
4848-COPY proxy/cors-proxy/ ./cors-proxy/
4949-WORKDIR /app/cors-proxy
5050-RUN npm install
5151-5252-# Copy Caddyfile
5353-WORKDIR /app
5454-COPY proxy/Caddyfile ./
5555-5656-# Create startup script
5757-RUN cat <<'EOF' > /app/start.sh
5858-#!/bin/bash
5959-set -e
6060-6161-echo "Starting Seams Proxy..."
6262-6363-# Start CORS proxy on port 8083 (Caddy proxies 8082 -> 8083)
6464-cd /app/cors-proxy
6565-echo "Starting CORS proxy on :8083..."
6666-CORS_PROXY_PORT=8083 npx tsx index.ts &
6767-CORS_PID=$!
6868-6969-# Wait for CORS proxy to be healthy (up to 30 seconds)
7070-echo "Waiting for CORS proxy to be ready..."
7171-MAX_ATTEMPTS=30
7272-ATTEMPT=0
7373-while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
7474- if curl -s -o /dev/null -w "%{http_code}" http://localhost:8083/ 2>/dev/null | grep -q "200"; then
7575- echo "CORS proxy is ready"
7676- break
7777- fi
7878- ATTEMPT=$((ATTEMPT + 1))
7979- if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
8080- echo "ERROR: CORS proxy failed to start within ${MAX_ATTEMPTS} seconds"
8181- exit 1
8282- fi
8383- sleep 1
8484-done
8585-8686-# Start Caddy (static files on 8081, cors proxy on 8082)
8787-cd /app
8888-echo "Starting Caddy on :8081 (static) and :8082 (cors proxy)..."
8989-caddy run --config /app/Caddyfile --adapter caddyfile
9090-EOF
9191-9292-RUN chmod +x /app/start.sh
9393-9494-EXPOSE 8081 8082
9595-9696-CMD ["/app/start.sh"]
+20-46
deploy-proxy.sh
···11-#!/bin/bash
22-# Deploy Proxy to Fly.io
11+#!/usr/bin/env bash
22+# Deploy Seams Proxy to Fly.io using Nix-built Docker image
33# Usage: ./deploy-proxy.sh
44-#
55-# HMAC Authentication Setup:
66-# 1. Create .env.secrets file (gitignored) with: CORS_PROXY_HMAC_SECRET=<your-secret>
77-# 2. Or set environment variable: export CORS_PROXY_HMAC_SECRET=<your-secret>
88-# 3. The script will set the Fly secret and use it for build
99-#
1010-# Generate a secret with: openssl rand -base64 32
114125set -e
136147APP_NAME="sure-seams-so"
1581616-echo "Deploying Proxy to Fly.io..."
99+echo "Building proxy client..."
1010+pnpm build:proxy
17111818-# Load secrets from .env.secrets if it exists
1919-if [ -f .env.secrets ]; then
2020- echo "Loading secrets from .env.secrets..."
2121- set -a
2222- source .env.secrets
2323- set +a
2424-fi
1212+echo "Building Docker image with Nix..."
1313+PROXY_DIST=$PWD/proxy/dist nix build .#proxy --impure
25142626-# Check if HMAC secret is available
2727-if [ -n "$CORS_PROXY_HMAC_SECRET" ]; then
2828- echo "HMAC secret found, enabling authentication..."
2929-3030- # Ensure the Fly.io app has the runtime secret
3131- echo "Setting runtime secret on Fly.io..."
3232- fly secrets set CORS_PROXY_HMAC_SECRET="$CORS_PROXY_HMAC_SECRET" --app "$APP_NAME" --stage
3333-3434- # Deploy with build-time secret
3535- echo "Deploying with HMAC authentication..."
3636- fly deploy --config fly.proxy.toml \
3737- --build-secret "CORS_PROXY_HMAC_SECRET=$CORS_PROXY_HMAC_SECRET"
3838-else
3939- echo "WARNING: CORS_PROXY_HMAC_SECRET not set"
4040- echo "HMAC authentication will be DISABLED"
4141- echo ""
4242- echo "To enable HMAC auth:"
4343- echo " 1. Generate secret: openssl rand -base64 32"
4444- echo " 2. Create .env.secrets with: CORS_PROXY_HMAC_SECRET=<secret>"
4545- echo " 3. Re-run this script"
4646- echo ""
4747- read -p "Continue without HMAC? (y/N) " -n 1 -r
4848- echo
4949- if [[ ! $REPLY =~ ^[Yy]$ ]]; then
5050- exit 1
5151- fi
5252-5353- fly deploy --config fly.proxy.toml
5454-fi
1515+echo "Loading image into Docker..."
1616+docker load < result
1717+1818+echo "Tagging image for Fly.io registry..."
1919+docker tag seams-proxy:latest registry.fly.io/$APP_NAME:latest
2020+2121+echo "Authenticating with Fly.io Docker registry..."
2222+flyctl auth docker
2323+2424+echo "Pushing image to Fly.io..."
2525+docker push registry.fly.io/$APP_NAME:latest
2626+2727+echo "Deploying to Fly.io..."
2828+flyctl deploy --config fly.proxy.toml --image registry.fly.io/$APP_NAME:latest
55295630echo ""
5731echo "Deployment complete!"
···11-# Caddyfile for sure-client-proxy
22-# Serves static files on :8081, reverse proxies CORS proxy on :8082
33-44-# Static file server for wabac.js client
55-:8081 {
66- root * /app/dist
77- file_server
88-99- # Enable gzip compression
1010- encode gzip
1111-1212- # CORS headers for service worker and client
1313- # Note: same-origin requests don't need CORS headers
1414- # Service worker operates same-origin so no CORS needed for static files
1515- # Remove wildcard CORS - only allow same-origin access
1616- header {
1717- # Security headers
1818- X-Content-Type-Options "nosniff"
1919- X-Frame-Options "SAMEORIGIN"
2020- Referrer-Policy "strict-origin-when-cross-origin"
2121- }
2222-2323- # Cache static assets
2424- @static {
2525- path *.js *.css *.woff *.woff2 *.png *.ico
2626- }
2727- header @static Cache-Control "public, max-age=3600"
2828-2929- # Don't cache HTML (for updates)
3030- @html {
3131- path *.html /
3232- }
3333- header @html Cache-Control "no-cache"
3434-}
3535-3636-# CORS proxy (reverse proxy to Node.js server on port 8083)
3737-# External clients connect to :8082, Caddy forwards to Node on :8083
3838-:8082 {
3939- # Security headers for proxy responses
4040- header {
4141- X-Content-Type-Options "nosniff"
4242- # Note: X-Frame-Options and CSP intentionally not set here
4343- # as the proxy strips these from upstream responses for iframe embedding
4444- }
4545-4646- # Request size limit (matches Node.js MAX_BODY_SIZE)
4747- request_body {
4848- max_size 10MB
4949- }
5050-5151- reverse_proxy localhost:8083
5252-}
+4-2
proxy/cors-proxy/index.ts
···596596 return c.text('ok');
597597});
598598599599-const port = parseInt(process.env.CORS_PROXY_PORT || '8082', 10);
600600-console.log(`[cors-proxy] Starting server on http://localhost:${port}`);
599599+const port = parseInt(process.env.CORS_PROXY_PORT || process.env.PORT || '8082', 10);
600600+const hostname = process.env.HOST || '0.0.0.0';
601601+console.log(`[cors-proxy] Starting server on http://${hostname}:${port}`);
601602console.log(`[cors-proxy] Allowed origins: ${CORS_ALLOWED_ORIGINS.join(', ')}`);
602603console.log(`[cors-proxy] Rate limit: ${RATE_LIMIT_MAX_REQUESTS} req/${RATE_LIMIT_WINDOW_MS / 1000}s, max ${RATE_LIMIT_MAX_CLIENTS} clients`);
603604console.log(`[cors-proxy] Max body: ${MAX_BODY_SIZE / 1024 / 1024}MB, timeout: ${REQUEST_TIMEOUT_MS / 1000}s`);
···609610const server = serve({
610611 fetch: app.fetch,
611612 port,
613613+ hostname,
612614});
613615614616// Graceful shutdown handling
+21-6
proxy/public/index.html
···3030 <button id="toggle-btn" class="sidebar-open" title="Toggle sidebar">>></button>
3131 </div>
3232 <script>
3333- // CORS proxy URL - configured at build time via Vite or falls back to localhost for dev
3434- // In production, this should be set to the deployed proxy URL
3535- // __CORS_PROXY_URL__ is replaced at build time by Vite define config
3636- const corsProxy = (typeof __CORS_PROXY_URL__ !== 'undefined' && __CORS_PROXY_URL__)
3737- || 'http://127.0.0.1:8082/proxy/';
3333+ // Handle legacy /proxy/<url> format - redirect to hash-based URL
3434+ // This maintains backwards compatibility for existing deployments
3535+ (function() {
3636+ const proxyPrefix = '/proxy/';
3737+ if (window.location.pathname.startsWith(proxyPrefix)) {
3838+ const targetUrl = window.location.pathname.slice(proxyPrefix.length) + window.location.search;
3939+ if (targetUrl) {
4040+ // Use replace() to avoid adding to browser history
4141+ window.location.replace(window.location.origin + '/#' + targetUrl);
4242+ return;
4343+ }
4444+ }
4545+ })();
4646+4747+ // CORS proxy URL - detect production vs development based on hostname
4848+ const isProduction = window.location.hostname === 'sure.seams.so' ||
4949+ window.location.hostname === 'sure-seams-so.fly.dev';
5050+ const corsProxy = isProduction
5151+ ? 'https://sure.seams.so:8082/proxy/'
5252+ : 'http://127.0.0.1:8082/proxy/';
38533939- console.log('[index] Using CORS proxy:', corsProxy);
5454+ console.log('[index] Using CORS proxy:', corsProxy, isProduction ? '(production)' : '(development)');
4055 const proxy = new SeamsLiveProxy({ corsProxy });
41564257 // Set up error handler to show user-friendly messages
+110-39
proxy/public/loadwabac.js
···33 * Based on https://github.com/webrecorder/wabac.js/blob/main/examples/live-proxy/loadwabac.js
44 */
55class SeamsLiveProxy {
66+ // Migration version - increment to trigger migration for all users
77+ // Version 2: Initial client-side proxy migration (clears old SW, OAuth, storage)
88+ static PROXY_VERSION = 2;
99+ static VERSION_KEY = 'seams-proxy-version';
1010+611 constructor({
712 corsProxy = 'http://127.0.0.1:8082/proxy/',
813 collName = 'liveproxy',
···4146 }
4247 }
43484949+ /**
5050+ * Run migration if needed - clears old SW, storage keys, and IndexedDB
5151+ * Returns true if migration was performed
5252+ */
5353+ async _migrateIfNeeded() {
5454+ const currentVersion = localStorage.getItem(SeamsLiveProxy.VERSION_KEY);
5555+ const targetVersion = SeamsLiveProxy.PROXY_VERSION.toString();
5656+5757+ if (currentVersion === targetVersion) {
5858+ console.log('[loadwabac] Version matches, no migration needed');
5959+ return false;
6060+ }
6161+6262+ console.log(`[loadwabac] Migration needed: ${currentVersion || 'none'} -> ${targetVersion}`);
6363+6464+ // Unregister all service workers for this scope
6565+ try {
6666+ const registrations = await navigator.serviceWorker.getRegistrations();
6767+ for (const registration of registrations) {
6868+ console.log('[loadwabac] Unregistering old service worker:', registration.scope);
6969+ await registration.unregister();
7070+ }
7171+ } catch (error) {
7272+ console.warn('[loadwabac] Failed to unregister service workers:', error);
7373+ }
7474+7575+ // Clear session storage (seams_login_redirect)
7676+ try {
7777+ sessionStorage.removeItem('seams_login_redirect');
7878+ console.log('[loadwabac] Cleared session storage redirect key');
7979+ } catch (error) {
8080+ console.warn('[loadwabac] Failed to clear session storage:', error);
8181+ }
8282+8383+ // Clear OAuth session (synthesis-oauth:session)
8484+ try {
8585+ localStorage.removeItem('synthesis-oauth:session');
8686+ console.log('[loadwabac] Cleared OAuth session');
8787+ } catch (error) {
8888+ console.warn('[loadwabac] Failed to clear OAuth session:', error);
8989+ }
9090+9191+ // Clear wabac.js IndexedDB databases
9292+ // wabac.js uses databases like 'db', 'wabac-xxx', etc.
9393+ try {
9494+ if (indexedDB.databases) {
9595+ const databases = await indexedDB.databases();
9696+ for (const db of databases) {
9797+ // Clear wabac-related databases and the generic 'db' used by wabac
9898+ if (db.name && (db.name.startsWith('wabac') || db.name === 'db')) {
9999+ console.log('[loadwabac] Deleting IndexedDB:', db.name);
100100+ await new Promise((resolve, reject) => {
101101+ const req = indexedDB.deleteDatabase(db.name);
102102+ req.onsuccess = () => resolve();
103103+ req.onerror = () => reject(req.error);
104104+ req.onblocked = () => {
105105+ console.warn('[loadwabac] IndexedDB delete blocked:', db.name);
106106+ resolve(); // Don't fail migration if blocked
107107+ };
108108+ });
109109+ }
110110+ }
111111+ }
112112+ } catch (error) {
113113+ console.warn('[loadwabac] Failed to clear IndexedDB:', error);
114114+ }
115115+116116+ // Mark migration complete
117117+ localStorage.setItem(SeamsLiveProxy.VERSION_KEY, targetVersion);
118118+ console.log('[loadwabac] Migration complete, version set to:', targetVersion);
119119+120120+ return true;
121121+ }
122122+44123 async init() {
45124 console.log('[loadwabac] Initializing proxy');
125125+126126+ // Run migration if needed (clears old SW, storage)
127127+ const migrated = await this._migrateIfNeeded();
128128+ if (migrated) {
129129+ console.log('[loadwabac] Migration completed, proceeding with fresh registration');
130130+ }
4613147132 const scope = './';
48133 const swParams = new URLSearchParams({ injectScripts: this.injectScripts });
491345050- // Register the service worker with timeout
5151- const SW_REGISTRATION_TIMEOUT = 30000;
135135+ // If no migration was needed and SW is already active, skip registration
136136+ // The collection persists in SW's IndexedDB
137137+ if (!migrated) {
138138+ const existingReg = await navigator.serviceWorker.getRegistration(scope);
139139+ if (existingReg?.active && navigator.serviceWorker.controller) {
140140+ console.log('[loadwabac] SW already active and controlling, skipping registration');
141141+ this._setupEventListeners();
142142+ return;
143143+ }
144144+ }
145145+146146+ // Do fresh registration
147147+ const SW_REGISTRATION_TIMEOUT = 5000;
52148 const registrationPromise = navigator.serviceWorker.register(
53149 `./sw.js?${swParams.toString()}`,
54150 { scope }
···66162 }
6716368164 // Set up message listener for collAdded BEFORE sending addColl
6969- // Use a resolved flag to handle race condition where message arrives before listener
70165 let initedResolve = null;
7171- let initedResolved = false;
72166 const inited = new Promise((resolve) => {
7373- initedResolve = () => {
7474- if (!initedResolved) {
7575- initedResolved = true;
7676- resolve();
7777- }
7878- };
167167+ initedResolve = resolve;
79168 });
801698181- // Store reference for cleanup
82170 const messageHandler = (event) => {
8383- const { msg_type, type, url, method, status } = event.data || {};
8484-8585- if (msg_type === 'collAdded') {
171171+ if (event.data?.msg_type === 'collAdded') {
86172 console.log('[loadwabac] Collection ready');
87173 initedResolve();
8888- return;
8989- }
9090-9191- // Handle proxy error messages from wabac.js service worker
9292- // These are sent when messageOnProxyErrors is enabled or for certain error types
9393- if (type === 'post-request-attempt') {
9494- console.warn(`[loadwabac] POST request attempted for: ${url}`);
9595- } else if (type === 'post-request-failed') {
9696- console.error(`[loadwabac] POST request failed: ${method} ${url} (status: ${status})`);
9797- } else if (type === 'rate-limited') {
9898- console.error(`[loadwabac] Rate limited by upstream: ${url}`);
9999- this.showError('Rate limited. Please wait a moment and try again.');
100174 }
101175 };
102176 navigator.serviceWorker.addEventListener('message', messageHandler);
103103- this._messageHandler = messageHandler;
104177105178 // Build base URL (without hash)
106179 const baseUrl = new URL(window.location);
107180 baseUrl.hash = '';
108181109182 // Configure the live proxy collection
110110- // isLive: true enables pure live proxy mode (no archive fallback)
111111- // This routes all requests through the CORS proxy for live fetching
112183 const msg = {
113184 msg_type: 'addColl',
114185 name: this.collName,
···121192 baseUrl: baseUrl.href,
122193 baseUrlHashReplay: true,
123194 noPostToGet: true,
124124- // Enable error messages from service worker for better debugging
125195 messageOnProxyErrors: true,
126196 },
127197 };
128198129129- // Send message to service worker controller
199199+ // Wait for controller then send addColl
130200 if (!navigator.serviceWorker.controller) {
131201 await new Promise((resolve) => {
132202 navigator.serviceWorker.addEventListener('controllerchange', () => {
···139209 }
140210141211 // Wait for collection to be ready with timeout
142142- const COLLECTION_TIMEOUT = 30000; // 30 seconds
212212+ const COLLECTION_TIMEOUT = 5000;
143213 const collectionTimeoutPromise = new Promise((_, reject) => {
144214 setTimeout(() => reject(new Error('Collection initialization timed out')), COLLECTION_TIMEOUT);
145215 });
···148218 await Promise.race([inited, collectionTimeoutPromise]);
149219 } catch (error) {
150220 console.error('[loadwabac] Collection init failed:', error);
151151- navigator.serviceWorker.removeEventListener('message', this._messageHandler);
221221+ navigator.serviceWorker.removeEventListener('message', messageHandler);
152222 throw error;
153223 }
154224155155- navigator.serviceWorker.removeEventListener('message', this._messageHandler);
225225+ navigator.serviceWorker.removeEventListener('message', messageHandler);
226226+ this._setupEventListeners();
227227+ }
156228229229+ // Set up iframe, hash change, and error listeners
230230+ _setupEventListeners() {
157231 // Set up iframe load listener
158232 const iframe = document.querySelector('#content');
159233 if (iframe) {
···169243 }
170244171245 // Listen for error messages from wabac.js error pages in the iframe
172172- // wabac.js posts messages when pages fail to load (see notfound.ts)
173246 this._windowMessageHandler = (event) => {
174247 const { wb_type, url, status } = event.data || {};
175248176249 if (wb_type === 'archive-not-found') {
177177- // This shouldn't happen in pure live proxy mode (isLive: true)
178178- // but handle it just in case
179250 console.error(`[loadwabac] Page not found in archive: ${url}`);
180251 this.showError('Page could not be loaded. The proxy may not be able to access this URL.', url);
181252 } else if (wb_type === 'live-proxy-url-error') {
···185256 };
186257 window.addEventListener('message', this._windowMessageHandler);
187258188188- // Set up hash change listeners (store references for cleanup)
259259+ // Set up hash change listeners
189260 this._hashChangeHandler = () => {
190261 this.onHashChange();
191262 };
···197268 };
198269 window.addEventListener('load', this._windowLoadHandler);
199270200200- // Also trigger immediately if page is already loaded
271271+ // Trigger immediately if page is already loaded
201272 if (document.readyState === 'complete') {
202273 this.onHashChange();
203274 }
···11+#!/usr/bin/env bash
22+# Test the Docker proxy container against the e2e test suite
33+# Usage: ./test-docker-proxy.sh
44+#
55+# This script:
66+# 1. Builds the proxy (if needed)
77+# 2. Builds the Docker image with Nix
88+# 3. Starts the container
99+# 4. Runs the proxy e2e tests against it
1010+# 5. Cleans up
1111+1212+set -e
1313+1414+CONTAINER_NAME="seams-proxy-test"
1515+IMAGE_NAME="seams-proxy:latest"
1616+1717+# Colors for output
1818+RED='\033[0;31m'
1919+GREEN='\033[0;32m'
2020+YELLOW='\033[1;33m'
2121+NC='\033[0m' # No Color
2222+2323+cleanup() {
2424+ echo -e "${YELLOW}Cleaning up...${NC}"
2525+ docker stop "$CONTAINER_NAME" 2>/dev/null || true
2626+ docker rm "$CONTAINER_NAME" 2>/dev/null || true
2727+}
2828+2929+# Always cleanup on exit
3030+trap cleanup EXIT
3131+3232+echo -e "${YELLOW}Building proxy client...${NC}"
3333+pnpm build:proxy
3434+3535+echo -e "${YELLOW}Building Docker image with Nix...${NC}"
3636+PROXY_DIST=$PWD/proxy/dist nix build .#proxy --impure
3737+3838+echo -e "${YELLOW}Loading image into Docker...${NC}"
3939+docker load < result
4040+4141+# Stop any existing container with the same name
4242+docker stop "$CONTAINER_NAME" 2>/dev/null || true
4343+docker rm "$CONTAINER_NAME" 2>/dev/null || true
4444+4545+echo -e "${YELLOW}Starting Docker container...${NC}"
4646+docker run -d --name "$CONTAINER_NAME" \
4747+ -p 8081:8081 \
4848+ -p 8082:8082 \
4949+ -e CORS_ALLOWED_ORIGINS="http://127.0.0.1:8081,http://localhost:8081" \
5050+ "$IMAGE_NAME"
5151+5252+echo -e "${YELLOW}Waiting for container to be ready...${NC}"
5353+5454+# Wait for static server on 8081
5555+MAX_ATTEMPTS=30
5656+ATTEMPT=0
5757+while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
5858+ if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8081/ 2>/dev/null | grep -q "200"; then
5959+ echo -e "${GREEN}Static server is ready on :8081${NC}"
6060+ break
6161+ fi
6262+ ATTEMPT=$((ATTEMPT + 1))
6363+ if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
6464+ echo -e "${RED}ERROR: Static server failed to start within ${MAX_ATTEMPTS} seconds${NC}"
6565+ echo "Container logs:"
6666+ docker logs "$CONTAINER_NAME"
6767+ exit 1
6868+ fi
6969+ sleep 1
7070+done
7171+7272+# Wait for CORS proxy on 8082
7373+ATTEMPT=0
7474+while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
7575+ if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8082/ 2>/dev/null | grep -q "200"; then
7676+ echo -e "${GREEN}CORS proxy is ready on :8082${NC}"
7777+ break
7878+ fi
7979+ ATTEMPT=$((ATTEMPT + 1))
8080+ if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then
8181+ echo -e "${RED}ERROR: CORS proxy failed to start within ${MAX_ATTEMPTS} seconds${NC}"
8282+ echo "Container logs:"
8383+ docker logs "$CONTAINER_NAME"
8484+ exit 1
8585+ fi
8686+ sleep 1
8787+done
8888+8989+echo -e "${GREEN}Container is ready!${NC}"
9090+echo ""
9191+echo -e "${YELLOW}Running proxy e2e tests...${NC}"
9292+9393+# Run tests with USE_EXTERNAL_PROXY=1 to skip starting the built-in servers
9494+RUN_PROXY_TESTS=1 USE_EXTERNAL_PROXY=1 pnpm test:e2e:proxy
9595+9696+echo ""
9797+echo -e "${GREEN}All tests passed!${NC}"
+134-104
tests/e2e/proxy/create.spec.ts
···1818 getSidebar,
1919 getProxiedContent,
2020} from '../../helpers/proxy';
2121+import { completePDSLogin } from '../../helpers/oauth-automation';
21222222-const TEST_HANDLE = process.env.TEST_HANDLE || 'seams-test.pds.seams.so';
2323+const TEST_HANDLE = process.env.TEST_HANDLE;
2424+const TEST_PASSWORD = process.env.TEST_PASSWORD;
23252426test.describe('Proxy Client Create Annotation', () => {
2527 test.skip(
···2830 );
29313032 test.skip(
3131- !process.env.TEST_HANDLE,
3232- 'Set TEST_HANDLE to run annotation creation tests'
3333+ !TEST_HANDLE || !TEST_PASSWORD,
3434+ 'Set TEST_HANDLE and TEST_PASSWORD in tests/.env.test'
3335 );
34363537 // Servers are started by playwright.config.ts webServer config
36383739 /**
3840 * Helper to login with test account via the sidebar
4141+ *
4242+ * The proxy uses WebOAuthLauncher which does a full page redirect (not a popup):
4343+ * 1. Click login → page redirects to PDS auth
4444+ * 2. Complete PDS login → redirects to oauth-callback.html
4545+ * 3. Callback processes → redirects back to original proxy URL
3946 */
4047 async function loginWithTestAccount(page: import('@playwright/test').Page) {
4848+ if (!TEST_HANDLE || !TEST_PASSWORD) {
4949+ throw new Error('TEST_HANDLE and TEST_PASSWORD must be set in tests/.env.test');
5050+ }
5151+4152 const sidebar = getSidebar(page);
42534354 // Click login trigger if visible
···5364 // Enter test handle
5465 await handleInput.fill(TEST_HANDLE);
55665656- // Click login button
6767+ // Click login button - this will redirect the page to PDS auth
5768 const loginBtn = sidebar.locator('#login-btn');
6969+ console.log('[proxy-test] Clicking login, will redirect to PDS auth...');
5870 await loginBtn.click();
59716060- // Wait for OAuth popup and manual authentication
6161- // In real testing, this would need automation or a pre-authenticated session
6262- console.log(
6363- 'OAuth login initiated - manual intervention may be required for full flow'
7272+ // Wait for redirect to PDS auth page
7373+ await page.waitForURL(/\/oauth\/authorize|\/oauth\/login/, { timeout: 30000 });
7474+ console.log('[proxy-test] On PDS auth page, completing login...');
7575+7676+ // Complete PDS login on the redirected page (not a popup)
7777+ // Proxy uses sure.seams.so/oauth-callback (different from extension's seams.so/oauth/callback)
7878+ await completePDSLogin(
7979+ page,
8080+ { identifier: TEST_HANDLE, password: TEST_PASSWORD },
8181+ /sure\.seams\.so\/oauth-callback|127\.0\.0\.1:8081\/oauth-callback/
6482 );
8383+ console.log('[proxy-test] PDS login completed, waiting for redirect back...');
65846666- // Wait for login to complete (look for user indicator)
6767- try {
6868- await sidebar.locator('.user-handle, #logout-btn').waitFor({
6969- timeout: 30000,
7070- });
7171- console.log('Login successful');
7272- } catch (e) {
7373- console.log('Login timeout - OAuth flow may not have completed');
8585+ // Wait for redirect back to proxy (oauth-callback then back to proxy)
8686+ await page.waitForURL(/127\.0\.0\.1:8081/, { timeout: 30000 });
8787+ console.log('[proxy-test] Back on proxy page');
8888+8989+ // Wait for sidebar to be ready after redirect
9090+ // The sidebar should show either the profile avatar or logout button when logged in
9191+ const sidebarAfterLogin = getSidebar(page);
9292+9393+ // Wait for service worker and sidebar to reinitialize
9494+ await page.waitForTimeout(2000);
9595+9696+ // Check if we're logged in by looking for logout button or profile elements
9797+ // The sidebar may not show profile-avatar immediately if it's in annotation view
9898+ const isLoggedIn = await sidebarAfterLogin.locator('#logout-btn').isVisible({ timeout: 5000 }).catch(() => false);
9999+ if (!isLoggedIn) {
100100+ // Try waiting a bit more for session to be picked up
101101+ await page.waitForTimeout(2000);
74102 }
103103+ console.log('[proxy-test] Login flow completed, logged in:', isLoggedIn);
75104 }
7610577106 /**
···86115 const content = getProxiedContent(page);
8711688117 // Execute selection in the iframe context
8989- const selectedText = await content.locator(selector).evaluate(
118118+ // Use .first() to handle pages with multiple matching elements
119119+ const selectedText = await content.locator(selector).first().evaluate(
90120 (el, { start, end }) => {
91121 const text = el.textContent || '';
122122+123123+ // Find the first text node (may be nested)
124124+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
125125+ const textNode = walker.nextNode();
126126+127127+ if (!textNode) {
128128+ console.log('[selectTextInProxy] No text node found');
129129+ return '';
130130+ }
131131+132132+ const nodeText = textNode.textContent || '';
92133 const range = document.createRange();
9393- const textNode = el.firstChild;
134134+ range.setStart(textNode, Math.min(start, nodeText.length));
135135+ range.setEnd(textNode, Math.min(end, nodeText.length));
941369595- if (textNode && textNode.nodeType === Node.TEXT_NODE) {
9696- range.setStart(textNode, Math.min(start, text.length));
9797- range.setEnd(textNode, Math.min(end, text.length));
137137+ const selection = window.getSelection();
138138+ selection?.removeAllRanges();
139139+ selection?.addRange(range);
981409999- const selection = window.getSelection();
100100- selection?.removeAllRanges();
101101- selection?.addRange(range);
141141+ // Dispatch selectionchange on document to trigger content script handlers
142142+ // The content script listens to 'selectionchange' on document with 150ms debounce
143143+ document.dispatchEvent(new Event('selectionchange'));
102144103103- return selection?.toString() || '';
104104- }
105105- return '';
145145+ // Return the selected portion of text
146146+ return nodeText.substring(start, Math.min(end, nodeText.length));
106147 },
107148 { start: startOffset, end: endOffset }
108149 );
150150+151151+ // Wait for the selection change to be processed (150ms debounce + propagation)
152152+ await page.waitForTimeout(500);
109153110154 return selectedText;
111155 }
···116160 await navigateToProxiedUrl(page, 'https://example.com/');
117161 await page.waitForTimeout(2000);
118162119119- // Try to select text (should show login prompt in sidebar)
120120- const content = getProxiedContent(page);
163163+ // Select text in the proxied content
164164+ await selectTextInProxy(page, 'p', 0, 20);
165165+ await page.waitForTimeout(500);
121166122122- // Find a text element and select it
123123- try {
124124- await selectTextInProxy(page, 'p', 0, 20);
125125- await page.waitForTimeout(500);
126126-127127- // Sidebar should show login prompt
128128- const sidebar = getSidebar(page);
129129- const loginPrompt = sidebar.locator(
130130- 'text=Log in to create, text=Login to annotate'
131131- );
132132- const loginTrigger = sidebar.locator('#login-trigger-btn');
133133-134134- const showsLoginUI =
135135- (await loginPrompt.isVisible().catch(() => false)) ||
136136- (await loginTrigger.isVisible().catch(() => false));
167167+ // Sidebar should show login prompt
168168+ const sidebar = getSidebar(page);
169169+ const loginTrigger = sidebar.locator('#login-trigger-btn');
137170138138- expect(showsLoginUI).toBe(true);
139139- } catch (e) {
140140- console.log('Could not select text in proxied content:', e);
141141- }
171171+ await expect(loginTrigger).toBeVisible({ timeout: 5000 });
142172 });
143173144174 test('creates annotation via text selection when logged in', async ({
···150180 // Login first
151181 await loginWithTestAccount(page);
152182183183+ // After login redirect, wait for the proxied content to be ready again
184184+ await page.waitForFunction(
185185+ () => {
186186+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
187187+ return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/');
188188+ },
189189+ { timeout: 15000 }
190190+ );
191191+ await page.waitForTimeout(1000);
192192+153193 // Select text in the proxied content
154154- try {
155155- const selectedText = await selectTextInProxy(page, 'p', 0, 20);
194194+ const selectedText = await selectTextInProxy(page, 'p', 0, 20);
195195+ expect(selectedText.length).toBeGreaterThan(0);
156196157157- if (selectedText.length === 0) {
158158- console.log('No text selected - skipping creation test');
159159- return;
160160- }
197197+ // Wait for selection to propagate to sidebar
198198+ await page.waitForTimeout(1000);
161199162162- // Wait for selection to propagate to sidebar
163163- await page.waitForTimeout(1000);
200200+ const sidebar = getSidebar(page);
164201165165- const sidebar = getSidebar(page);
202202+ // The annotation form should appear with the selected text
203203+ const annotationForm = sidebar.locator('#annotation-form');
204204+ await annotationForm.waitFor({ timeout: 5000 });
166205167167- // The annotation form should appear with the selected text
168168- const annotationForm = sidebar.locator('#annotation-form');
206206+ // Should show the selected text in blockquote
207207+ const quoteBlock = sidebar.locator('#selected-text blockquote');
208208+ await expect(quoteBlock).toContainText(selectedText.substring(0, 10));
169209170170- try {
171171- await annotationForm.waitFor({ timeout: 5000 });
210210+ // Fill in annotation note
211211+ const textarea = sidebar.locator('#annotation-text');
212212+ await textarea.fill('Proxy test annotation - ' + Date.now());
172213173173- // Should show the selected text in blockquote
174174- const quoteBlock = sidebar.locator('#selected-text blockquote');
175175- await expect(quoteBlock).toContainText(selectedText.substring(0, 10));
214214+ // Save the annotation
215215+ await sidebar.locator('#save-btn').click();
176216177177- // Fill in annotation note
178178- const textarea = sidebar.locator('#annotation-text');
179179- await textarea.fill('Proxy test annotation - ' + Date.now());
217217+ // Wait for annotation to be created
218218+ await page.waitForTimeout(2000);
180219181181- // Save the annotation
182182- await sidebar.locator('#save-btn').click();
183183-184184- // Wait for annotation to be created
185185- await page.waitForTimeout(2000);
186186-187187- // Verify the annotation was created
188188- const annotationCards = sidebar.locator('seams-annotation-card');
189189- const count = await annotationCards.count();
190190- expect(count).toBeGreaterThan(0);
191191- } catch (e) {
192192- console.log('Annotation form did not appear:', e);
193193- }
194194- } catch (e) {
195195- console.log('Could not select text in proxied content:', e);
196196- }
220220+ // Verify the annotation was created
221221+ const annotationCards = sidebar.locator('seams-annotation-card');
222222+ const count = await annotationCards.count();
223223+ expect(count).toBeGreaterThan(0);
197224 });
198225199226 test('clears selection when clicking cancel', async ({ page }) => {
···203230 // Login first
204231 await loginWithTestAccount(page);
205232206206- try {
207207- // Select text
208208- await selectTextInProxy(page, 'p', 0, 15);
209209- await page.waitForTimeout(500);
233233+ // After login redirect, wait for the proxied content to be ready again
234234+ await page.waitForFunction(
235235+ () => {
236236+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
237237+ return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/');
238238+ },
239239+ { timeout: 15000 }
240240+ );
241241+ await page.waitForTimeout(1000);
242242+243243+ // Select text
244244+ const selectedText = await selectTextInProxy(page, 'p', 0, 15);
245245+ expect(selectedText.length).toBeGreaterThan(0);
246246+247247+ // Wait for selection to propagate to sidebar
248248+ await page.waitForTimeout(1000);
210249211211- const sidebar = getSidebar(page);
212212- const annotationForm = sidebar.locator('#annotation-form');
250250+ const sidebar = getSidebar(page);
251251+ const annotationForm = sidebar.locator('#annotation-form');
213252214214- try {
215215- await annotationForm.waitFor({ timeout: 5000 });
253253+ await annotationForm.waitFor({ timeout: 10000 });
216254217217- // Click cancel/clear button
218218- const clearBtn = sidebar.locator(
219219- '#clear-selection-btn, button:text("Cancel")'
220220- );
221221- await clearBtn.click();
255255+ // Click cancel/clear button
256256+ const clearBtn = sidebar.locator('#clear-selection-btn, button:text("Cancel")');
257257+ await clearBtn.click();
222258223223- // Form should be hidden
224224- await expect(annotationForm).not.toBeVisible({ timeout: 2000 });
225225- } catch (e) {
226226- console.log('Annotation form or cancel button not found:', e);
227227- }
228228- } catch (e) {
229229- console.log('Could not complete cancel test:', e);
230230- }
259259+ // Form should be hidden
260260+ await expect(annotationForm).not.toBeVisible({ timeout: 2000 });
231261 });
232262233263 test('mobile toggle button shows annotation interface', async ({ page }) => {
+49
tests/e2e/proxy/hard-refresh.spec.ts
···11+/**
22+ * Proxy refresh integration tests
33+ *
44+ * Tests that the proxy correctly handles page refreshes.
55+ * Known Issue: Hard refresh breaks the proxy - see known_issues/HARD_REFRESH_FAILURE.md
66+ */
77+88+import { test, expect } from '@playwright/test';
99+import { PROXY_BASE_URL } from '../../helpers/proxy';
1010+1111+test.describe('Proxy Refresh Behavior', () => {
1212+ test.skip(
1313+ !process.env.RUN_PROXY_TESTS,
1414+ 'Set RUN_PROXY_TESTS=1 to run proxy client tests'
1515+ );
1616+1717+ test('proxied content loads after soft refresh (F5)', async ({ page }) => {
1818+ // Navigate to proxy with URL
1919+ await page.goto(`${PROXY_BASE_URL}/#https://example.com/`);
2020+ await page.waitForLoadState('networkidle');
2121+2222+ // Wait for iframe src to be set (proxy working)
2323+ await page.waitForFunction(
2424+ () => {
2525+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
2626+ return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/');
2727+ },
2828+ { timeout: 10000 }
2929+ );
3030+3131+ // Soft refresh
3232+ await page.reload({ waitUntil: 'networkidle' });
3333+3434+ // Verify iframe src is set again
3535+ await page.waitForFunction(
3636+ () => {
3737+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
3838+ return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/');
3939+ },
4040+ { timeout: 10000 }
4141+ );
4242+4343+ const iframeSrc = await page.evaluate(() => {
4444+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
4545+ return iframe?.src || '';
4646+ });
4747+ expect(iframeSrc).toContain('example.com');
4848+ });
4949+});
+281
tests/e2e/proxy/migration.spec.ts
···11+/**
22+ * Proxy migration E2E tests
33+ *
44+ * Tests that the proxy correctly handles migration when:
55+ * 1. The version key is missing (fresh install or upgrade from old version)
66+ * 2. The version key is outdated
77+ * 3. Old service worker needs to be replaced
88+ *
99+ * The migration should:
1010+ * - Unregister old service workers
1111+ * - Clear sessionStorage['seams_login_redirect']
1212+ * - Clear localStorage['synthesis-oauth:session']
1313+ * - Delete wabac.js IndexedDB databases
1414+ * - Set the version key to prevent re-migration
1515+ */
1616+1717+import { test, expect, type Page } from '@playwright/test';
1818+import { PROXY_BASE_URL, waitForServiceWorkerReady } from '../../helpers/proxy';
1919+2020+// Constants matching those in loadwabac.js
2121+const VERSION_KEY = 'seams-proxy-version';
2222+const OAUTH_SESSION_KEY = 'synthesis-oauth:session';
2323+const LOGIN_REDIRECT_KEY = 'seams_login_redirect';
2424+2525+test.describe('Proxy Migration', () => {
2626+ test.skip(
2727+ !process.env.RUN_PROXY_TESTS,
2828+ 'Set RUN_PROXY_TESTS=1 to run proxy client tests'
2929+ );
3030+3131+ /**
3232+ * Helper to clear all proxy-related storage before a test
3333+ */
3434+ async function clearProxyStorage(page: Page): Promise<void> {
3535+ await page.evaluate(() => {
3636+ localStorage.clear();
3737+ sessionStorage.clear();
3838+ });
3939+4040+ // Clear IndexedDB databases
4141+ await page.evaluate(async () => {
4242+ const databases = await indexedDB.databases?.() || [];
4343+ for (const db of databases) {
4444+ if (db.name) {
4545+ indexedDB.deleteDatabase(db.name);
4646+ }
4747+ }
4848+ });
4949+5050+ // Unregister all service workers
5151+ await page.evaluate(async () => {
5252+ const registrations = await navigator.serviceWorker.getRegistrations();
5353+ for (const registration of registrations) {
5454+ await registration.unregister();
5555+ }
5656+ });
5757+ }
5858+5959+ /**
6060+ * Helper to set up "old" state that should be cleared by migration
6161+ */
6262+ async function setupOldState(page: Page): Promise<void> {
6363+ await page.evaluate(
6464+ ({ oauthKey, redirectKey }) => {
6565+ // Set a fake OAuth session
6666+ localStorage.setItem(
6767+ oauthKey,
6868+ JSON.stringify({
6969+ sub: 'did:plc:test',
7070+ accessToken: 'fake-token',
7171+ })
7272+ );
7373+ // Set a fake redirect URL
7474+ sessionStorage.setItem(redirectKey, 'https://example.com/old-page');
7575+ },
7676+ { oauthKey: OAUTH_SESSION_KEY, redirectKey: LOGIN_REDIRECT_KEY }
7777+ );
7878+ }
7979+8080+ test('runs migration when version key is missing', async ({ page }) => {
8181+ // Start fresh - clear everything
8282+ await page.goto(PROXY_BASE_URL);
8383+ await clearProxyStorage(page);
8484+8585+ // Set up old state that should be cleared
8686+ await setupOldState(page);
8787+8888+ // Verify old state is set
8989+ const preOAuth = await page.evaluate(
9090+ (key) => localStorage.getItem(key),
9191+ OAUTH_SESSION_KEY
9292+ );
9393+ const preRedirect = await page.evaluate(
9494+ (key) => sessionStorage.getItem(key),
9595+ LOGIN_REDIRECT_KEY
9696+ );
9797+ expect(preOAuth).not.toBeNull();
9898+ expect(preRedirect).not.toBeNull();
9999+100100+ // Reload to trigger migration
101101+ await page.reload({ waitUntil: 'networkidle' });
102102+ await waitForServiceWorkerReady(page);
103103+104104+ // Version key should now be set
105105+ const versionKey = await page.evaluate(
106106+ (key) => localStorage.getItem(key),
107107+ VERSION_KEY
108108+ );
109109+ expect(versionKey).not.toBeNull();
110110+ expect(parseInt(versionKey!)).toBeGreaterThanOrEqual(1);
111111+112112+ // OAuth session should be cleared
113113+ const postOAuth = await page.evaluate(
114114+ (key) => localStorage.getItem(key),
115115+ OAUTH_SESSION_KEY
116116+ );
117117+ expect(postOAuth).toBeNull();
118118+119119+ // Session storage redirect should be cleared
120120+ const postRedirect = await page.evaluate(
121121+ (key) => sessionStorage.getItem(key),
122122+ LOGIN_REDIRECT_KEY
123123+ );
124124+ expect(postRedirect).toBeNull();
125125+ });
126126+127127+ test('does not re-run migration when version key is current', async ({
128128+ page,
129129+ }) => {
130130+ // Navigate to proxy and let it initialize normally
131131+ await page.goto(PROXY_BASE_URL);
132132+ await page.waitForLoadState('networkidle');
133133+ await waitForServiceWorkerReady(page);
134134+135135+ // Get the current version
136136+ const currentVersion = await page.evaluate(
137137+ (key) => localStorage.getItem(key),
138138+ VERSION_KEY
139139+ );
140140+ expect(currentVersion).not.toBeNull();
141141+142142+ // Now set some state that should NOT be cleared on reload
143143+ await page.evaluate(
144144+ ({ oauthKey }) => {
145145+ localStorage.setItem(
146146+ oauthKey,
147147+ JSON.stringify({
148148+ sub: 'did:plc:persist',
149149+ accessToken: 'should-persist',
150150+ })
151151+ );
152152+ },
153153+ { oauthKey: OAUTH_SESSION_KEY }
154154+ );
155155+156156+ // Reload - migration should NOT run since version matches
157157+ await page.reload({ waitUntil: 'networkidle' });
158158+ await waitForServiceWorkerReady(page);
159159+160160+ // OAuth session should still be there (not cleared by migration)
161161+ const postOAuth = await page.evaluate(
162162+ (key) => localStorage.getItem(key),
163163+ OAUTH_SESSION_KEY
164164+ );
165165+ expect(postOAuth).not.toBeNull();
166166+167167+ const parsed = JSON.parse(postOAuth!);
168168+ expect(parsed.sub).toBe('did:plc:persist');
169169+ });
170170+171171+ test('runs migration when version key is outdated', async ({ page }) => {
172172+ // Navigate and initialize
173173+ await page.goto(PROXY_BASE_URL);
174174+ await page.waitForLoadState('networkidle');
175175+ await waitForServiceWorkerReady(page);
176176+177177+ // Set an old version number (simulating upgrade from old version)
178178+ await page.evaluate(
179179+ (key) => {
180180+ localStorage.setItem(key, '1'); // Old version
181181+ },
182182+ VERSION_KEY
183183+ );
184184+185185+ // Set up state that should be cleared
186186+ await setupOldState(page);
187187+188188+ // Reload to trigger migration
189189+ await page.reload({ waitUntil: 'networkidle' });
190190+ await waitForServiceWorkerReady(page);
191191+192192+ // Version should be updated to current
193193+ const newVersion = await page.evaluate(
194194+ (key) => localStorage.getItem(key),
195195+ VERSION_KEY
196196+ );
197197+ expect(parseInt(newVersion!)).toBeGreaterThan(1);
198198+199199+ // OAuth session should be cleared
200200+ const postOAuth = await page.evaluate(
201201+ (key) => localStorage.getItem(key),
202202+ OAUTH_SESSION_KEY
203203+ );
204204+ expect(postOAuth).toBeNull();
205205+ });
206206+207207+ test('service worker is registered after migration', async ({ page }) => {
208208+ // Start fresh
209209+ await page.goto(PROXY_BASE_URL);
210210+ await clearProxyStorage(page);
211211+212212+ // Reload to trigger fresh registration
213213+ await page.reload({ waitUntil: 'networkidle' });
214214+ await waitForServiceWorkerReady(page);
215215+216216+ // Verify service worker is registered and controlling
217217+ const hasController = await page.evaluate(
218218+ () => navigator.serviceWorker.controller !== null
219219+ );
220220+ expect(hasController).toBe(true);
221221+222222+ // Verify we can navigate to a proxied URL (SW is working)
223223+ await page.evaluate(() => {
224224+ window.location.hash = 'https://example.com/';
225225+ });
226226+227227+ // Wait for iframe to load proxied content
228228+ await page.waitForFunction(
229229+ () => {
230230+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
231231+ return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/');
232232+ },
233233+ { timeout: 15000 }
234234+ );
235235+236236+ const iframeSrc = await page.evaluate(() => {
237237+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
238238+ return iframe?.src || '';
239239+ });
240240+ expect(iframeSrc).toContain('example.com');
241241+ });
242242+243243+ test('migration clears IndexedDB databases', async ({ page }) => {
244244+ // Navigate and initialize - this creates wabac.js IndexedDB
245245+ await page.goto(`${PROXY_BASE_URL}/#https://example.com/`);
246246+ await page.waitForLoadState('networkidle');
247247+ await waitForServiceWorkerReady(page);
248248+249249+ // Wait for proxy to initialize and potentially create IndexedDB
250250+ await page.waitForTimeout(2000);
251251+252252+ // Get list of IndexedDB databases before migration
253253+ const dbsBefore = await page.evaluate(async () => {
254254+ const databases = await indexedDB.databases?.() || [];
255255+ return databases.map((db) => db.name).filter(Boolean);
256256+ });
257257+258258+ // Force a migration by clearing version key
259259+ await page.evaluate(
260260+ (key) => {
261261+ localStorage.removeItem(key);
262262+ },
263263+ VERSION_KEY
264264+ );
265265+266266+ // Reload to trigger migration
267267+ await page.reload({ waitUntil: 'networkidle' });
268268+ await waitForServiceWorkerReady(page);
269269+270270+ // Wait for migration to complete
271271+ await page.waitForTimeout(1000);
272272+273273+ // The wabac-related databases should be cleared
274274+ // Note: New databases may be created after migration, but old data is gone
275275+ const versionAfter = await page.evaluate(
276276+ (key) => localStorage.getItem(key),
277277+ VERSION_KEY
278278+ );
279279+ expect(versionAfter).not.toBeNull();
280280+ });
281281+});
+66
tests/e2e/proxy/redirect.spec.ts
···11+/**
22+ * Proxy URL redirect tests
33+ *
44+ * Tests that legacy /proxy/<url> paths redirect to the hash-based #<url> format.
55+ * This maintains backwards compatibility for existing deployments (landing page,
66+ * iOS shortcut, share_target).
77+ */
88+99+import { test, expect } from '@playwright/test';
1010+import { PROXY_BASE_URL } from '../../helpers/proxy';
1111+1212+test.describe('Proxy URL Redirect', () => {
1313+ test.skip(
1414+ !process.env.RUN_PROXY_TESTS,
1515+ 'Set RUN_PROXY_TESTS=1 to run proxy client tests'
1616+ );
1717+1818+ test('/proxy/<url> redirects to #<url>', async ({ page }) => {
1919+ // Navigate to legacy URL format
2020+ await page.goto(`${PROXY_BASE_URL}/proxy/https://example.com`);
2121+2222+ // Should redirect to hash-based URL
2323+ // The redirect happens via serve.json config (302 redirect)
2424+ await page.waitForURL(`${PROXY_BASE_URL}/#https://example.com`);
2525+ expect(page.url()).toBe(`${PROXY_BASE_URL}/#https://example.com`);
2626+ });
2727+2828+ test('/proxy/<url> with path preserves full URL', async ({ page }) => {
2929+ await page.goto(`${PROXY_BASE_URL}/proxy/https://example.com/path/to/page`);
3030+3131+ await page.waitForURL(`${PROXY_BASE_URL}/#https://example.com/path/to/page`);
3232+ expect(page.url()).toBe(`${PROXY_BASE_URL}/#https://example.com/path/to/page`);
3333+ });
3434+3535+ test('/proxy/<url> with query string preserves params', async ({ page }) => {
3636+ await page.goto(`${PROXY_BASE_URL}/proxy/https://example.com?foo=bar&baz=qux`);
3737+3838+ // Query strings in the target URL should be preserved
3939+ await page.waitForURL(/\/#https:\/\/example\.com\?foo=bar/);
4040+ expect(page.url()).toContain('#https://example.com?foo=bar');
4141+ });
4242+4343+ test('redirected URL loads proxy correctly', async ({ page }) => {
4444+ // Navigate via legacy URL
4545+ await page.goto(`${PROXY_BASE_URL}/proxy/https://example.com`);
4646+4747+ // Wait for redirect to complete
4848+ await page.waitForURL(`${PROXY_BASE_URL}/#https://example.com`);
4949+5050+ // Wait for the proxy to initialize and load the content
5151+ await page.waitForFunction(
5252+ () => {
5353+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
5454+ return iframe?.src?.includes('/w/') && iframe?.src?.includes('mp_/');
5555+ },
5656+ { timeout: 15000 }
5757+ );
5858+5959+ // Verify the iframe is loading the correct URL
6060+ const iframeSrc = await page.evaluate(() => {
6161+ const iframe = document.querySelector('#content') as HTMLIFrameElement;
6262+ return iframe?.src || '';
6363+ });
6464+ expect(iframeSrc).toContain('example.com');
6565+ });
6666+});
+24
tests/e2e/proxy/sidebar.spec.ts
···6565 expect(hasLoginUI).toBe(true);
6666 });
67676868+ test('OAuth callback page is accessible without redirect', async ({ page }) => {
6969+ // This test ensures the OAuth callback page is served correctly
7070+ // and not redirected to index.html by SPA mode
7171+ // Bug: serve -s mode redirects .html files to clean URLs, breaking OAuth
7272+ const response = await page.goto('http://127.0.0.1:8081/oauth-callback.html');
7373+7474+ // Should not be redirected (or if redirected, should still get the callback page)
7575+ expect(response?.status()).toBe(200);
7676+7777+ // Wait for page to load
7878+ await page.waitForLoadState('domcontentloaded');
7979+8080+ // The callback page should have its specific title
8181+ await expect(page).toHaveTitle('Seams OAuth Callback');
8282+8383+ // The callback page should show the "Connecting..." UI, not the proxy UI
8484+ const spinner = page.locator('.spinner');
8585+ await expect(spinner).toBeVisible();
8686+8787+ // Should NOT have the proxy URL input (that would indicate we got index.html)
8888+ const urlInput = page.locator('#urlInput');
8989+ await expect(urlInput).not.toBeVisible();
9090+ });
9191+6892 test('toggles sidebar visibility', async ({ page }) => {
6993 await page.goto('http://127.0.0.1:8081/');
7094 await page.waitForLoadState('networkidle');
+7-2
tests/helpers/oauth-automation.ts
···1515 * 1. Click "Sign in" button on initial PDS page
1616 * 2. Fill identifier and password
1717 * 3. Click "Authorize" button on consent screen
1818+ *
1919+ * @param page - Playwright page on the PDS auth URL
2020+ * @param credentials - User credentials
2121+ * @param callbackUrlPattern - Regex to match the OAuth callback URL (default: extension callback)
1822 */
1923export async function completePDSLogin(
2024 page: Page,
2121- credentials: { identifier: string; password: string }
2525+ credentials: { identifier: string; password: string },
2626+ callbackUrlPattern: RegExp = /seams\.so\/oauth\/callback/
2227): Promise<void> {
2328 // Wait for PDS authorize page to load (React app hydration)
2429 await page.waitForLoadState('networkidle');
···6469 await authorizeBtn.click();
65706671 // Wait for redirect back to seams callback
6767- await page.waitForURL(/seams\.so\/oauth\/callback/, { timeout: 30000 });
7272+ await page.waitForURL(callbackUrlPattern, { timeout: 30000 });
6873}
69747075/**
+3-2
tests/playwright.config.ts
···80808181 // Servers for integration tests
8282 // Only start proxy servers when running proxy tests
8383+ // Set USE_EXTERNAL_PROXY=1 to skip starting proxy servers (e.g., when testing Docker container)
8384 webServer: [
8485 // Backend server (Go) - needed for both extension and proxy tests
8586 {
···8990 reuseExistingServer: !process.env.CI,
9091 timeout: 30000,
9192 },
9292- // Proxy static server (for proxy tests only)
9393- ...(process.env.RUN_PROXY_TESTS
9393+ // Proxy servers (for proxy tests only, skip if USE_EXTERNAL_PROXY is set)
9494+ ...(process.env.RUN_PROXY_TESTS && !process.env.USE_EXTERNAL_PROXY
9495 ? [
9596 {
9697 command: 'npx serve -p 8081 dist',
+1-1
vite.proxy.shared.ts
···23232424// CORS proxy URL configuration
2525export const DEV_CORS_PROXY_URL = `http://${DEV_HOST}:${DEV_PROXY_PORT}/proxy/`;
2626-export const PROD_CORS_PROXY_URL = 'https://sure.seams.so/proxy/';
2626+export const PROD_CORS_PROXY_URL = 'https://sure.seams.so:8082/proxy/';
27272828// Standard env defines for OAuth configuration
2929export function getEnvDefines() {
+13-6
wxt.config.ts
···22import { injectOauthEnvForExtension } from './scripts/inject-oauth-plugin';
3344export default defineConfig({
55+ hooks: {
66+ 'build:manifestGenerated': (wxt, manifest) => {
77+ // Add default_icon to sidebar_action for Firefox
88+ if (wxt.config.browser === 'firefox' && manifest.sidebar_action) {
99+ manifest.sidebar_action.default_icon = {
1010+ "16": "icon-16.png",
1111+ "32": "icon-32.png",
1212+ };
1313+ }
1414+ },
1515+ },
516 manifest: (env) => ({
617 name: 'Seams',
718 description: 'Web annotations on AT Protocol',
···2334 name: "Seams",
2435 url: "https://seams.so",
2536 },
2626- sidebar_action: {
2727- default_icon: {
2828- "16": "icon-16.png",
2929- "32": "icon-32.png",
3030- },
3131- },
3737+ // Note: sidebar_action.default_icon is added via build:manifestGenerated hook
3838+ // because WXT overwrites the sidebar_action from the sidepanel entrypoint
3239 }),
3340 permissions: [
3441 'storage',