Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
118 changes: 118 additions & 0 deletions tests/e2e/playwright/capture-screenshot-happy.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
38 changes: 38 additions & 0 deletions tests/e2e/playwright/fixtures/auth.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => Promise<void> } } };
};
await ext.chrome.storage.local.set({ 'auth-tokens-storage-key': value });
}, tokens);
} finally {
await page.close();
}
};
72 changes: 72 additions & 0 deletions tests/e2e/playwright/fixtures/extension.ts
Original file line number Diff line number Diff line change
@@ -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<string> => {
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<Worker>(resolveFn => {
context.once('serviceworker', resolveFn);
});
return extractIdFromUrl(promised.url());
};

export { launchExtensionContext, teardownExtensionContext, getExtensionId };
13 changes: 13 additions & 0 deletions tests/e2e/playwright/fixtures/host-page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Brie E2E Host Page</title>
</head>
<body>
<h1 id="page-heading">Brie E2E host fixture</h1>
<p>This page exists so the content script + content-UI have a mount point for the happy-path capture test.</p>
<button id="primary-button" type="button">Primary action</button>
<button id="secondary-button" type="button">Secondary action</button>
</body>
</html>
111 changes: 111 additions & 0 deletions tests/e2e/playwright/fixtures/mock-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { BrowserContext, Route } from '@playwright/test';

type RecordedRequest = {
method: string;
url: string;
postDataBuffer: Buffer | null;
headers: Record<string, string>;
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<RecordedRequest>;
};

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<MockApi> => {
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 };
Loading