diff --git a/package.json b/package.json index 21eff7b4..a8baf8c2 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "e2e": "pnpm zip && turbo e2e", "e2e:firefox": "pnpm zip:firefox && turbo e2e", "test:site": "pnpm build:chrome:production && pnpm -F @extension/e2e test:site", + "test:happy": "pnpm build:chrome:production && pnpm -F @extension/e2e test:happy", "lint": "turbo lint --continue", "lint:fix": "turbo lint:fix --continue", "format": "turbo format --continue -- --cache --cache-location node_modules/.cache/.prettiercache", diff --git a/tests/e2e/package.json b/tests/e2e/package.json index bfae9466..f291a72e 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -7,7 +7,8 @@ "type": "module", "scripts": { "e2e": "wdio run ./config/wdio.browser.conf.ts", - "test:site": "playwright test --config=./playwright/playwright.config.ts", + "test:site": "playwright test --config=./playwright/playwright.config.ts non-interference.spec.ts", + "test:happy": "playwright test --config=./playwright/playwright.config.ts capture-screenshot-happy.spec.ts", "clean:node_modules": "pnpx rimraf node_modules", "clean:turbo": "pnpx rimraf .turbo", "clean": "pnpm clean:turbo && pnpm clean:node_modules" diff --git a/tests/e2e/playwright/capture-screenshot-happy.spec.ts b/tests/e2e/playwright/capture-screenshot-happy.spec.ts new file mode 100644 index 00000000..1c647321 --- /dev/null +++ b/tests/e2e/playwright/capture-screenshot-happy.spec.ts @@ -0,0 +1,118 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { expect, test } from '@playwright/test'; +import type { BrowserContext } from '@playwright/test'; + +import { seedAuth } from './fixtures/auth.js'; +import { getExtensionId, launchExtensionContext, teardownExtensionContext } from './fixtures/extension.js'; +import { installMockApi } from './fixtures/mock-api.js'; +import type { MockApi } from './fixtures/mock-api.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOST_PAGE_URL = pathToFileURL(resolve(__dirname, 'fixtures/host-page.html')).toString(); + +let context: BrowserContext; +let userDataDir: string; +let mockApi: MockApi; + +/** + * Phase-1 happy path: load extension → seed auth → install API mocks → + * open popup → click screenshot capture → wait for content-UI mount in host → + * draw an annotation → submit → assert exactly one asset upload reached the mock. + * + * Selectors are intentionally loose (role + name regex) so a copy tweak in + * the popup doesn't break the suite — when something here goes red, prefer + * tightening the assertion over hardcoding a brittle CSS path. + * + * Marked `test.fixme` until the selector pass has been validated locally + * against a fresh `pnpm build:chrome:production`. Flip it on by removing + * `.fixme` once you've run it once and confirmed every step lands. + */ +test.beforeAll(async () => { + const launched = await launchExtensionContext(); + context = launched.context; + userDataDir = launched.userDataDir; + + mockApi = await installMockApi(context); + await seedAuth(context); +}); + +test.afterAll(async () => { + await teardownExtensionContext(context, userDataDir); +}); + +test.fixme('popup → screenshot capture → annotate → send produces an asset upload', async () => { + const extensionId = await getExtensionId(context); + + // Step 1 — host page is reachable and the content script mounts. + const host = await context.newPage(); + await host.goto(HOST_PAGE_URL, { waitUntil: 'domcontentloaded' }); + await host.waitForFunction(() => !!document.getElementById('brie-root'), null, { timeout: 10_000 }); + + // Step 2 — open the popup directly. The MV3 popup HTML is web-accessible. + const popup = await context.newPage(); + await popup.goto(`chrome-extension://${extensionId}/popup/index.html`, { + waitUntil: 'domcontentloaded', + }); + + // Step 3 — drive the screenshot CTA. Capture mode defaults to 'area'. + // The button's aria-label is `t('runAction', title)` with title from + // CAPTURE_TITLE['area'], so it should match /capture/i. + const captureButton = popup.getByRole('button', { name: /capture/i }).first(); + await expect(captureButton).toBeVisible(); + await captureButton.click(); + + // After click the popup calls window.close(); allow Chromium to settle. + await popup.waitForEvent('close', { timeout: 5_000 }).catch(() => { + /* If the popup variant doesn't close itself, continue anyway. */ + }); + + // Step 4 — content-UI mounts the annotation dialog into the host page. + // The shadow host lives at #brie-root; the dialog renders inside its shadow + // DOM after SCREENSHOT.START fires. + await host.waitForFunction( + () => { + const root = document.getElementById('brie-root'); + const shadow = (root as HTMLElement | null)?.shadowRoot ?? null; + return !!shadow?.querySelector('canvas, [role="dialog"]'); + }, + null, + { timeout: 15_000 }, + ); + + // Step 5 — draw a single rectangle on the annotation canvas. Selector probe + // accepts either the shadow-root canvas or a top-level one; the click drag + // simulates a freehand box. + const canvasHandle = await host.evaluateHandle(() => { + const root = document.getElementById('brie-root') as HTMLElement | null; + return root?.shadowRoot?.querySelector('canvas') ?? document.querySelector('canvas'); + }); + const box = await canvasHandle.evaluate(c => { + if (!(c instanceof HTMLCanvasElement)) return null; + const r = c.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + if (box) { + await host.mouse.move(box.x + box.w * 0.25, box.y + box.h * 0.25); + await host.mouse.down(); + await host.mouse.move(box.x + box.w * 0.6, box.y + box.h * 0.6, { steps: 10 }); + await host.mouse.up(); + } + + // Step 6 — submit. The send button lives in create-dropdown.ui.tsx and is + // gated by the details form. Use role+name to stay resilient to layout. + const sendButton = host.getByRole('button', { name: /create|send|share|submit/i }).first(); + await sendButton.click(); + + // Step 7 — verify exactly one asset upload reached the mock. If the flow + // splits into multiple assets later, change this to .toBeGreaterThan(0). + const upload = await mockApi.waitForAssetUpload(20_000); + expect(upload.method).toBe('POST'); + expect(upload.url).toMatch(/\/slices\/[^/]+\/assets\/[^/?]+/); + + // FormData crosses the wire as multipart/form-data with a 'file' field; the + // boundary header proves the body shape. + expect(upload.headers['content-type'] ?? '').toMatch(/multipart\/form-data/); + expect(upload.postDataBuffer?.length ?? 0).toBeGreaterThan(0); +}); diff --git a/tests/e2e/playwright/fixtures/auth.ts b/tests/e2e/playwright/fixtures/auth.ts new file mode 100644 index 00000000..a40f75a8 --- /dev/null +++ b/tests/e2e/playwright/fixtures/auth.ts @@ -0,0 +1,38 @@ +import type { BrowserContext } from '@playwright/test'; + +import { getExtensionId } from './extension.js'; + +/** + * Seed the auth-tokens storage so the popup renders the capture screen + * instead of the login flow. The storage key + value shape are pinned to + * what packages/storage/lib/impl/auth/tokens.storage.ts writes. + * + * Storage uses identity serialization (the base storage's default), so we + * write the raw object rather than a JSON-encoded string. + */ +export const seedAuth = async ( + context: BrowserContext, + tokens: { accessToken: string; refreshToken: string } = { + accessToken: 'pw-fake-access-token', + refreshToken: 'pw-fake-refresh-token', + }, +) => { + const extensionId = await getExtensionId(context); + + // Use a backgrounded popup page to run chrome.storage.local.set; calling it + // from a content-script page wouldn't have the right host_permissions. + const page = await context.newPage(); + try { + await page.goto(`chrome-extension://${extensionId}/popup/index.html`); + await page.evaluate(async value => { + // chrome namespace is provided at runtime by the extension context; cast + // through window since this test package doesn't include @types/chrome. + const ext = window as unknown as { + chrome: { storage: { local: { set: (items: Record) => Promise } } }; + }; + await ext.chrome.storage.local.set({ 'auth-tokens-storage-key': value }); + }, tokens); + } finally { + await page.close(); + } +}; diff --git a/tests/e2e/playwright/fixtures/extension.ts b/tests/e2e/playwright/fixtures/extension.ts new file mode 100644 index 00000000..7c7d33a2 --- /dev/null +++ b/tests/e2e/playwright/fixtures/extension.ts @@ -0,0 +1,72 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from '@playwright/test'; +import type { BrowserContext, Worker } from '@playwright/test'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const EXTENSION_DIR = resolve(__dirname, '../../../../dist'); + +/** + * Boots a persistent-context Chromium with the MV3 extension pre-loaded and + * fake-media flags pre-armed. Returns the context plus its temporary + * user-data-dir so the caller can clean up. + * + * `--use-fake-ui-for-media-stream` + `--use-fake-device-for-media-stream` are + * set unconditionally so the video happy-path (Phase 2) can be added without + * touching this loader. Screenshot-only specs ignore them. + */ +const launchExtensionContext = async (): Promise<{ + context: BrowserContext; + userDataDir: string; +}> => { + const userDataDir = mkdtempSync(join(tmpdir(), 'brie-pw-happy-')); + + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + `--disable-extensions-except=${EXTENSION_DIR}`, + `--load-extension=${EXTENSION_DIR}`, + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + '--auto-select-desktop-capture-source=Entire screen', + ...(process.env.CI === 'true' ? ['--headless=new', '--no-sandbox', '--disable-gpu'] : []), + ], + }); + + return { context, userDataDir }; +}; + +const teardownExtensionContext = async (context: BrowserContext | undefined, userDataDir: string | undefined) => { + await context?.close(); + if (userDataDir) rmSync(userDataDir, { recursive: true, force: true }); +}; + +const extractIdFromUrl = (url: string): string => { + const match = url.match(/chrome-extension:\/\/([^/]+)/); + if (!match) throw new Error(`Could not extract extension ID from SW URL: ${url}`); + return match[1]!; +}; + +/** + * Resolves the MV3 extension's runtime ID by waiting for its background + * service worker to register against the persistent context. Tests can't + * hardcode the ID because Chromium derives it from the unpacked dir path. + */ +const getExtensionId = async (context: BrowserContext, timeoutMs = 10_000): Promise => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const worker = context.serviceWorkers()[0]; + if (worker) return extractIdFromUrl(worker.url()); + await new Promise(r => setTimeout(r, 200)); + } + const promised = await new Promise(resolveFn => { + context.once('serviceworker', resolveFn); + }); + return extractIdFromUrl(promised.url()); +}; + +export { launchExtensionContext, teardownExtensionContext, getExtensionId }; diff --git a/tests/e2e/playwright/fixtures/host-page.html b/tests/e2e/playwright/fixtures/host-page.html new file mode 100644 index 00000000..b24d2082 --- /dev/null +++ b/tests/e2e/playwright/fixtures/host-page.html @@ -0,0 +1,13 @@ + + + + + Brie E2E Host Page + + +

Brie E2E host fixture

+

This page exists so the content script + content-UI have a mount point for the happy-path capture test.

+ + + + diff --git a/tests/e2e/playwright/fixtures/mock-api.ts b/tests/e2e/playwright/fixtures/mock-api.ts new file mode 100644 index 00000000..e2ef99c7 --- /dev/null +++ b/tests/e2e/playwright/fixtures/mock-api.ts @@ -0,0 +1,111 @@ +import type { BrowserContext, Route } from '@playwright/test'; + +type RecordedRequest = { + method: string; + url: string; + postDataBuffer: Buffer | null; + headers: Record; + matchedRoute: 'draft' | 'asset' | 'auth-refresh' | 'other'; +}; + +type MockApi = { + /** Every request that hit a mocked route, in order. */ + requests: () => RecordedRequest[]; + /** Asset-upload requests only — what the "send report" flow produces. */ + assetUploads: () => RecordedRequest[]; + /** True once at least one asset upload has been observed. */ + waitForAssetUpload: (timeoutMs?: number) => Promise; +}; + +const draftPattern = /\/slices\/draft\/?(?:\?|$)/; +const assetPattern = /\/slices\/[^/]+\/assets\/[^/?]+(?:\?|$)/; +const refreshPattern = /\/auth\/refresh\/?(?:\?|$)/; + +/** + * Intercepts the three API endpoints the capture-send flow touches: + * - POST /slices/draft — returns a stub sliceId + asset placeholder + * - POST /slices/{id}/assets/{aid} — accepts FormData, returns 200 + * - POST /auth/refresh — in case the seeded fake tokens hit the + * pre-emptive refresh path + * + * Routes are installed at context-level so both the popup page and any host + * page (where content-ui's fetches originate) inherit them. RTK Query fires + * from the page realm in both cases, which is what page.route can see. + * + * We DON'T route everything — only the patterns above — so any other request + * fails loudly if the flow ever depends on a new endpoint. + */ +const installMockApi = async (context: BrowserContext): Promise => { + const recorded: RecordedRequest[] = []; + + const stubSliceId = 'pw-stub-slice-id'; + const stubAssetId = 'pw-stub-asset-id'; + + const record = (route: Route, kind: RecordedRequest['matchedRoute']) => { + const request = route.request(); + recorded.push({ + method: request.method(), + url: request.url(), + postDataBuffer: request.postDataBuffer(), + headers: request.headers(), + matchedRoute: kind, + }); + }; + + await context.route(draftPattern, async route => { + record(route, 'draft'); + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + sliceId: stubSliceId, + assets: [{ assetId: stubAssetId, kind: 'screenshot' }], + }), + }); + }); + + await context.route(assetPattern, async route => { + record(route, 'asset'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true, assetId: stubAssetId }), + }); + }); + + await context.route(refreshPattern, async route => { + record(route, 'auth-refresh'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + accessToken: 'pw-fake-access-token-refreshed', + refreshToken: 'pw-fake-refresh-token-refreshed', + }), + }); + }); + + return { + requests: () => recorded.slice(), + assetUploads: () => recorded.filter(r => r.matchedRoute === 'asset'), + waitForAssetUpload: async (timeoutMs = 15_000) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const hit = recorded.find(r => r.matchedRoute === 'asset'); + if (hit) return hit; + await new Promise(r => setTimeout(r, 100)); + } + throw new Error( + `Timed out after ${timeoutMs}ms waiting for an asset upload. Recorded calls: ` + + JSON.stringify( + recorded.map(r => `${r.method} ${r.url}`), + null, + 2, + ), + ); + }, + }; +}; + +export { installMockApi }; +export type { MockApi };