From dc8a86c6f788e6c97956296733221591251c002f Mon Sep 17 00:00:00 2001 From: Darren Jia Date: Mon, 1 Jun 2026 17:36:41 -0700 Subject: [PATCH 1/2] playwright testing --- Bouncer/.gitignore | 6 ++ Bouncer/e2e/README.md | 80 +++++++++++++++ Bouncer/e2e/filter-management.spec.ts | 100 ++++++++++++++++++ Bouncer/e2e/filtering.spec.ts | 91 +++++++++++++++++ Bouncer/e2e/fixtures.ts | 139 ++++++++++++++++++++++++++ Bouncer/e2e/popup-settings.spec.ts | 112 +++++++++++++++++++++ Bouncer/e2e/save-auth.setup.ts | 42 ++++++++ Bouncer/e2e/tsconfig.json | 7 ++ Bouncer/e2e/x-feed.spec.ts | 43 ++++++++ Bouncer/package-lock.json | 64 ++++++++++++ Bouncer/package.json | 6 +- Bouncer/playwright.config.ts | 34 +++++++ 12 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 Bouncer/e2e/README.md create mode 100644 Bouncer/e2e/filter-management.spec.ts create mode 100644 Bouncer/e2e/filtering.spec.ts create mode 100644 Bouncer/e2e/fixtures.ts create mode 100644 Bouncer/e2e/popup-settings.spec.ts create mode 100644 Bouncer/e2e/save-auth.setup.ts create mode 100644 Bouncer/e2e/tsconfig.json create mode 100644 Bouncer/e2e/x-feed.spec.ts create mode 100644 Bouncer/playwright.config.ts diff --git a/Bouncer/.gitignore b/Bouncer/.gitignore index 1c35c27..a1fa79f 100644 --- a/Bouncer/.gitignore +++ b/Bouncer/.gitignore @@ -2,3 +2,9 @@ node_modules/ dist/* # Generated by generate-manifests.mjs from manifest.base.json + manifest..json /manifest.json + +# Playwright E2E: live login sessions (X cookies + extension Firebase auth) and reports +e2e/.userdata/ +playwright-report/ +test-results/ +.auth/ diff --git a/Bouncer/e2e/README.md b/Bouncer/e2e/README.md new file mode 100644 index 0000000..740a562 --- /dev/null +++ b/Bouncer/e2e/README.md @@ -0,0 +1,80 @@ +# E2E tests (Playwright + real X) + +These drive the **built, unpacked extension** in a real Chromium against the +live `x.com`. Because Chrome extensions require a headed *persistent* context, +all auth lives in one Chrome profile directory (`e2e/.userdata`, gitignored): + +- the **X website** session (cookies), and +- the **extension's Firebase** session (Firebase persists auth in the + extension's IndexedDB, which lives inside the profile — it is *not* captured by + Playwright's `storageState`). + +Log in once; every later run reuses that profile. + +## Prerequisites + +1. A **dedicated throwaway X account** (don't automate your personal account). +2. `.env.prod` populated with the Imbue keys (`FIREBASE_*`, `GOOGLE_CLIENT_ID`, + `IMBUE_WS_URL`). Without them the build stubs out auth and there's no Google + sign-in button. (Already set in this repo.) +3. Install the browser binary once: `npx playwright install chromium`. + +## One-time login + +```bash +npm run test:e2e:login +``` + +A real browser window opens on x.com. **By hand:** (1) log in to X, then (2) in +the feed click the Bouncer **Settings** gear → *Activate Bouncer* and complete +Google sign-in. The in-feed settings load the same UI as the toolbar popup, so +there's no separate popup tab. Then press ▶ Resume in the Playwright Inspector +(or close it). The profile is saved to `e2e/.userdata`. + +## Run the suite + +```bash +npm run test:e2e # headed (a browser window is visible) +npm run test:e2e:headless # no window — uses Chromium's new headless mode +``` + +Both scripts run `npm run build` first so the loaded extension is current. + +### Headless + +`HEADLESS=1` runs the suite with no visible window. Extensions do **not** load in +Playwright's old headless or the bundled `headless-shell`, so the fixture switches +to `channel: 'chromium'` (the full build) with `headless: true`, which uses +Chromium's *new* headless mode — the only headless variant that supports +extensions. The one-time `test:e2e:login` must stay **headed** (you log in by +hand); don't set `HEADLESS` for it. + +## When tests fail with "filter bar not visible" + +The saved session probably expired (X rotates cookies; Firebase tokens lapse). +Just re-run `npm run test:e2e:login`. + +## What's covered + +- `x-feed.spec.ts` — content script injects on the feed; a typed phrase persists. +- `filter-management.spec.ts` — comma-key commit, persistence across reload, no + duplicates, chip removal, the in-feed settings modal, and filter-bar + re-injection after SPA navigation. +- `popup-settings.spec.ts` — "filter replies" toggle, experimental AI-text + toggle + section reveal, threshold slider, and BYOK Anthropic key enable + (valid + invalid, with the verification request mocked via `context.route`). +- `filtering.spec.ts` — the end-to-end filtering behavior: seeds the Anthropic + BYOK path and mocks `api.anthropic.com` to mark every post as a match, then + asserts a post is hidden (`data-filtered-by-extension`), the "View filtered" + counter advances, and the filtered-posts modal lists it. The classification + fetch runs in the background service worker — `context.route` intercepts it. + +## Notes / limits + +- `workers` is pinned to 1 — persistent contexts don't parallelize. +- The filtering test mocks the classifier, so it proves the *pipeline + hide + + View-filtered* path is wired correctly — not that real AI inference is + accurate. It deliberately matches every post for determinism. +- This is **local-only**. Running against real X from GitHub CI is possible but + flaky (datacenter IPs get challenged, cookies expire); add it as a nightly / + manual workflow later, not a PR gate. diff --git a/Bouncer/e2e/filter-management.spec.ts b/Bouncer/e2e/filter-management.spec.ts new file mode 100644 index 0000000..ffbbadf --- /dev/null +++ b/Bouncer/e2e/filter-management.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, readStorage, openFeed, waitForFilterBar, FILTER_INPUT } from './fixtures'; + +const DESCRIPTIONS_KEY = 'descriptions_twitter'; + +async function descriptions(context: Parameters[0]): Promise { + const all = await readStorage(context); + return (all[DESCRIPTIONS_KEY] as string[] | undefined) ?? []; +} + +test.describe('Filter management on the real X feed', () => { + test.beforeEach(async ({ context }) => { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + await sw.evaluate((key) => chrome.storage.local.remove(key), DESCRIPTIONS_KEY); + }); + + test('comma key commits a phrase', async ({ context }) => { + const page = await openFeed(context); + const input = await waitForFilterBar(page); + + await input.click(); + await input.fill('crypto'); + await input.press(','); // the input commits on Enter OR comma + + await expect.poll(() => descriptions(context), { timeout: 10_000 }).toContain('crypto'); + }); + + test('phrases persist across a page reload', async ({ context }) => { + const page = await openFeed(context); + const input = await waitForFilterBar(page); + await input.click(); + await input.fill('engagement bait'); + await input.press('Enter'); + await expect.poll(() => descriptions(context), { timeout: 10_000 }).toContain('engagement bait'); + + await page.reload({ waitUntil: 'domcontentloaded' }); + + // The phrase is re-rendered as a chip from storage after reload. + await expect( + page.locator('.filter-phrase-inline:visible', { hasText: 'engagement bait' }).first() + ).toBeVisible({ timeout: 15_000 }); + expect(await descriptions(context)).toContain('engagement bait'); + }); + + test('the same phrase is not added twice', async ({ context }) => { + const page = await openFeed(context); + const input = await waitForFilterBar(page); + + for (let i = 0; i < 2; i++) { + await input.click(); + await input.fill('politics'); + await input.press('Enter'); + await expect.poll(() => descriptions(context), { timeout: 10_000 }).toContain('politics'); + } + + expect((await descriptions(context)).filter((d) => d === 'politics')).toHaveLength(1); + }); + + test('clicking a phrase chip removes it', async ({ context }) => { + const page = await openFeed(context); + const input = await waitForFilterBar(page); + await input.click(); + await input.fill('sports'); + await input.press('Enter'); + await expect.poll(() => descriptions(context), { timeout: 10_000 }).toContain('sports'); + + const chip = page.locator('.filter-phrase-inline:visible', { hasText: 'sports' }).first(); + await expect(chip).toBeVisible(); + await chip.click(); // chips are titled "Click to remove" + + await expect.poll(() => descriptions(context), { timeout: 10_000 }).not.toContain('sports'); + }); + + test('the settings gear opens the in-feed settings modal', async ({ context }) => { + const page = await openFeed(context); + await waitForFilterBar(page); + + await page.locator('.filter-settings-btn:visible').first().click(); + + const modal = page.locator('.settings-modal-overlay'); + await expect(modal).toBeVisible({ timeout: 10_000 }); + // The modal hosts the same popup UI in an iframe. + const iframe = modal.locator('iframe.settings-modal-iframe'); + await expect(iframe).toHaveAttribute('src', /popup\.html$/); + }); + + test('the filter bar re-injects after SPA navigation', async ({ context }) => { + const page = await openFeed(context); + await waitForFilterBar(page); + + // Navigate away within the SPA, then back — the MutationObserver should + // re-inject the bar without a full page load. + await page.locator('a[data-testid="AppTabBar_Explore_Link"], a[href="/explore"]').first().click(); + await page.waitForURL('**/explore', { timeout: 15_000 }); + await page.locator('a[data-testid="AppTabBar_Home_Link"], a[href="/home"]').first().click(); + await page.waitForURL('**/home', { timeout: 15_000 }); + + await waitForFilterBar(page); + }); +}); diff --git a/Bouncer/e2e/filtering.spec.ts b/Bouncer/e2e/filtering.spec.ts new file mode 100644 index 0000000..dac162b --- /dev/null +++ b/Bouncer/e2e/filtering.spec.ts @@ -0,0 +1,91 @@ +import { test, expect, openFeed } from './fixtures'; + +/** + * Tier 2: the actual filtering behavior. We point the classification pipeline + * at the Anthropic BYOK path (selectedModel = "anthropic:…" + a stored key) and + * mock that HTTP endpoint to mark EVERY post as a match. That makes the outcome + * deterministic without live AI inference or a real key. + * + * Note: the classification fetch is made by the extension's *background service + * worker*, so this also exercises Playwright's service-worker route interception. + */ + +const SEED = { + selectedModel: 'anthropic:claude-haiku-4-5-20251001', + anthropicApiKey: 'sk-ant-e2e-test', + descriptions_twitter: ['crypto'], + filterReplies: true, +}; + +async function setStorage(context: Parameters[0], items: Record) { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + await sw.evaluate((i) => chrome.storage.local.set(i), items); +} + +test.describe('Filtering behavior (mocked Anthropic BYOK)', () => { + test.afterEach(async ({ context }) => { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + await sw.evaluate(() => + chrome.storage.local.remove(['selectedModel', 'anthropicApiKey', 'descriptions_twitter']) + ); + }); + + test('a matching post is hidden and appears in "View filtered" with reasoning', async ({ + context, + }) => { + let intercepted = 0; + await context.route('https://api.anthropic.com/**', (route) => { + intercepted++; + // parseAPIResponse() treats any other than "no match"/"unknown" + // as a hit, and surfaces in the filtered-posts view. + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + content: [ + { + type: 'text', + text: 'Promotes cryptocurrency.crypto', + }, + ], + }), + }); + }); + + await setStorage(context, SEED); + const page = await openFeed(context); + + // Wait for the live feed to render real tweets. Use 'attached', not + // 'visible': the mock matches every post, so the extension may have already + // hidden (display:none) them by the time this runs. + await page.locator('article[data-testid="tweet"]').first().waitFor({ + state: 'attached', + timeout: 20_000, + }); + + // Hidden posts are marked on their cell container by the Twitter adapter. + await expect + .poll( + () => page.locator('[data-testid="cellInnerDiv"][data-filtered-by-extension="true"]').count(), + { timeout: 45_000, message: 'no post was hidden — was the SW fetch intercepted?' } + ) + .toBeGreaterThan(0); + + // Confirms the mock actually intercepted the background service worker's fetch. + expect(intercepted).toBeGreaterThan(0); + + // The in-feed "View filtered (N)" counter should advance past zero. + await expect + .poll(async () => (await page.locator('.filtered-toggle-count:visible').first().textContent()) ?? '', { + timeout: 10_000, + }) + .not.toBe('(0)'); + + // Opening the modal lists the filtered post(s); reasoning/category flow through. + await page.locator('.filtered-toggle-btn:visible').first().click(); + await expect(page.locator('.filtered-view-container')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.slop-post-wrapper').first()).toBeVisible(); + }); +}); diff --git a/Bouncer/e2e/fixtures.ts b/Bouncer/e2e/fixtures.ts new file mode 100644 index 0000000..44227bc --- /dev/null +++ b/Bouncer/e2e/fixtures.ts @@ -0,0 +1,139 @@ +import { test as base, chromium, type BrowserContext, type Locator, type Page, type Worker } from '@playwright/test'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** The built, unpacked extension folder (the one containing manifest.json + dist/). */ +export const EXTENSION_PATH = path.resolve(__dirname, '..'); + +/** + * Fixed Chrome profile directory. Holds BOTH the X website session AND the + * extension's Firebase session (Firebase persists auth in the extension's + * IndexedDB, which lives inside this profile — not in storageState). Log in + * once via `npm run test:e2e:login`; every later run reuses this profile. + */ +export const USER_DATA_DIR = path.resolve(__dirname, '.userdata'); + +type Fixtures = { + context: BrowserContext; + extensionId: string; +}; + +// Run headless with `HEADLESS=1`. Extensions DON'T load in Playwright's old +// headless or the bundled headless-shell — only in Chromium's *new* headless +// mode, which requires the full `channel: 'chromium'` build. The interactive +// login (test:e2e:login) must stay headed; don't set HEADLESS for it. +const HEADLESS = process.env.HEADLESS === '1' || process.env.HEADLESS === 'true'; + +/** + * Launches Chromium with the unpacked extension loaded against the persistent + * profile. `--enable-automation` is stripped because it breaks the singleton + * browser-process behavior the extension relies on (and reduces Google's + * "automated software" friction at login). + */ +export const test = base.extend({ + context: async ({}, use) => { + const context = await chromium.launchPersistentContext(USER_DATA_DIR, { + headless: HEADLESS, + // New headless mode (extension-capable) lives in the full chromium build, + // not the default headless-shell. + channel: HEADLESS ? 'chromium' : undefined, + ignoreDefaultArgs: ['--enable-automation'], + // Desktop width so X renders the right sidebar (where the filter bar lives). + viewport: { width: 1440, height: 900 }, + args: [ + `--disable-extensions-except=${EXTENSION_PATH}`, + `--load-extension=${EXTENSION_PATH}`, + // Reduces "automated software" friction during the manual Google/X login. + '--disable-blink-features=AutomationControlled', + ], + }); + await use(context); + await context.close(); + }, + + extensionId: async ({ context }, use) => { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + // chrome-extension:///... + const extensionId = sw.url().split('/')[2]; + await use(extensionId); + }, +}); + +export const expect = test.expect; + +/** Convenience: read the extension's chrome.storage.local from the service worker. */ +export async function readStorage(context: BrowserContext): Promise> { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + return (sw as Worker).evaluate(() => chrome.storage.local.get(null)); +} + +/** + * Returns a single x.com page navigated to `url`, and closes every other tab. + * The extension auto-opens an x.com tab on install (onInstalled), so without + * this each test would end up with two x tabs. We reuse that tab when present. + */ +export async function openFeed( + context: BrowserContext, + url = 'https://x.com/home' +): Promise { + const page = + context.pages().find((p) => p.url().includes('x.com')) ?? + (await context.waitForEvent('page', { timeout: 5_000 }).catch(() => null)) ?? + context.pages()[0] ?? + (await context.newPage()); + + // Close any other tabs (a leftover blank tab, or a duplicate x.com tab). + for (const other of context.pages()) { + if (other !== page) await other.close().catch(() => {}); + } + + await page.goto(url, { waitUntil: 'domcontentloaded' }); + return page; +} + +/** + * Opens the extension's settings page (popup.html) in a single tab, closing + * others. Waits for the authenticated settings view (#mainContainer) to show — + * if that times out, the extension's Firebase session has expired; re-run + * `npm run test:e2e:login`. + */ +export async function openPopup(context: BrowserContext, extensionId: string): Promise { + const page = context.pages()[0] ?? (await context.newPage()); + for (const other of context.pages()) { + if (other !== page) await other.close().catch(() => {}); + } + await page.goto(`chrome-extension://${extensionId}/popup.html`, { waitUntil: 'domcontentloaded' }); + // #mainContainer is visible by default, so it's not a readiness signal. Wait + // instead for the model dropdown to be populated — that happens inside + // loadSettings(), which is immediately followed by setupEventListeners(), so + // by the time the menu has items the change/click handlers are attached. + await page.locator('#modelDropdownMenu > *').first().waitFor({ state: 'attached', timeout: 15_000 }); + return page; +} + +// Bouncer's filter input. It has sidebar/bottom/mobile variants and only one is +// shown per layout, so always target the visible one. +export const FILTER_INPUT = '.filter-phrases-input:visible'; + +/** + * Waits for Bouncer's filter bar to be injected and visible, returning the + * input locator. The bar only appears after X's feed has rendered and the + * extension is signed in, so this is the single reliable "feed is ready" signal. + * The timeout is generous because it depends on live X's (variable) load time. + */ +export async function waitForFilterBar(page: Page, timeout = 30_000): Promise { + const input = page.locator(FILTER_INPUT).first(); + await input.waitFor({ state: 'visible', timeout }); + return input; +} + +/** Remove keys from chrome.storage.local via the service worker (test cleanup). */ +export async function clearStorageKeys(context: BrowserContext, keys: string[]): Promise { + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + await (sw as Worker).evaluate((k) => chrome.storage.local.remove(k), keys); +} diff --git a/Bouncer/e2e/popup-settings.spec.ts b/Bouncer/e2e/popup-settings.spec.ts new file mode 100644 index 0000000..1faddc6 --- /dev/null +++ b/Bouncer/e2e/popup-settings.spec.ts @@ -0,0 +1,112 @@ +import { test, expect, readStorage, openPopup, clearStorageKeys } from './fixtures'; + +// Keys these tests touch — reset after each so the dev profile isn't polluted. +const TOUCHED_KEYS = [ + 'filterReplies', + 'aiTextFilterExperimental', + 'aiTextFilterEnabled', + 'aiImageFilterEnabled', + 'aiTextDetectionThreshold', + 'anthropicApiKey', + 'authErrorApis', +]; + +test.describe('Popup settings', () => { + test.afterEach(async ({ context }) => { + await clearStorageKeys(context, TOUCHED_KEYS); + }); + + test('"filter replies" toggle persists to storage', async ({ context, extensionId }) => { + const page = await openPopup(context, extensionId); + const toggle = page.locator('#enableFilterReplies'); + + await toggle.uncheck(); + await expect + .poll(async () => (await readStorage(context)).filterReplies, { timeout: 10_000 }) + .toBe(false); + + await toggle.check(); + await expect + .poll(async () => (await readStorage(context)).filterReplies, { timeout: 10_000 }) + .toBe(true); + }); + + test('enabling the experimental AI-text filter reveals its section and persists', async ({ + context, + extensionId, + }) => { + const page = await openPopup(context, extensionId); + + await page.locator('#enableAiTextExperimental').check(); + + await expect(page.locator('#aiTextFilterExperimentalContent')).toBeVisible(); + await expect + .poll(async () => (await readStorage(context)).aiTextFilterExperimental, { timeout: 10_000 }) + .toBe(true); + }); + + test('the AI-text threshold slider updates its label and persists', async ({ + context, + extensionId, + }) => { + const page = await openPopup(context, extensionId); + await page.locator('#enableAiTextExperimental').check(); + + const slider = page.locator('#aiTextThreshold'); + await expect(slider).toBeVisible(); + // range inputs can't be .fill()'d — set the value and fire input+change. + await slider.evaluate((el: HTMLInputElement) => { + el.value = '0.9'; + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }); + + await expect(page.locator('#aiTextThresholdValue')).toHaveText('90%'); + await expect + .poll(async () => (await readStorage(context)).aiTextDetectionThreshold, { timeout: 10_000 }) + .toBeCloseTo(0.9, 2); + }); + + test('BYOK: a valid Anthropic key enables the provider', async ({ context, extensionId }) => { + // Mock the verification request so no real key/network is needed. + await context.route('https://api.anthropic.com/v1/messages', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ content: [{ type: 'text', text: 'ok' }] }), + }) + ); + + const page = await openPopup(context, extensionId); + await page.locator('.api-provider-header[data-provider="anthropic"]').click(); // expand accordion + await page.locator('#anthropicApiKey').fill('sk-ant-e2e-test-key'); + await page.locator('#anthropicEnableBtn').click(); + + await expect(page.locator('#anthropicEnabled')).toBeVisible({ timeout: 10_000 }); + await expect + .poll(async () => (await readStorage(context)).anthropicApiKey, { timeout: 10_000 }) + .toBe('sk-ant-e2e-test-key'); + }); + + test('BYOK: an invalid Anthropic key shows an error and does not enable', async ({ + context, + extensionId, + }) => { + await context.route('https://api.anthropic.com/v1/messages', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: { message: 'invalid x-api-key' } }), + }) + ); + + const page = await openPopup(context, extensionId); + await page.locator('.api-provider-header[data-provider="anthropic"]').click(); + await page.locator('#anthropicApiKey').fill('sk-ant-bogus'); + await page.locator('#anthropicEnableBtn').click(); + + await expect(page.locator('#anthropicError')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('#anthropicEnabled')).toBeHidden(); + expect((await readStorage(context)).anthropicApiKey).toBeUndefined(); + }); +}); diff --git a/Bouncer/e2e/save-auth.setup.ts b/Bouncer/e2e/save-auth.setup.ts new file mode 100644 index 0000000..0eb5db5 --- /dev/null +++ b/Bouncer/e2e/save-auth.setup.ts @@ -0,0 +1,42 @@ +import { test, expect } from './fixtures'; + +/** + * ONE-TIME, INTERACTIVE login. Run with: npm run test:e2e:login + * + * Opens a single x.com tab and pauses. While paused, YOU manually: + * 1. Log in to your (throwaway) X account. + * 2. In the feed, click the Bouncer "Settings" gear → "Activate Bouncer" and + * complete Google sign-in. (The in-feed settings load the same popup UI, + * so there's no separate popup tab to deal with.) + * Then press ▶ Resume in the Playwright Inspector (or close it). + * + * Both sessions are written into the persistent profile (e2e/.userdata) by + * Chrome itself, so every later `npm run test:e2e` run starts already + * authenticated. Re-run this whenever a session expires. + */ +test('interactive login — sign in to X and to the extension by hand', async ({ context }) => { + test.setTimeout(0); // no timeout — wait for the human + + // The extension opens an x.com tab itself on first install (onInstalled in + // src/background/index.ts). Reuse that tab if it's already there or about to + // appear; otherwise reuse the blank starting tab. Avoids piling up tabs. + let x = + context.pages().find((p) => p.url().includes('x.com')) ?? + (await context.waitForEvent('page', { timeout: 5_000 }).catch(() => null)); + if (!x) x = context.pages()[0] ?? (await context.newPage()); + if (!x.url().includes('x.com')) await x.goto('https://x.com/login'); + + console.log( + '\n >>> Log in to X, then open the in-feed Bouncer Settings → Activate Bouncer.' + + '\n >>> When done, press Resume / close the Inspector.\n' + ); + await x.pause(); // opens the Inspector; resume when done + + // Sanity check (read-only): confirm the X login took by looking for the + // session cookie. The extension/Firebase session can't be read this simply; + // it persists in the profile regardless and is only needed for tests that + // exercise live filtering. + const cookies = await context.cookies('https://x.com'); + const hasSession = cookies.some((c) => c.name === 'auth_token'); + expect(hasSession, 'No x.com auth_token cookie — X login did not complete').toBe(true); +}); diff --git a/Bouncer/e2e/tsconfig.json b/Bouncer/e2e/tsconfig.json new file mode 100644 index 0000000..63751e2 --- /dev/null +++ b/Bouncer/e2e/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["chrome", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/Bouncer/e2e/x-feed.spec.ts b/Bouncer/e2e/x-feed.spec.ts new file mode 100644 index 0000000..ed031a7 --- /dev/null +++ b/Bouncer/e2e/x-feed.spec.ts @@ -0,0 +1,43 @@ +import { test, expect, readStorage, openFeed, waitForFilterBar } from './fixtures'; + +const DESCRIPTIONS_KEY = 'descriptions_twitter'; +const TEST_PHRASE = 'e2e-test-crypto-spam'; + +test.describe('Bouncer on the real X feed', () => { + test.beforeEach(async ({ context }) => { + // Start each test from a clean filter list so runs are idempotent. + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent('serviceworker'); + await sw.evaluate((key) => chrome.storage.local.remove(key), DESCRIPTIONS_KEY); + }); + + test('content script injects the filter bar on the home feed', async ({ context }) => { + const page = await openFeed(context); + + // The filter input only renders (vs. a sign-in prompt) when the extension + // is signed in, and only after X's feed has loaded — so its visibility is a + // sufficient signal. If this times out, the saved session likely expired — + // re-run `npm run test:e2e:login`. + await waitForFilterBar(page); + }); + + test('typing a filter phrase persists it to chrome.storage', async ({ context }) => { + const page = await openFeed(context); + + const input = await waitForFilterBar(page); + + await input.click(); + await input.fill(TEST_PHRASE); + await input.press('Enter'); + + await expect + .poll( + async () => { + const all = await readStorage(context); + return (all[DESCRIPTIONS_KEY] as string[] | undefined) ?? []; + }, + { timeout: 10_000 } + ) + .toContain(TEST_PHRASE); + }); +}); diff --git a/Bouncer/package-lock.json b/Bouncer/package-lock.json index b92224c..42b44cd 100644 --- a/Bouncer/package-lock.json +++ b/Bouncer/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", "@types/chrome": "^0.1.37", "@types/node": "^25.5.0", "esbuild": "^0.20.0", @@ -1298,6 +1299,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -3164,6 +3181,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/Bouncer/package.json b/Bouncer/package.json index 2b41e4b..3a7da49 100644 --- a/Bouncer/package.json +++ b/Bouncer/package.json @@ -15,7 +15,10 @@ "watch:dev": "node build.js --watch --dev", "test": "vitest run", "test:watch": "vitest", - "lint": "eslint . && tsc --noEmit", + "test:e2e:login": "npm run build && playwright test --project=login", + "test:e2e": "npm run build && playwright test --project=x", + "test:e2e:headless": "npm run build && HEADLESS=1 playwright test --project=x", + "lint": "eslint . && tsc --noEmit && tsc --noEmit -p e2e/tsconfig.json", "lint:fix": "eslint . --fix", "cut-chrome": "node cut.js", "cut-chrome:same": "node cut.js --same-version", @@ -25,6 +28,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.60.0", "@types/chrome": "^0.1.37", "@types/node": "^25.5.0", "esbuild": "^0.20.0", diff --git a/Bouncer/playwright.config.ts b/Bouncer/playwright.config.ts new file mode 100644 index 0000000..cb94360 --- /dev/null +++ b/Bouncer/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from '@playwright/test'; + +/** + * E2E tests run against the REAL x.com using a persistent Chrome profile that + * holds both the X session and the extension's Firebase session. See e2e/README.md. + * + * Persistent contexts can't be parallelized safely, so workers is pinned to 1. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [['list'], ['html', { open: 'never' }]], + timeout: 90_000, + expect: { timeout: 10_000 }, + projects: [ + { + // Interactive, one-time manual login. Run via `npm run test:e2e:login`. + // Never retry — it waits on a human. + name: 'login', + testMatch: /save-auth\.setup\.ts/, + retries: 0, + }, + { + // The actual test suite. Run via `npm run test:e2e`. Retries absorb live + // X's load-timing flakiness (slow timeline render, occasional + // interstitials) — the tests themselves are deterministic. + name: 'x', + testMatch: /.*\.spec\.ts/, + retries: 2, + }, + ], +}); From e02474cf98481574981ee8a10f5641b9b4bc9c43 Mon Sep 17 00:00:00 2001 From: Darren Jia Date: Mon, 1 Jun 2026 17:50:35 -0700 Subject: [PATCH 2/2] exclude playwright from vitest --- Bouncer/vitest.config.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Bouncer/vitest.config.js b/Bouncer/vitest.config.js index f5d2405..373fdfd 100644 --- a/Bouncer/vitest.config.js +++ b/Bouncer/vitest.config.js @@ -5,5 +5,10 @@ export default defineConfig({ globals: true, environment: 'node', setupFiles: ['./tests/setup.ts'], + // Scope to the unit tests. Without this, vitest's default glob also matches + // the Playwright e2e specs (e2e/**/*.spec.ts), which import @playwright/test + // and fail with "test.describe() … not expected here". Playwright runs those + // separately via `npm run test:e2e`. + include: ['tests/**/*.{test,spec}.ts'], }, });