Browser E2E Tests (Playwright)
SkillWeb & browsingAuthoring browser end-to-end tests for the Composer app with Playwright. Use when writing, editing, or reviewing *.spec.ts under packages/apps/composer-app/src/playwright, adding page-object helpers, or deciding how to target elements (always data-testid, never labels/roles).
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Browser E2E Tests (Playwright) skill
What this skill tells your AI
The instructions your AI receives, as published by dxos/dxos in .agents/skills/browser-e2e-tests/SKILL.md and read by ahel’s review.
Browser e2e drives the real Composer app in a browser — the only tier that verifies perceived
behavior: interactivity during async work, no false empty states, and full click→render flows.
Distinct from agent-eval-tests (the LLM agent harness) and storybook interaction tests.
Location: packages/apps/composer-app/src/playwright/ — *.spec.ts specs, root page-objects
(app-manager.ts), and per-plugin helpers under plugins/ (re-exported from plugins/index.ts).
Golden rule: target by data-testid, never by label, text, or role-name
Labels and visible text come from translations.ts and change with copy/i18n; role-names are
ambiguous when several controls share a name. data-testid is the only stable selector.
- Do:
page.getByTestId('inbox.message.reply'). - Don't:
getByRole('button', { name: 'Reply' }),getByText('Send'),getByLabelText('To'). - If the element has no testid, add one to the component as part of writing the test. A missing testid is a source gap to fix, not a reason to fall back to a label. Never let a translated string become a selector.
How to add testids
- Plain elements / primitives: pass
data-testid. Many@dxos/react-uiprimitives andForm.Root/Form.Submitforward it (e.g.Form.Submitrendersdata-testid='save-button'). - Menu & toolbar actions (
@dxos/react-ui-menu): the toolbar emitsdata-testidonly when the action setsaction.properties.testId— alabel/iconalone produces no testid. Addproperties: { testId: 'inbox.message.reply' }to the action spec; do not target the menu label. - Naming: dot-namespaced
plugin.area.element, matching existing ids (spacePlugin.object,deck.plank,create-object-form). E.g.inbox.mailbox.row,inbox.message.header,inbox.draft.send.
Selector priority (only when a testid genuinely can't exist yet)
data-testid— the default; add it if missing.- Framework/ARIA state that encodes behavior, not copy:
aria-selected,aria-current, ids/ classes the framework sets (e.g. a Mosaic tile'sid,dx-current/dx-selected). - Role — only scoped inside a testid'd container, never role + translated name as the primary hook.
Visible text/labels are for assertions about content, never for locating controls.
Spec structure
Follow the existing shape (basic.spec.ts):
import { expect, test } from '@playwright/test';
import { AppManager } from './app-manager';
// The PWA service worker breaks routing/interception; require it disabled.
if (process.env.DX_PWA !== 'false') {
throw new Error('run with DX_PWA=false');
}
test.describe('Inbox', () => {
let host: AppManager;
test.beforeEach(async ({ browser }) => {
host = new AppManager(browser, false);
await host.init();
});
test.afterEach(async () => {
await host.closePage();
});
test('selecting a thread opens the companion', async () => {
// Drive via page objects + testids — no inline selectors, no labels.
});
});
Page objects
Every interaction lives behind a page-object so specs read as intent, not selectors.
- Reuse
AppManager:init()(boots, waits for the auto-created identity),createSpace(),createObject({ type: '<Typename label>' })(picks the type by its typename label, e.g.'Mailbox'),enablePlugin('org.dxos.plugin.<x>')(via the/!dxos:plugin-registryroute),deck.plank(nth). - Add a per-plugin helper under
plugins/(e.g.plugins/inbox.tsexporting anInboxpage-object) and re-export fromplugins/index.ts. Keep all selectors inside the helper.
Running
DX_PWA=false moon run composer-app:e2e— configsrc/playwright/playwright.config.ts(e2ePreset,vite previewon port 4173, pre-built bundle).PLAYWRIGHT_BROWSER=chromium|firefox|webkit|allselects projects; many tests are chromium-only viatest.skip(browserName !== 'chromium').- CI: the
Checke2ejob runs only on main/release orworkflow_dispatch e2e=true. DX_HARNESS_THROTTLED=1enables the startup harness's throttled cold start (chromium-only, CDP). Its profile defaults to Fast 3G + 2x CPU and each field is overridable viaDX_HARNESS_LATENCY_MS,DX_HARNESS_DOWN_MBPS,DX_HARNESS_UP_KBPS,DX_HARNESS_CPU(throttleProfileinharness-helpers.ts). Override when the default cannot reach ready insidewaitForReady.
Waiting & stability
- No
page.waitForTimeout/sleep. Use auto-retrying web-first assertions (await expect(locator).toBeVisible()),locator.waitFor(), and assert on framework state (aria-selected) rather than screenshots or copy where behavior is the thing under test.
Data setup
- Prefer driving the real UI (
createSpace→createObject→ interact) so the test exercises production code paths. - When a flow can't be driven from the UI (e.g. OAuth login), use a documented, dev/e2e-gated test bridge — never live credentials, never real network to third parties. Mock external HTTP.
Anti-patterns
| Don't | Do |
|---|---|
getByRole('button', { name: 'Reply' }) | add properties.testId → getByTestId('inbox.message.reply') |
getByText('Send') / getByLabelText('To') | testid on the field/control |
| Fall back to a label when the testid is missing | add the testid to the component |
page.waitForTimeout(1000) | expect(locator).toBeVisible() / waitFor() |
| Inline selectors scattered across a spec | a page-object helper under plugins/ |
| Live provider credentials / real third-party network | mock + a gated test bridge; DX_PWA=false |
Signals
- GitHub stars
- 520
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
browser-e2e-tests- Source
- github.com/dxos/dxos