extrig
v0.2.0
Published
The only way to reliably test browser extensions without pain
Downloads
309
Maintainers
Readme
Why Extrig?
Testing Chrome extensions is painful. launchPersistentContext boilerplate, dynamic extension IDs, service worker suspension flakiness in CI, manual chrome.* API stubs that return undefined — every developer hits these walls.
Extrig solves all of them in one package.
pnpm add -D extrig @playwright/test vitest
npx extrig init
npx extrig test --unit # ⚡ fast — no browser
npx extrig test --e2e # 🌐 real ChromeFeatures
- Stateful Chrome API mocking —
chrome.storage.local.get()actually returns stored values across calls.chrome.runtime.sendMessage()routes to registered listeners.chrome.alarmsuses real timers. Not thin stubs — real in-memory implementations. - Zero-config Playwright fixtures — Never write
launchPersistentContextagain.extContext,extensionId,sw,openPopup(),openOptions()— all auto-wired. - Flakiness-free service worker handling —
waitForSWReady()polls the SW before proceeding.forceSWTermination()+waitForSWWake()test the suspend/resume cycle reliably. - Shadow DOM helpers —
shadowLocator(['host', '#inner'])pierces shadow roots without manualevaluate()gymnastics. - Network interception —
mockNetwork()andcaptureNetwork()on the context level, including SW-initiated requests. - Built-in flakiness detection — Tracks every test result across runs, surfaces flaky tests automatically.
- Beautiful terminal UI — Built on Ink v5, with live elapsed time, SW status indicator, and per-test timings.
- VS Code integration — Run tests inline with CodeLens, see results in the Test Explorer sidebar, get inline error diagnostics.
Quick start
1. Install
pnpm add -D extrig @playwright/test vitest2. Initialize
npx extrig initThis auto-detects your extension framework (WXT, Plasmo, or generic), then creates:
| File | Purpose |
|---|---|
| extrig.config.ts | Extrig configuration |
| vitest.config.ts | Unit test runner (with chrome mock auto-setup) |
| playwright.config.ts | E2E test runner |
| tests/unit/ | Example unit test |
| tests/e2e/ | Example E2E test |
| .github/workflows/test.yml | CI pipeline |
3. Run
npx extrig test --unit # Unit tests only (no browser, instant)
npx extrig test --e2e # E2E tests only (real Chrome)
npx extrig test # Both
npx extrig watch # Watch mode
npx extrig ci # CI-optimized runUnit tests (no browser)
Extrig provides stateful, in-memory Chrome API mocks. They behave like the real APIs:
// tests/unit/background.test.ts
import { describe, it, expect } from 'vitest';
import { mockRuntime, mockStorage, mockAlarms } from 'extrig/mock';
describe('background', () => {
it('responds to PING', async () => {
mockRuntime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'PING') sendResponse({ pong: true });
});
const result = await mockRuntime.sendMessage({ type: 'PING' });
expect(result).toEqual({ pong: true });
});
it('stores and retrieves data', async () => {
await mockStorage.local.set({ user: { name: 'Alice' } });
const data = await mockStorage.local.get('user');
expect(data).toEqual({ user: { name: 'Alice' } });
});
it('fires alarms instantly with _fire()', async () => {
const fired = [];
mockAlarms.onAlarm.addListener(a => fired.push(a.name));
await mockAlarms.create('sync', { periodInMinutes: 30 });
await mockAlarms._fire('sync'); // ⚡ no real timer needed
expect(fired).toContain('sync');
});
it('throws when no listener is registered', async () => {
await expect(mockRuntime.sendMessage({ type: 'UNKNOWN' }))
.rejects.toThrow('Could not establish connection');
});
});Available mocks
| Module | Import | What it mocks |
|---|---|---|
| Storage | extrig/mock → mockStorage | local, sync, session, managed areas with real Map storage |
| Runtime | extrig/mock → mockRuntime | sendMessage, connect, onMessage, onInstalled, getURL |
| Tabs | extrig/mock → mockTabs | query, create, update, remove with tab store and events |
| Alarms | extrig/mock → mockAlarms | create, get, clear, _fire() for instant triggering |
| Action | extrig/mock → mockAction | setBadgeText, setTitle, enable/disable, onClicked |
E2E tests (real Chrome)
E2E tests use Playwright with automatic extension loading:
// tests/e2e/popup.test.ts
import { test, expect } from 'extrig/fixtures';
test('popup opens without errors', async ({ openPopup }) => {
const popup = await openPopup();
await expect(popup.locator('body')).not.toHaveText(/error/i);
});
test('loads data from storage', async ({ openPopup, seedStorage }) => {
await seedStorage('local', { userName: 'Alice' });
const popup = await openPopup();
await expect(popup.locator('#username')).toHaveText('Alice');
});
test('survives service worker termination', async ({
openPopup, forceSWTermination, waitForSWWake
}) => {
await openPopup();
await forceSWTermination(); // kill SW via CDP
await waitForSWWake(15_000); // wait for restart
// Extension is still functional
});
test('network requests can be mocked', async ({ openPage, mockNetwork }) => {
const unmock = await mockNetwork({
url: 'https://api.example.com/**',
response: { status: 200, body: { mocked: true } },
});
const page = await openPage('popup.html');
await expect(page.locator('body')).toBeVisible();
await unmock();
});Available fixtures
| Fixture | Type | Description |
|---|---|---|
| extContext | BrowserContext | The persistent Chromium context with extension loaded |
| extensionId | string | The extension's 32-character ID |
| sw | Worker | The extension's service worker |
| openPopup() | () => Page | Opens popup.html |
| openOptions() | () => Page | Opens options.html |
| openSidePanel() | () => Page | Opens sidepanel.html |
| openPage(path) | (path) => Page | Opens any extension page |
| waitForSWReady() | (ms?) => Worker | Polls SW until responsive |
| forceSWTermination() | () => void | Kills SW via CDP |
| waitForSWWake() | (ms?) => Worker | Waits for SW restart |
| seedStorage(area, data) | (area, data) => void | Pre-populates extension storage |
| getStorage(area, keys) | (area, keys) => data | Reads extension storage |
| assertStorage(area, data) | (area, data) => void | Asserts storage matches |
| sendMessage(msg) | (msg) => response | Sends message to SW |
| captureMessages() | () => { messages } | Captures all incoming messages |
| mockNetwork(opts) | (opts) => unmock | Intercepts network requests |
| captureNetwork(pattern) | (pat) => { requests } | Spies on network requests |
| shadowLocator(page, c) | (page, c[]) => Loc | Pierces shadow DOM |
What Extrig solves
| Problem | Without Extrig | With Extrig |
|---|---|---|
| Chrome API mocking | Manual stubs returning undefined | Stateful in-memory implementations |
| SW suspension flakiness | Random 30s timeouts in CI | waitForSWReady() + auto-retry |
| Dynamic extension ID | Breaks between launches | Auto-detected from SW URL |
| Headless Chrome in CI | Xvfb + 15 flags + trial and error | channel: 'chromium' auto-configured |
| Shadow DOM | Playwright can't pierce shadow roots | shadowLocator(['host', '#inner']) |
| Alarm testing | setTimeout(3600000) in tests | mockAlarms._fire('alarmName') |
| Flaky test detection | Grep through 200 CI log lines | Built-in flakiness score tracking |
| Extension launch boilerplate | 40+ lines of launchPersistentContext | One fixture: extContext |
CI
# .github/workflows/test.yml (auto-generated by `extrig init`)
name: Extension Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'pnpm' }
- run: pnpm install
- run: pnpm build
- run: pnpm exec playwright install chromium --with-deps
- run: pnpm extrig test --unit
- run: pnpm extrig test --e2e
env: { CI: true }Configuration
extrig.config.ts:
import { defineConfig } from 'extrig';
export default defineConfig({
extensionPath: 'dist', // path to built extension
headless: true, // run Chrome headless (default in CI)
retries: 2, // retry failed tests
workers: 1, // Playwright workers (extensions need 1)
viewport: { width: 1280, height: 720 },
launchTimeout: 60_000,
swTimeout: 30_000,
extraBrowserArgs: [], // extra Chrome flags
testMatch: ['tests/e2e/**/*.test.ts'],
unitTestMatch: ['tests/unit/**/*.test.ts'],
reporter: ['terminal', 'json'], // 'html', 'studio' also available
studio: { // Extrig Studio (Pro)
projectId: 'your-project-id',
apiKey: 'your-api-key',
},
});Extrig Studio (Pro)
Track every test run, eliminate flakiness, and ship with confidence. Learn more →
Packages in this ecosystem
| Package | npm | Purpose |
|---|---|---|
| extrig | | Core CLI + test runner |
|
extrig-vscode | VS Code Marketplace | VS Code extension |
License
MIT © SRoyLabs
