diff --git a/e2e/browser-mode/fixtures/locator-api/rstest.config.ts b/e2e/browser-mode/fixtures/locator-api/rstest.config.ts new file mode 100644 index 000000000..aa2c7f21a --- /dev/null +++ b/e2e/browser-mode/fixtures/locator-api/rstest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core'; +import { BROWSER_PORTS } from '../ports'; + +export default defineConfig({ + browser: { + enabled: true, + provider: 'playwright', + headless: true, + port: BROWSER_PORTS['locator-api'], + }, + include: ['tests/**/*.test.ts'], + testTimeout: 30000, +}); diff --git a/e2e/browser-mode/fixtures/locator-api/tests/locatorApi.test.ts b/e2e/browser-mode/fixtures/locator-api/tests/locatorApi.test.ts new file mode 100644 index 000000000..4fc32083d --- /dev/null +++ b/e2e/browser-mode/fixtures/locator-api/tests/locatorApi.test.ts @@ -0,0 +1,129 @@ +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +function createCounter(initial = 0) { + let count = initial; + + const container = document.createElement('div'); + const display = document.createElement('p'); + const incBtn = document.createElement('button'); + + display.textContent = `Count: ${count}`; + incBtn.textContent = 'Increment'; + incBtn.id = 'inc-btn'; + + incBtn.addEventListener('click', () => { + count++; + display.textContent = `Count: ${count}`; + }); + + container.append(display, incBtn); + return container; +} + +test('locator + expect.element proxy works', async () => { + document.body.appendChild(createCounter(5)); + + const nameInput = document.createElement('input'); + nameInput.placeholder = 'Name'; + + const emailInput = document.createElement('input'); + emailInput.placeholder = 'Email'; + + document.body.append(nameInput, emailInput); + + const sections = document.createElement('div'); + sections.innerHTML = ` +
+

Home

+ +
+
+

Profile

+ +
+ `; + document.body.append(sections); + + await expect.element(page.getByText('Count: 5')).toBeVisible(); + await page.getByRole('button', { name: 'Increment' }).click(); + await expect.element(page.getByText('Count: 6')).toBeVisible(); + await expect.element(page.getByText('Count: 6')).toHaveText('Count: 6'); + await expect + .element(page.getByRole('button', { name: 'Increment' })) + .toHaveId('inc-btn'); + + await page.getByPlaceholder('Name').fill('Alice'); + await expect.element(page.getByPlaceholder('Name')).toHaveValue('Alice'); + + await page.getByPlaceholder('Email').fill('a@b.com'); + await expect.element(page.getByPlaceholder('Email')).toHaveValue('a@b.com'); + + // filter({ has }) + const profileSave = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Profile' }) }) + .getByRole('button', { name: 'Save' }); + await expect.element(profileSave).toHaveCount(1, { timeout: 1000 }); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = 'agree'; + + const label = document.createElement('label'); + label.htmlFor = 'agree'; + label.textContent = 'Agree'; + + document.body.append(checkbox, label); + + await expect.element(page.getByLabel('Agree')).toBeUnchecked(); + await page.getByLabel('Agree').check(); + await expect.element(page.getByLabel('Agree')).toBeChecked(); + await expect.element(page.getByLabel('Agree')).not.toBeUnchecked(); + await page.getByLabel('Agree').uncheck(); + await expect.element(page.getByLabel('Agree')).toBeUnchecked(); + await expect.element(page.getByLabel('Agree')).not.toBeChecked(); + + await page.getByLabel('Agree').focus(); + await expect.element(page.getByLabel('Agree')).toBeFocused(); + + // selectOption + const selectLabel = document.createElement('label'); + selectLabel.htmlFor = 'choice'; + selectLabel.textContent = 'Choice'; + + const select = document.createElement('select'); + select.id = 'choice'; + select.innerHTML = ` + + + `; + document.body.append(selectLabel, select); + await page.getByLabel('Choice').selectOption('b'); + await expect.element(page.getByLabel('Choice')).toHaveValue('b'); + + // toBeAttached/toBeDetached + const later = document.createElement('div'); + later.id = 'later'; + setTimeout(() => document.body.appendChild(later), 50); + await expect.element(page.locator('#later')).toBeAttached(); + later.remove(); + await expect.element(page.locator('#later')).toBeDetached(); + + // dispatchEvent + const evBtn = document.createElement('button'); + evBtn.textContent = 'Event'; + evBtn.addEventListener('custom', () => { + evBtn.textContent = 'Event:OK'; + }); + document.body.appendChild(evBtn); + await page.getByRole('button', { name: 'Event' }).dispatchEvent('custom'); + await expect.element(page.getByText('Event:OK')).toBeVisible(); + + // and/or composition + const incBtn = page.getByRole('button', { name: 'Increment' }); + await expect.element(incBtn.and(page.locator('#inc-btn'))).toHaveCount(1); + await expect + .element(page.getByPlaceholder('Name').or(page.getByPlaceholder('Email'))) + .toHaveCount(2); +}); diff --git a/e2e/browser-mode/fixtures/multi-project-config/project-a/rstest.config.ts b/e2e/browser-mode/fixtures/multi-project-config/project-a/rstest.config.ts new file mode 100644 index 000000000..7292d5a9f --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/project-a/rstest.config.ts @@ -0,0 +1,12 @@ +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-a', + plugins: [pluginReact()], + include: ['tests/**/*.test.tsx'], + browser: { + enabled: true, + provider: 'playwright', + }, +}); diff --git a/e2e/browser-mode/fixtures/multi-project-config/project-a/tests/jsxRuntime.test.tsx b/e2e/browser-mode/fixtures/multi-project-config/project-a/tests/jsxRuntime.test.tsx new file mode 100644 index 000000000..4775eccca --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/project-a/tests/jsxRuntime.test.tsx @@ -0,0 +1,6 @@ +import { expect, test } from '@rstest/core'; + +test('jsx runtime works without React in scope', () => { + const element = hello; + expect(element.props.children).toBe('hello'); +}); diff --git a/e2e/browser-mode/fixtures/multi-project-config/project-a/tsconfig.json b/e2e/browser-mode/fixtures/multi-project-config/project-a/tsconfig.json new file mode 100644 index 000000000..671ad7782 --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/project-a/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["@rstest/core"] + }, + "include": ["tests", "rstest.config.ts"] +} diff --git a/e2e/browser-mode/fixtures/multi-project-config/project-b/rstest.config.ts b/e2e/browser-mode/fixtures/multi-project-config/project-b/rstest.config.ts new file mode 100644 index 000000000..7fa1624a8 --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/project-b/rstest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-b', + include: ['tests/**/*.test.ts'], + browser: { + enabled: true, + provider: 'playwright', + }, +}); diff --git a/e2e/browser-mode/fixtures/multi-project-config/project-b/tests/smoke.test.ts b/e2e/browser-mode/fixtures/multi-project-config/project-b/tests/smoke.test.ts new file mode 100644 index 000000000..cbe9a0f41 --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/project-b/tests/smoke.test.ts @@ -0,0 +1,5 @@ +import { expect, test } from '@rstest/core'; + +test('smoke', () => { + expect(1 + 1).toBe(2); +}); diff --git a/e2e/browser-mode/fixtures/multi-project-config/project-b/tsconfig.json b/e2e/browser-mode/fixtures/multi-project-config/project-b/tsconfig.json new file mode 100644 index 000000000..1fe947995 --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/project-b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "module": "ESNext", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "moduleResolution": "bundler", + "types": ["@rstest/core"] + }, + "include": ["tests", "rstest.config.ts"] +} diff --git a/e2e/browser-mode/fixtures/multi-project-config/rstest.config.ts b/e2e/browser-mode/fixtures/multi-project-config/rstest.config.ts new file mode 100644 index 000000000..72cef633d --- /dev/null +++ b/e2e/browser-mode/fixtures/multi-project-config/rstest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + projects: ['./project-b/rstest.config.ts', './project-a/rstest.config.ts'], +}); diff --git a/e2e/browser-mode/fixtures/ports.ts b/e2e/browser-mode/fixtures/ports.ts index a60767b62..2cc171db9 100644 --- a/e2e/browser-mode/fixtures/ports.ts +++ b/e2e/browser-mode/fixtures/ports.ts @@ -1,6 +1,13 @@ +/** + * Keep every fixture port unique. + * + * Browser-mode e2e tests can run concurrently in a single process. Reusing a port + * across fixtures can cause flaky "EADDRINUSE" failures. + */ export const BROWSER_PORTS = { basic: 5180, 'browser-react': 5202, + 'locator-api': 5226, list: 5204, 'no-tests': 5206, 'entry-override': 5208, @@ -22,3 +29,11 @@ export const BROWSER_PORTS = { 'reporter-watch': 5222, 'github-actions': 5224, } as const; + +const browserPortValues = Object.values(BROWSER_PORTS); + +if (new Set(browserPortValues).size !== browserPortValues.length) { + throw new Error( + `Duplicate browser fixture ports detected: ${JSON.stringify(BROWSER_PORTS)}`, + ); +} diff --git a/e2e/browser-mode/locatorApi.test.ts b/e2e/browser-mode/locatorApi.test.ts new file mode 100644 index 000000000..faa2dc0cc --- /dev/null +++ b/e2e/browser-mode/locatorApi.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from '@rstest/core'; +import { runBrowserCli } from './utils'; + +describe('browser mode - locator api', () => { + it('should run locator API tests correctly', async () => { + const { expectExecSuccess, cli } = await runBrowserCli('locator-api'); + + await expectExecSuccess(); + expect(cli.stdout).toMatch(/Tests.*passed/); + }); + + it('should run locator API tests in headed mode without scheduler page', async () => { + const { expectExecSuccess, cli } = await runBrowserCli('locator-api', { + args: ['--browser.headless', 'false'], + }); + + await expectExecSuccess(); + expect(cli.stdout).toMatch(/Tests.*passed/); + expect(cli.stdout).not.toContain('/scheduler.html'); + }); +}); diff --git a/e2e/browser-mode/multiProjectConfig.test.ts b/e2e/browser-mode/multiProjectConfig.test.ts new file mode 100644 index 000000000..a905bd802 --- /dev/null +++ b/e2e/browser-mode/multiProjectConfig.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from '@rstest/core'; +import { runBrowserCli } from './utils'; + +/** + * Regression test: in multi-project browser mode, each project must compile with + * its own config instead of inheriting config from the first browser project. + */ +describe('browser mode - multi project config isolation', () => { + it('should apply each browser project config independently', async () => { + const { expectExecSuccess, cli } = await runBrowserCli( + 'multi-project-config', + { + args: ['project-a/tests/jsxRuntime.test.tsx'], + }, + ); + + await expectExecSuccess(); + expect(cli.stdout).toContain('jsxRuntime.test.tsx'); + expect(cli.stdout).toMatch(/Tests.*passed/); + }); +}); diff --git a/e2e/browser-mode/viewport.test.ts b/e2e/browser-mode/viewport.test.ts index 0ae590c85..4ac5ea261 100644 --- a/e2e/browser-mode/viewport.test.ts +++ b/e2e/browser-mode/viewport.test.ts @@ -13,4 +13,14 @@ describe('browser mode - viewport', () => { await expectExecSuccess(); expect(cli.stdout).toMatch(/Tests.*passed/); }); + + it('should apply viewport config in headed mode without scheduler page', async () => { + const { expectExecSuccess, cli } = await runBrowserCli('viewport', { + args: ['--browser.headless', 'false'], + }); + + await expectExecSuccess(); + expect(cli.stdout).toMatch(/Tests.*passed/); + expect(cli.stdout).not.toContain('/scheduler.html'); + }); }); diff --git a/e2e/rstest.config.ts b/e2e/rstest.config.ts index bb01ac605..ab7bc7386 100644 --- a/e2e/rstest.config.ts +++ b/e2e/rstest.config.ts @@ -13,7 +13,9 @@ export default defineConfig({ setupFiles: ['../scripts/rstest.setup.ts'], // Increased timeout for CI to handle slower environments (e.g., Node.js 22 on Windows) // and reduce flaky timeouts caused by resource contention under high parallelism. - testTimeout: process.env.CI ? 60_000 : 10_000, + // Use the same timeout locally because browser-mode e2e tests can exceed 15s when + // running together with the full suite under high machine load. + testTimeout: process.env.CI ? 60_000 : 30_000, slowTestThreshold: 2_000, // Stabilize date/time based e2e fixtures across different runner timezones. // Some fixtures use `new Date('YYYY-MM-DD')` (UTC parsing) but assert on local diff --git a/examples/browser-react/package.json b/examples/browser-react/package.json index 3e6e04667..e9ff021f2 100644 --- a/examples/browser-react/package.json +++ b/examples/browser-react/package.json @@ -15,8 +15,6 @@ "@rstest/browser": "workspace:*", "@rstest/browser-react": "workspace:*", "@rstest/core": "workspace:*", - "@testing-library/dom": "^10.4.1", - "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "playwright": "^1.58.2", diff --git a/examples/browser-react/src/Counter.css b/examples/browser-react/src/Counter.css index c4fb59371..316ec1b39 100644 --- a/examples/browser-react/src/Counter.css +++ b/examples/browser-react/src/Counter.css @@ -1,11 +1,70 @@ -.count { - border: 3px dashed blue; - margin: 1em; - padding: 1em; +.product-card { + display: grid; + gap: 0.75rem; + max-width: 22rem; + margin: 1rem; + padding: 1rem; + border: 1px solid #d8dee8; + border-radius: 0.75rem; + background: linear-gradient(180deg, #fff 0%, #f7fafc 100%); } -.count-btn { - margin: 0 0.5em; - padding: 0.5em 1em; - font-size: 1em; +.product-badge { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + color: #2a4f7a; +} + +.product-title { + margin: 0; + font-size: 1.25rem; +} + +.control-select { + width: 6rem; + padding: 0.35rem 0.5rem; +} + +.control-row { + display: flex; + gap: 0.5rem; + align-items: center; + margin: 0; + padding: 0; + border: 0; +} + +.control-row > legend { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.count-btn, +.primary-btn { + padding: 0.45rem 0.8rem; + font-size: 0.95rem; + border-radius: 0.5rem; + border: 1px solid #c8d2df; + background: #fff; + cursor: pointer; +} + +.primary-btn { + border-color: #0053d6; + color: #fff; + background: #0053d6; +} + +.count-btn:disabled, +.primary-btn:disabled { + opacity: 0.45; + cursor: not-allowed; } diff --git a/examples/browser-react/src/Counter.tsx b/examples/browser-react/src/Counter.tsx index 6d2b2ea74..6e085508b 100644 --- a/examples/browser-react/src/Counter.tsx +++ b/examples/browser-react/src/Counter.tsx @@ -3,28 +3,87 @@ import './Counter.css'; interface CounterProps { initialCount?: number; + min?: number; + max?: number; } -export function Counter({ initialCount = 0 }: CounterProps) { +export function Counter({ initialCount = 0, min = 0, max = 5 }: CounterProps) { const [count, setCount] = useState(initialCount); + const [size, setSize] = useState('M'); + const [message, setMessage] = useState(''); + + const canDecrement = count > min; + const canIncrement = count < max; + const canAddToCart = count > 0; + + const increment = () => { + setCount((current) => { + return current < max ? current + 1 : current; + }); + }; + + const decrement = () => { + setCount((current) => { + return current > min ? current - 1 : current; + }); + }; + + const addToCart = () => { + setMessage(`Added ${count} item(s), size ${size}`); + }; return ( -
- {count} - + {count} + + + + + Selected quantity: {count} + + -
+ + {message ?

{message}

: null} + ); } diff --git a/examples/browser-react/tests/counter.test.tsx b/examples/browser-react/tests/counter.test.tsx index b34abe3c4..811fec04a 100644 --- a/examples/browser-react/tests/counter.test.tsx +++ b/examples/browser-react/tests/counter.test.tsx @@ -1,39 +1,66 @@ /** - * Basic React component testing example. + * Component-library style browser testing example. * * Demonstrates: * - Rendering components with `render` from @rstest/browser-react - * - Querying DOM elements with @testing-library/dom - * - Simulating user interactions with @testing-library/user-event + * - Querying elements with Playwright-style Locator API (`page.getBy*`) + * - Interacting with form controls (`click`, `selectOption`) + * - Web-first assertions through `expect.element(locator)` */ +import { page } from '@rstest/browser'; import { render } from '@rstest/browser-react'; import { describe, expect, test } from '@rstest/core'; -import { getByRole, getByTestId } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; import { Counter } from '../src/Counter'; describe('Counter', () => { - test('renders with initial count', async () => { - const { container } = await render(); + test('renders product controls with default state', async () => { + await render(); - expect(getByTestId(container, 'count').textContent).toBe('0'); + await expect + .element(page.getByRole('heading', { name: 'Soft Hoodie' })) + .toBeVisible(); + await expect.element(page.getByLabel('count')).toHaveText('0'); + await expect + .element(page.getByRole('button', { name: 'Decrease' })) + .toBeDisabled(); + await expect + .element(page.getByRole('button', { name: 'Add to cart' })) + .toBeDisabled(); }); - test('increments count on button click', async () => { - const { container } = await render(); + test('updates quantity and allows adding to cart', async () => { + await render(); - const button = getByRole(container, 'button', { name: 'Increment' }); - await userEvent.click(button); + await page.getByRole('button', { name: 'Increase' }).click(); + await page.getByRole('button', { name: 'Increase' }).click(); + await page.getByLabel('Size').selectOption('L'); - expect(getByTestId(container, 'count').textContent).toBe('1'); + await expect.element(page.getByLabel('count')).toHaveText('2'); + await expect + .element(page.getByLabel('Selected quantity')) + .toHaveText('Selected quantity: 2'); + + await page.getByRole('button', { name: 'Add to cart' }).click(); + await expect + .element(page.getByRole('alert')) + .toHaveText('Added 2 item(s), size L'); }); - test('decrements count on button click', async () => { - const { container } = await render(); + test('respects max boundary and disables increase at max', async () => { + await render(); + + await expect + .element(page.getByRole('button', { name: 'Increase' })) + .toBeDisabled(); + + await page.getByRole('button', { name: 'Decrease' }).click(); + await expect.element(page.getByLabel('count')).toHaveText('4'); - const button = getByRole(container, 'button', { name: 'Decrement' }); - await userEvent.click(button); + await expect + .element(page.getByRole('button', { name: 'Increase' })) + .toBeEnabled(); + await page.getByRole('button', { name: 'Increase' }).click(); - expect(getByTestId(container, 'count').textContent).toBe('4'); + await expect.element(page.getByLabel('count')).toHaveText('5'); }); }); diff --git a/packages/browser-ui/src/core/browserRpc.test.ts b/packages/browser-ui/src/core/browserRpc.test.ts new file mode 100644 index 000000000..036bf6020 --- /dev/null +++ b/packages/browser-ui/src/core/browserRpc.test.ts @@ -0,0 +1,70 @@ +import { + DISPATCH_METHOD_RPC, + DISPATCH_NAMESPACE_BROWSER, +} from '@rstest/browser/protocol'; +import { describe, expect, it } from '@rstest/core'; +import { + createStaleBrowserRpcDispatchResponse, + isStaleBrowserRpcRequest, + readBrowserRpcRequest, +} from './browserRpc'; + +describe('browser rpc helpers', () => { + it('should read browser rpc payload from dispatch request', () => { + const request = readBrowserRpcRequest({ + requestId: 'dispatch-1', + namespace: DISPATCH_NAMESPACE_BROWSER, + method: DISPATCH_METHOD_RPC, + args: { + id: 'rpc-1', + testPath: '/tests/example.test.ts', + runId: 'run-1', + kind: 'locator', + locator: { steps: [] }, + method: 'click', + args: [], + }, + }); + + expect(request).toBeTruthy(); + expect(request?.testPath).toBe('/tests/example.test.ts'); + expect(request?.runId).toBe('run-1'); + }); + + it('should return null for non-browser dispatch requests', () => { + const request = readBrowserRpcRequest({ + requestId: 'dispatch-2', + namespace: 'snapshot', + method: 'readSnapshotFile', + args: { + filepath: '/tmp/a.snap', + }, + }); + + expect(request).toBeNull(); + }); + + it('should detect stale browser rpc request by runId', () => { + expect(isStaleBrowserRpcRequest({ runId: 'run-1' }, 'run-2')).toBe(true); + expect(isStaleBrowserRpcRequest({ runId: 'run-1' }, 'run-1')).toBe(false); + expect(isStaleBrowserRpcRequest({ runId: 'run-1' }, undefined)).toBe(true); + }); + + it('should create stale dispatch response envelope', () => { + const response = createStaleBrowserRpcDispatchResponse( + 'dispatch-3', + { + kind: 'expect', + method: 'toBeVisible', + testPath: '/tests/example.test.ts', + runId: 'run-old', + }, + 'run-new', + ); + + expect(response.requestId).toBe('dispatch-3'); + expect(response.stale).toBe(true); + expect(response.error).toContain('run-old'); + expect(response.error).toContain('run-new'); + }); +}); diff --git a/packages/browser-ui/src/core/browserRpc.ts b/packages/browser-ui/src/core/browserRpc.ts new file mode 100644 index 000000000..7d8ae5014 --- /dev/null +++ b/packages/browser-ui/src/core/browserRpc.ts @@ -0,0 +1,75 @@ +import { + DISPATCH_METHOD_RPC, + DISPATCH_NAMESPACE_BROWSER, +} from '@rstest/browser/protocol'; +import type { BrowserDispatchResponse, BrowserRpcRequest } from '../types'; + +export const canPostMessageSource = ( + source: MessageEventSource | null, +): source is Window => { + return ( + source !== null && typeof (source as Window).postMessage === 'function' + ); +}; + +const isObjectRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const hasString = (value: Record, key: string): boolean => { + return typeof value[key] === 'string'; +}; + +export const readBrowserRpcRequest = ( + value: unknown, +): BrowserRpcRequest | null => { + if (!isObjectRecord(value)) { + return null; + } + + if ( + value.namespace !== DISPATCH_NAMESPACE_BROWSER || + value.method !== DISPATCH_METHOD_RPC + ) { + return null; + } + + const args = value.args; + if (!isObjectRecord(args)) { + return null; + } + + if ( + !hasString(args, 'id') || + !hasString(args, 'kind') || + !hasString(args, 'method') || + !hasString(args, 'testPath') || + !hasString(args, 'runId') + ) { + return null; + } + + return args as BrowserRpcRequest; +}; + +export const isStaleBrowserRpcRequest = ( + request: Pick, + currentRunId?: string, +): boolean => { + return !currentRunId || request.runId !== currentRunId; +}; + +export const createStaleBrowserRpcDispatchResponse = ( + dispatchRequestId: string, + request: Pick, + currentRunId?: string, +): BrowserDispatchResponse => { + return { + requestId: dispatchRequestId, + stale: true, + error: + 'Ignored stale browser RPC request from previous run: ' + + `${request.kind}.${request.method} (testPath: ${request.testPath}, ` + + `runId: ${request.runId}, currentRunId: ${currentRunId ?? 'none'})`, + }; +}; diff --git a/packages/browser-ui/src/core/channel.test.ts b/packages/browser-ui/src/core/channel.test.ts index 66132f54a..cfc4ba54d 100644 --- a/packages/browser-ui/src/core/channel.test.ts +++ b/packages/browser-ui/src/core/channel.test.ts @@ -1,3 +1,4 @@ +import { DISPATCH_RESPONSE_TYPE } from '@rstest/browser/protocol'; import { describe, expect, it, rstest } from '@rstest/core'; import { forwardDispatchRpcRequest } from './channel'; @@ -31,7 +32,7 @@ describe('forwardDispatchRpcRequest', () => { }); expect((sourceWindow as any).postMessage).toHaveBeenCalledWith( { - type: '__rstest_dispatch_response__', + type: DISPATCH_RESPONSE_TYPE, payload: { requestId: 'req-1', result: { ok: true }, @@ -58,7 +59,7 @@ describe('forwardDispatchRpcRequest', () => { expect((sourceWindow as any).postMessage).toHaveBeenCalledWith( { - type: '__rstest_dispatch_response__', + type: DISPATCH_RESPONSE_TYPE, payload: { requestId: 'req-2', error: 'Container RPC is not ready for dispatch.', @@ -86,7 +87,7 @@ describe('forwardDispatchRpcRequest', () => { expect(rpc.dispatch).not.toHaveBeenCalled(); expect((sourceWindow as any).postMessage).toHaveBeenCalledWith( { - type: '__rstest_dispatch_response__', + type: DISPATCH_RESPONSE_TYPE, payload: { requestId: 'unknown-request', error: diff --git a/packages/browser-ui/src/core/channel.ts b/packages/browser-ui/src/core/channel.ts index 5db9b55df..18d49d591 100644 --- a/packages/browser-ui/src/core/channel.ts +++ b/packages/browser-ui/src/core/channel.ts @@ -1,3 +1,7 @@ +import { + DISPATCH_MESSAGE_TYPE, + DISPATCH_RESPONSE_TYPE, +} from '@rstest/browser/protocol'; import type { BrowserClientMessage, BrowserDispatchRequest, @@ -5,9 +9,6 @@ import type { HostRPC, } from '../types'; -const DISPATCH_MESSAGE_TYPE = '__rstest_dispatch__'; -const DISPATCH_RESPONSE_TYPE = '__rstest_dispatch_response__'; - type DispatchRpcHandler = Pick; const canPostMessage = ( diff --git a/packages/browser-ui/src/core/runtime.ts b/packages/browser-ui/src/core/runtime.ts index 079318767..bb411e5c4 100644 --- a/packages/browser-ui/src/core/runtime.ts +++ b/packages/browser-ui/src/core/runtime.ts @@ -1,5 +1,12 @@ export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16_000, 30_000]; +export const createRunId = (): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `run-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +}; + export const createWebSocketUrl = (wsPort: number): string => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.hostname}:${wsPort}`; @@ -10,6 +17,7 @@ export const createRunnerUrl = ( runnerBase?: string, testNamePattern?: string, cacheBust = false, + runId?: string, ): string => { const base = runnerBase || window.location.origin; const url = new URL('/runner.html', base); @@ -17,6 +25,9 @@ export const createRunnerUrl = ( if (testNamePattern) { url.searchParams.set('testNamePattern', testNamePattern); } + if (runId) { + url.searchParams.set('runId', runId); + } if (cacheBust) { url.searchParams.set('t', Date.now().toString()); } diff --git a/packages/browser-ui/src/main.tsx b/packages/browser-ui/src/main.tsx index 6484ff37f..6ea2616ef 100644 --- a/packages/browser-ui/src/main.tsx +++ b/packages/browser-ui/src/main.tsx @@ -1,3 +1,8 @@ +import { + DISPATCH_RESPONSE_TYPE, + DISPATCH_RPC_REQUEST_TYPE, + RSTEST_CONFIG_MESSAGE_TYPE, +} from '@rstest/browser/protocol'; import { App as AntdApp, theme as antdTheme, ConfigProvider } from 'antd'; import React, { useCallback, @@ -14,12 +19,19 @@ import { SidebarHeader } from './components/SidebarHeader'; import { TestFilesHeader } from './components/TestFilesHeader'; import { TestFilesTree } from './components/TestFilesTree'; import { ViewportFrame } from './components/ViewportFrame'; +import { + canPostMessageSource, + createStaleBrowserRpcDispatchResponse, + isStaleBrowserRpcRequest, + readBrowserRpcRequest, +} from './core/browserRpc'; import { forwardDispatchRpcRequest, readDispatchMessage } from './core/channel'; -import { createRunnerUrl } from './core/runtime'; +import { createRunId, createRunnerUrl } from './core/runtime'; import { useRpc } from './hooks/useRpc'; import type { BrowserClientFileResult, BrowserClientTestResult, + BrowserDispatchRequest, BrowserHostConfig, FatalPayload, LogPayload, @@ -45,6 +57,23 @@ const getDisplayName = (testFile: string): string => { return parts[parts.length - 1] || testFile; }; +const readRunIdFromFrame = (frame: HTMLIFrameElement): string | undefined => { + try { + const url = new URL(frame.src, window.location.href); + return url.searchParams.get('runId') ?? undefined; + } catch { + return undefined; + } +}; + +const findRunnerFrameByTestPath = ( + testPath: string, +): HTMLIFrameElement | undefined => { + return Array.from( + document.querySelectorAll('iframe[data-test-file]'), + ).find((frame) => frame.dataset.testFile === testPath); +}; + // ============================================================================ // App Component // ============================================================================ @@ -75,6 +104,9 @@ const BrowserRunner: React.FC<{ } > >(new Map()); + const [runIdByTestFile, setRunIdByTestFile] = useState< + Record + >({}); const viewportStorageKey = useCallback( (projectName: string) => { @@ -205,7 +237,11 @@ const BrowserRunner: React.FC<{ ); return; } - + const nextRunId = createRunId(); + setRunIdByTestFile((prev) => ({ + ...prev, + [testFile]: nextRunId, + })); setStatusMap((prev) => ({ ...prev, [testFile]: 'running' })); setCaseMap((prev) => { const prevFile = prev[testFile] ?? {}; @@ -219,6 +255,8 @@ const BrowserRunner: React.FC<{ testFile, options.runnerUrl, testNamePattern, + false, + nextRunId, ); logger.debug('[Container] Setting iframe.src to:', newSrc); iframe.src = newSrc; @@ -269,6 +307,14 @@ const BrowserRunner: React.FC<{ return next; }); + setRunIdByTestFile((prev) => { + const next: Record = {}; + for (const file of testFiles) { + next[file.testPath] = prev[file.testPath] ?? createRunId(); + } + return next; + }); + // Clean up openFiles: remove files that no longer exist const testPaths = testFiles.map((f) => f.testPath); setOpenFiles((prev) => prev.filter((file) => testPaths.includes(file))); @@ -444,9 +490,38 @@ const BrowserRunner: React.FC<{ } else if (message.type === 'log') { const payload = message.payload as LogPayload; rpc?.onLog(payload); - } else if (message.type === 'dispatch-rpc-request') { + } else if (message.type === DISPATCH_RPC_REQUEST_TYPE) { // Unified RPC path for snapshot and future runner-side capabilities. - void forwardDispatchRpcRequest(rpc, message.payload, event.source); + const dispatchRequest = message.payload as BrowserDispatchRequest; + const browserRpcRequest = readBrowserRpcRequest(dispatchRequest); + + if (browserRpcRequest) { + const currentFrame = findRunnerFrameByTestPath( + browserRpcRequest.testPath, + ); + const currentRunId = currentFrame + ? readRunIdFromFrame(currentFrame) + : undefined; + + if (isStaleBrowserRpcRequest(browserRpcRequest, currentRunId)) { + if (canPostMessageSource(event.source)) { + event.source.postMessage( + { + type: DISPATCH_RESPONSE_TYPE, + payload: createStaleBrowserRpcDispatchResponse( + dispatchRequest.requestId, + browserRpcRequest, + currentRunId, + ), + }, + '*', + ); + } + return; + } + } + + void forwardDispatchRpcRequest(rpc, dispatchRequest, event.source); } }; window.addEventListener('message', listener); @@ -689,6 +764,10 @@ const BrowserRunner: React.FC<{ {testFiles.map((fileInfo) => (() => { const isActive = fileInfo.testPath === active; + const runId = runIdByTestFile[fileInfo.testPath]; + if (!runId) { + return null; + } const selection = viewportByProject[fileInfo.projectName] ?? selectionFromConfig( @@ -698,13 +777,15 @@ const BrowserRunner: React.FC<{ event: React.SyntheticEvent, ) => { const frame = event.currentTarget; + const frameRunId = readRunIdFromFrame(frame) ?? runId; if (frame.contentWindow) { frame.contentWindow.postMessage( { - type: 'RSTEST_CONFIG', + type: RSTEST_CONFIG_MESSAGE_TYPE, payload: { ...options, testFile: fileInfo.testPath, + runId: frameRunId, }, }, '*', @@ -745,6 +826,9 @@ const BrowserRunner: React.FC<{ src={createRunnerUrl( fileInfo.testPath, options.runnerUrl, + undefined, + false, + runId, )} className="block h-full w-full border-0" style={{ background: token.colorBgContainer }} diff --git a/packages/browser-ui/src/types.ts b/packages/browser-ui/src/types.ts index 8cd997dbe..fb31aa5c7 100644 --- a/packages/browser-ui/src/types.ts +++ b/packages/browser-ui/src/types.ts @@ -1,56 +1,29 @@ -/** - * Browser UI types - * - * These types are derived from @rstest/core's protocol types but simplified - * for the browser UI's needs. The UI only needs a subset of the full config. - */ +import type { + BrowserDispatchRequest, + BrowserDispatchResponse, + BrowserHostConfig, + BrowserProjectRuntime, + BrowserRpcRequest, + BrowserRpcResponse, + BrowserClientMessage as ProtocolBrowserClientMessage, + TestFileInfo, +} from '@rstest/browser/protocol'; -export type BrowserProjectRuntime = { - name: string; - environmentName: string; - projectRoot: string; - runtimeConfig: Record; - viewport?: - | { - width: number; - height: number; - } - | string; -}; +import type { TestFileResult, TestResult } from '@rstest/core/browser-runtime'; /** - * Test file info with associated project name. - * Used to track which project a test file belongs to. + * Browser UI types + * + * Keep protocol types (locator IR + snapshot/browser RPC) in sync with + * @rstest/browser by importing from the shared source. */ -export type TestFileInfo = { - testPath: string; - projectName: string; -}; - -export type BrowserHostConfig = { - rootPath: string; - projects: BrowserProjectRuntime[]; - snapshot: { - updateSnapshot: unknown; - }; - /** If provided, only run this specific test file */ - testFile?: string; - /** Base URL for runner (iframe) pages */ - runnerUrl?: string; - /** WebSocket port for container RPC */ - wsPort?: number; - /** Debug mode. When true, enables verbose logging in browser */ - debug?: boolean; - /** Timeout for RPC operations in milliseconds */ - rpcTimeout?: number; -}; export type BrowserClientTestResult = { - testId: string; - status: 'skip' | 'pass' | 'fail' | 'todo'; - name: string; - testPath: string; - parentNames?: string[]; + testId: TestResult['testId']; + status: TestResult['status']; + name: TestResult['name']; + testPath: TestResult['testPath']; + parentNames?: TestResult['parentNames']; location?: { line: number; column?: number; @@ -58,56 +31,37 @@ export type BrowserClientTestResult = { }; }; -export type BrowserClientFileResult = BrowserClientTestResult & { +export type BrowserClientFileResult = { + testId: TestFileResult['testId']; + status: TestFileResult['status']; + name: TestFileResult['name']; + testPath: TestFileResult['testPath']; + parentNames?: TestFileResult['parentNames']; + location?: { + line: number; + column?: number; + file?: string; + }; results: BrowserClientTestResult[]; }; -export type TestFileStartPayload = { - testPath: string; - projectName: string; -}; +export type TestFileStartPayload = Extract< + ProtocolBrowserClientMessage, + { type: 'file-start' } +>['payload']; -export type LogPayload = { - level: 'log' | 'warn' | 'error' | 'info' | 'debug'; - content: string; - testPath: string; - type: 'stdout' | 'stderr'; - trace?: string; -}; +export type LogPayload = Extract< + ProtocolBrowserClientMessage, + { type: 'log' } +>['payload']; -export type FatalPayload = { - message: string; - stack?: string; -}; +export type FatalPayload = Extract< + ProtocolBrowserClientMessage, + { type: 'fatal' } +>['payload']; export type BrowserClientMessage = - | { type: 'ready' } - | { - type: 'file-start'; - payload: TestFileStartPayload; - } - | { - type: 'case-result'; - payload: BrowserClientTestResult; - } - | { - type: 'file-complete'; - payload: BrowserClientFileResult; - } - | { - type: 'fatal'; - payload: FatalPayload; - } - | { - type: 'log'; - payload: LogPayload; - } - | { - // Keep browser-ui aligned with @rstest/browser dispatch protocol so new - // namespaces can be routed without introducing extra message variants. - type: 'dispatch-rpc-request'; - payload: BrowserDispatchRequest; - } + | ProtocolBrowserClientMessage | { type: string; payload?: unknown }; export type HostRPC = { @@ -128,16 +82,12 @@ export type ContainerRPC = { reloadTestFile: (testFile: string, testNamePattern?: string) => Promise; }; -export type BrowserDispatchRequest = { - requestId: string; - [key: string]: unknown; -}; - -export type BrowserDispatchResponse = { - requestId: string; - runToken?: number; - result?: unknown; - error?: string; - stale?: boolean; - [key: string]: unknown; +export type { + BrowserHostConfig, + BrowserProjectRuntime, + BrowserDispatchRequest, + BrowserDispatchResponse, + BrowserRpcRequest, + BrowserRpcResponse, + TestFileInfo, }; diff --git a/packages/browser-ui/src/utils/viewportPresets.ts b/packages/browser-ui/src/utils/viewportPresets.ts index 82690b4fb..881117ac5 100644 --- a/packages/browser-ui/src/utils/viewportPresets.ts +++ b/packages/browser-ui/src/utils/viewportPresets.ts @@ -1,3 +1,7 @@ +import { + BROWSER_VIEWPORT_PRESET_DIMENSIONS, + BROWSER_VIEWPORT_PRESET_IDS, +} from '@rstest/browser/viewport-presets'; import type { DevicePreset } from '@rstest/core/browser'; export type { DevicePreset }; @@ -9,52 +13,37 @@ export type DevicePresetInfo = { height: number; }; -/** - * Presets aligned with Chrome DevTools (portrait for phones/tablets). - * - * Source (Chromium): front_end/models/emulation/EmulatedDevices.ts - * Default subset: entries with `show-by-default: true`. - */ -export const DEVICE_PRESETS: DevicePresetInfo[] = [ - { id: 'iPhoneSE', label: 'iPhone SE', width: 375, height: 667 }, - { id: 'iPhoneXR', label: 'iPhone XR', width: 414, height: 896 }, - { id: 'iPhone12Pro', label: 'iPhone 12 Pro', width: 390, height: 844 }, - { id: 'iPhone14ProMax', label: 'iPhone 14 Pro Max', width: 430, height: 932 }, - { id: 'Pixel7', label: 'Pixel 7', width: 412, height: 915 }, - { - id: 'SamsungGalaxyS8Plus', - label: 'Samsung Galaxy S8+', - width: 360, - height: 740, - }, - { - id: 'SamsungGalaxyS20Ultra', - label: 'Samsung Galaxy S20 Ultra', - width: 412, - height: 915, - }, - { id: 'iPadMini', label: 'iPad Mini', width: 768, height: 1024 }, - { id: 'iPadAir', label: 'iPad Air', width: 820, height: 1180 }, - { id: 'iPadPro', label: 'iPad Pro', width: 1024, height: 1366 }, - { id: 'SurfacePro7', label: 'Surface Pro 7', width: 912, height: 1368 }, - { id: 'SurfaceDuo', label: 'Surface Duo', width: 540, height: 720 }, - { id: 'GalaxyZFold5', label: 'Galaxy Z Fold 5', width: 344, height: 882 }, - { - id: 'AsusZenbookFold', - label: 'Asus Zenbook Fold', - width: 853, - height: 1280, - }, - { - id: 'SamsungGalaxyA51A71', - label: 'Samsung Galaxy A51/71', - width: 412, - height: 914, - }, +const DEVICE_PRESET_LABELS: Record = { + iPhoneSE: 'iPhone SE', + iPhoneXR: 'iPhone XR', + iPhone12Pro: 'iPhone 12 Pro', + iPhone14ProMax: 'iPhone 14 Pro Max', + Pixel7: 'Pixel 7', + SamsungGalaxyS8Plus: 'Samsung Galaxy S8+', + SamsungGalaxyS20Ultra: 'Samsung Galaxy S20 Ultra', + iPadMini: 'iPad Mini', + iPadAir: 'iPad Air', + iPadPro: 'iPad Pro', + SurfacePro7: 'Surface Pro 7', + SurfaceDuo: 'Surface Duo', + GalaxyZFold5: 'Galaxy Z Fold 5', + AsusZenbookFold: 'Asus Zenbook Fold', + SamsungGalaxyA51A71: 'Samsung Galaxy A51/71', // Nest Hub devices only expose a single (horizontal) mode in DevTools. - { id: 'NestHub', label: 'Nest Hub', width: 1024, height: 600 }, - { id: 'NestHubMax', label: 'Nest Hub Max', width: 1280, height: 800 }, -]; + NestHub: 'Nest Hub', + NestHubMax: 'Nest Hub Max', +}; + +export const DEVICE_PRESETS: DevicePresetInfo[] = + BROWSER_VIEWPORT_PRESET_IDS.map((id) => { + const dimensions = BROWSER_VIEWPORT_PRESET_DIMENSIONS[id]; + return { + id, + label: DEVICE_PRESET_LABELS[id], + width: dimensions.width, + height: dimensions.height, + }; + }); const presetIds = new Set(DEVICE_PRESETS.map((p) => p.id)); diff --git a/packages/browser-ui/tsconfig.json b/packages/browser-ui/tsconfig.json index 23ff22ab7..4a427f241 100644 --- a/packages/browser-ui/tsconfig.json +++ b/packages/browser-ui/tsconfig.json @@ -6,17 +6,19 @@ "noEmit": true, "skipLibCheck": true, "useDefineForClassFields": true, - - /* modules */ "module": "ESNext", "moduleDetection": "force", "moduleResolution": "bundler", "verbatimModuleSyntax": true, + "baseUrl": ".", + "paths": { + "@rstest/browser/rpc-protocol": ["../browser/src/rpcProtocol.ts"], + "@rstest/browser/protocol": ["../browser/src/protocol.ts"], + "@rstest/browser/viewport-presets": ["../browser/src/viewportPresets.ts"] + }, "resolveJsonModule": true, "allowImportingTsExtensions": true, "noUncheckedSideEffectImports": true, - - /* type checking */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true diff --git a/packages/browser/LICENSE-APACHE-2.0 b/packages/browser/LICENSE-APACHE-2.0 new file mode 100644 index 000000000..4ace03ddb --- /dev/null +++ b/packages/browser/LICENSE-APACHE-2.0 @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Portions Copyright (c) Microsoft Corporation. + Portions Copyright 2017 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/browser/NOTICE b/packages/browser/NOTICE new file mode 100644 index 000000000..6c42d4932 --- /dev/null +++ b/packages/browser/NOTICE @@ -0,0 +1,11 @@ +@rstest/browser includes portions adapted from Playwright. + +Playwright +Copyright (c) Microsoft Corporation + +This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer), +available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE). + +The adapted portions are used in Playwright provider matcher/RPC integration code under: +- src/providers/playwright/dispatchBrowserRpc.ts +- src/providers/playwright/expectUtils.ts diff --git a/packages/browser/package.json b/packages/browser/package.json index 4a33bd5ed..39dbdefc3 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -20,9 +20,12 @@ "license": "MIT", "type": "module", "main": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { ".": { + "types": "./dist/browser.d.ts", + "default": "./dist/browser.js" + }, + "./internal": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, @@ -32,7 +35,9 @@ }, "files": [ "dist", - "src" + "src", + "NOTICE", + "LICENSE-APACHE-2.0" ], "scripts": { "build": "rslib build", diff --git a/packages/browser/rslib.config.ts b/packages/browser/rslib.config.ts index 9dc64d0d5..c23a7bcca 100644 --- a/packages/browser/rslib.config.ts +++ b/packages/browser/rslib.config.ts @@ -27,8 +27,10 @@ export default defineConfig({ }, }, source: { + tsconfigPath: './tsconfig.json', entry: { index: './src/index.ts', + browser: './src/browser.ts', }, }, tools: { diff --git a/packages/browser/src/augmentExpect.ts b/packages/browser/src/augmentExpect.ts new file mode 100644 index 000000000..7e3c33c63 --- /dev/null +++ b/packages/browser/src/augmentExpect.ts @@ -0,0 +1,62 @@ +import type { Locator } from './client/locator'; + +export type BrowserElementExpect = { + not: BrowserElementExpect; + toBeVisible: (options?: { timeout?: number }) => Promise; + toBeHidden: (options?: { timeout?: number }) => Promise; + toBeEnabled: (options?: { timeout?: number }) => Promise; + toBeDisabled: (options?: { timeout?: number }) => Promise; + toBeChecked: (options?: { timeout?: number }) => Promise; + toBeUnchecked: (options?: { timeout?: number }) => Promise; + toBeAttached: (options?: { timeout?: number }) => Promise; + toBeDetached: (options?: { timeout?: number }) => Promise; + toBeEditable: (options?: { timeout?: number }) => Promise; + toBeFocused: (options?: { timeout?: number }) => Promise; + toBeEmpty: (options?: { timeout?: number }) => Promise; + toBeInViewport: (options?: { + timeout?: number; + ratio?: number; + }) => Promise; + toHaveText: ( + text: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toContainText: ( + text: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toHaveValue: ( + value: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toHaveId: ( + value: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toHaveAttribute: ( + name: string, + value?: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toHaveClass: ( + value: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toHaveCount: (count: number, options?: { timeout?: number }) => Promise; + toHaveCSS: ( + name: string, + value: string | RegExp, + options?: { timeout?: number }, + ) => Promise; + toHaveJSProperty: ( + name: string, + value: unknown, + options?: { timeout?: number }, + ) => Promise; +}; + +declare module '@rstest/core' { + interface ExpectStatic { + element: (locator: Locator) => BrowserElementExpect; + } +} diff --git a/packages/browser/src/browser.ts b/packages/browser/src/browser.ts new file mode 100644 index 000000000..3ea039af0 --- /dev/null +++ b/packages/browser/src/browser.ts @@ -0,0 +1,3 @@ +import './augmentExpect'; + +export * from './client/api'; diff --git a/packages/browser/src/browserRpcRegistry.ts b/packages/browser/src/browserRpcRegistry.ts new file mode 100644 index 000000000..b3da31fa6 --- /dev/null +++ b/packages/browser/src/browserRpcRegistry.ts @@ -0,0 +1,57 @@ +/** + * Runtime allowlists for Browser RPC methods. + * + * Planned capabilities are intentionally documented in comments (not runtime + * data) to keep this module focused on host-side validation. + * + * Planned gaps (non-exhaustive): + * - Locator query/interop: filter({ hasNot, hasNotText }), locator.selector/length, + * locator.query()/element()/elements()/all(), page.elementLocator(element), + * locators.extend(...) + * - Locator actions: tripleClick, hover out, drag/drop helpers + * - Assertions: a11y matchers (accessible name/description), toHaveRole, + * toHaveValues + * - Artifacts intentionally excluded for now: screenshot/toMatchScreenshot/ + * trace/video + */ +export const supportedLocatorActions = new Set([ + 'click', + 'dblclick', + 'fill', + 'hover', + 'press', + 'clear', + 'check', + 'uncheck', + 'focus', + 'blur', + 'scrollIntoViewIfNeeded', + 'waitFor', + 'dispatchEvent', + 'selectOption', + 'setInputFiles', +]); + +export const supportedExpectElementMatchers = new Set([ + 'toBeVisible', + 'toBeHidden', + 'toBeEnabled', + 'toBeDisabled', + 'toBeAttached', + 'toBeDetached', + 'toBeEditable', + 'toBeFocused', + 'toBeEmpty', + 'toBeInViewport', + 'toHaveText', + 'toContainText', + 'toHaveValue', + 'toHaveAttribute', + 'toHaveClass', + 'toHaveCount', + 'toBeChecked', + 'toBeUnchecked', + 'toHaveId', + 'toHaveCSS', + 'toHaveJSProperty', +]); diff --git a/packages/browser/src/client/api.ts b/packages/browser/src/client/api.ts new file mode 100644 index 000000000..63af0e37a --- /dev/null +++ b/packages/browser/src/client/api.ts @@ -0,0 +1,213 @@ +import type { BrowserElementExpect } from '../augmentExpect'; +import type { BrowserLocatorText, BrowserRpcRequest } from '../rpcProtocol'; +import { callBrowserRpc } from './browserRpc'; +import { + isLocator, + Locator, + page, + serializeText, + setTestIdAttribute, +} from './locator'; + +const serializeMatcherText = (value: string | RegExp): BrowserLocatorText => { + return serializeText(value); +}; + +const createElementExpect = ( + locator: Locator, + isNot: boolean, +): BrowserElementExpect => { + const callExpect = async ( + method: string, + args: unknown[], + timeout?: number, + ): Promise => { + await callBrowserRpc({ + kind: 'expect', + locator: locator.ir, + method, + args, + isNot, + timeout, + } satisfies Omit); + }; + + const api: Omit = { + async toBeVisible(options) { + await callExpect('toBeVisible', [], options?.timeout); + }, + async toBeHidden(options) { + await callExpect('toBeHidden', [], options?.timeout); + }, + async toBeEnabled(options) { + await callExpect('toBeEnabled', [], options?.timeout); + }, + async toBeDisabled(options) { + await callExpect('toBeDisabled', [], options?.timeout); + }, + async toBeChecked(options) { + await callExpect('toBeChecked', [], options?.timeout); + }, + async toBeUnchecked(options) { + await callExpect('toBeUnchecked', [], options?.timeout); + }, + async toBeAttached(options) { + await callExpect('toBeAttached', [], options?.timeout); + }, + async toBeDetached(options) { + await callExpect('toBeDetached', [], options?.timeout); + }, + async toBeEditable(options) { + await callExpect('toBeEditable', [], options?.timeout); + }, + async toBeFocused(options) { + await callExpect('toBeFocused', [], options?.timeout); + }, + async toBeEmpty(options) { + await callExpect('toBeEmpty', [], options?.timeout); + }, + async toBeInViewport(options) { + const ratio = options?.ratio; + await callExpect( + 'toBeInViewport', + ratio === undefined ? [] : [ratio], + options?.timeout, + ); + }, + async toHaveText(text, options) { + await callExpect( + 'toHaveText', + [serializeMatcherText(text)], + options?.timeout, + ); + }, + async toContainText(text, options) { + await callExpect( + 'toContainText', + [serializeMatcherText(text)], + options?.timeout, + ); + }, + async toHaveValue(value, options) { + await callExpect( + 'toHaveValue', + [serializeMatcherText(value)], + options?.timeout, + ); + }, + async toHaveId(value, options) { + await callExpect( + 'toHaveId', + [serializeMatcherText(value)], + options?.timeout, + ); + }, + async toHaveAttribute(name, value, options) { + const args = + value === undefined ? [name] : [name, serializeMatcherText(value)]; + await callExpect('toHaveAttribute', args, options?.timeout); + }, + async toHaveClass(value, options) { + await callExpect( + 'toHaveClass', + [serializeMatcherText(value)], + options?.timeout, + ); + }, + async toHaveCount(count, options) { + await callExpect('toHaveCount', [count], options?.timeout); + }, + async toHaveCSS(name, value, options) { + if (typeof name !== 'string' || !name) { + throw new TypeError('toHaveCSS expects a non-empty CSS property name'); + } + await callExpect( + 'toHaveCSS', + [name, serializeMatcherText(value)], + options?.timeout, + ); + }, + async toHaveJSProperty(name, value, options) { + if (typeof name !== 'string' || !name) { + throw new TypeError( + 'toHaveJSProperty expects a non-empty property name', + ); + } + await callExpect('toHaveJSProperty', [name, value], options?.timeout); + }, + }; + + const withNot = api as BrowserElementExpect; + Object.defineProperty(withNot, 'not', { + configurable: false, + enumerable: false, + get() { + return createElementExpect(locator, !isNot); + }, + }); + return withNot; +}; + +const element = (locator: unknown): BrowserElementExpect => { + if (!isLocator(locator)) { + throw new TypeError( + 'expect.element() expects a Locator returned from @rstest/browser page.getBy* APIs.', + ); + } + + return createElementExpect(locator, false); +}; + +const markBrowserElement = (): void => { + Object.defineProperty(element, '__rstestBrowser', { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); +}; + +const installExpectElement = (): void => { + // In browser runtime, `@rstest/core` exports are proxies that forward property + // access to `globalThis.RSTEST_API`. Patch the underlying expect implementation. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const api = (globalThis as any).RSTEST_API as any; + const target = api?.expect; + if (!target) { + throw new Error( + 'RSTEST_API.expect is not registered yet. This usually indicates @rstest/browser was imported too early.', + ); + } + + if (typeof target.element !== 'function' || !target.element.__rstestBrowser) { + markBrowserElement(); + target.element = element; + } +}; + +installExpectElement(); + +export type { + BrowserPage, + BrowserSerializable, + LocatorBlurOptions, + LocatorCheckOptions, + LocatorClickOptions, + LocatorDblclickOptions, + LocatorDispatchEventInit, + LocatorFillOptions, + LocatorFilterOptions, + LocatorFocusOptions, + LocatorGetByRoleOptions, + LocatorHoverOptions, + LocatorKeyboardModifier, + LocatorMouseButton, + LocatorPosition, + LocatorPressOptions, + LocatorScrollIntoViewIfNeededOptions, + LocatorSelectOptionOptions, + LocatorSetInputFilesOptions, + LocatorTextOptions, + LocatorWaitForOptions, +} from './locator'; +export { Locator, page, setTestIdAttribute }; diff --git a/packages/browser/src/client/browserRpc.ts b/packages/browser/src/client/browserRpc.ts new file mode 100644 index 000000000..e2b0785c1 --- /dev/null +++ b/packages/browser/src/client/browserRpc.ts @@ -0,0 +1,86 @@ +import type { BrowserDispatchRequest } from '../protocol'; +import { DISPATCH_METHOD_RPC, DISPATCH_NAMESPACE_BROWSER } from '../protocol'; +import type { BrowserRpcRequest } from '../rpcProtocol'; +import { + createRequestId, + dispatchRpc, + getRpcTimeout, +} from './dispatchTransport'; + +const getUrlSearchParam = (name: string): string | undefined => { + try { + const value = new URL(window.location.href).searchParams.get(name); + return value ?? undefined; + } catch { + return undefined; + } +}; + +const getCurrentTestPath = (): string => { + const testPath = + window.__RSTEST_BROWSER_OPTIONS__?.testFile ?? + getUrlSearchParam('testFile'); + if (!testPath) { + throw new Error( + 'Browser RPC requires testFile in __RSTEST_BROWSER_OPTIONS__. ' + + 'This usually indicates the runner iframe was not configured by the container or URL.', + ); + } + return testPath; +}; + +const getCurrentRunId = (): string => { + const runId = + window.__RSTEST_BROWSER_OPTIONS__?.runId ?? getUrlSearchParam('runId'); + if (!runId) { + throw new Error( + 'Browser RPC requires runId in __RSTEST_BROWSER_OPTIONS__. ' + + 'This usually indicates the runner iframe URL/config is stale or incomplete.', + ); + } + return runId; +}; + +const createBrowserDispatchRequest = ( + requestId: string, + request: BrowserRpcRequest, +): BrowserDispatchRequest => { + return { + requestId, + namespace: DISPATCH_NAMESPACE_BROWSER, + method: DISPATCH_METHOD_RPC, + args: request, + target: { + testFile: request.testPath, + }, + }; +}; + +export const callBrowserRpc = async ( + payload: Omit, +): Promise => { + if ( + payload.kind === 'config' && + window.__RSTEST_BROWSER_OPTIONS__?.mode === 'collect' + ) { + return undefined as T; + } + + const id = createRequestId('browser-rpc'); + const rpcTimeout = getRpcTimeout(); + const request: BrowserRpcRequest = { + id, + testPath: getCurrentTestPath(), + runId: getCurrentRunId(), + ...payload, + }; + const dispatchRequest = createBrowserDispatchRequest(id, request); + + return dispatchRpc({ + requestId: id, + request: dispatchRequest, + timeoutMs: rpcTimeout, + staleMessage: 'Stale browser RPC request ignored.', + timeoutMessage: `Browser RPC timeout after ${rpcTimeout / 1000}s: ${request.kind}.${request.method}`, + }); +}; diff --git a/packages/browser/src/client/dispatchTransport.ts b/packages/browser/src/client/dispatchTransport.ts new file mode 100644 index 000000000..11ae69528 --- /dev/null +++ b/packages/browser/src/client/dispatchTransport.ts @@ -0,0 +1,178 @@ +import type { + BrowserDispatchRequest, + BrowserDispatchResponse, +} from '../protocol'; +import { + DISPATCH_MESSAGE_TYPE, + DISPATCH_RESPONSE_TYPE, + DISPATCH_RPC_REQUEST_TYPE, +} from '../protocol'; + +export const DEFAULT_RPC_TIMEOUT_MS = 30_000; + +export const getRpcTimeout = (): number => { + return ( + window.__RSTEST_BROWSER_OPTIONS__?.rpcTimeout ?? DEFAULT_RPC_TIMEOUT_MS + ); +}; + +const pendingRequests = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + staleMessage: string; + } +>(); + +let requestIdCounter = 0; +let messageListenerInitialized = false; + +export const createRequestId = (prefix: string): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + requestIdCounter += 1; + return `${prefix}-${Date.now().toString(36)}-${requestIdCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +}; + +const isDispatchResponse = ( + value: unknown, +): value is BrowserDispatchResponse => { + return ( + typeof value === 'object' && + value !== null && + 'requestId' in value && + typeof (value as { requestId: unknown }).requestId === 'string' + ); +}; + +const settlePendingRequest = (response: BrowserDispatchResponse): void => { + const pending = pendingRequests.get(response.requestId); + if (!pending) { + return; + } + + pendingRequests.delete(response.requestId); + if (response.stale) { + pending.reject(new Error(pending.staleMessage)); + return; + } + if (response.error) { + pending.reject(new Error(response.error)); + return; + } + pending.resolve(response.result); +}; + +const initMessageListener = (): void => { + if (messageListenerInitialized) { + return; + } + messageListenerInitialized = true; + + window.addEventListener('message', (event: MessageEvent) => { + if (event.data?.type === DISPATCH_RESPONSE_TYPE) { + settlePendingRequest(event.data.payload as BrowserDispatchResponse); + } + }); +}; + +const unwrapDispatchBridgeResult = ( + requestId: string, + result: unknown, + staleMessage: string, +): T => { + if (!isDispatchResponse(result)) { + throw new Error('Invalid dispatch bridge response payload.'); + } + + if (result.requestId !== requestId) { + throw new Error( + `Mismatched dispatch response id: expected ${requestId}, got ${result.requestId}`, + ); + } + if (result.stale) { + throw new Error(staleMessage); + } + if (result.error) { + throw new Error(result.error); + } + return result.result as T; +}; + +export const dispatchRpc = ({ + requestId, + request, + timeoutMs, + timeoutMessage, + staleMessage, +}: { + requestId: string; + request: BrowserDispatchRequest; + timeoutMs: number; + timeoutMessage: string; + staleMessage: string; +}): Promise => { + if (window.parent === window) { + const dispatchBridge = window.__rstest_dispatch_rpc__; + if (!dispatchBridge) { + throw new Error( + 'Dispatch RPC bridge is not available in top-level runner.', + ); + } + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(timeoutMessage)); + }, timeoutMs); + + const call = Promise.resolve(dispatchBridge(request)).then((result) => + unwrapDispatchBridgeResult(requestId, result, staleMessage), + ); + + call + .then((result) => { + clearTimeout(timeoutId); + resolve(result); + }) + .catch((error) => { + clearTimeout(timeoutId); + reject(error instanceof Error ? error : new Error(String(error))); + }); + }); + } + + initMessageListener(); + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + pendingRequests.delete(requestId); + reject(new Error(timeoutMessage)); + }, timeoutMs); + + pendingRequests.set(requestId, { + staleMessage, + resolve: (value) => { + clearTimeout(timeoutId); + resolve(value as T); + }, + reject: (error) => { + clearTimeout(timeoutId); + reject(error); + }, + }); + + window.parent.postMessage( + { + type: DISPATCH_MESSAGE_TYPE, + payload: { + type: DISPATCH_RPC_REQUEST_TYPE, + payload: request, + }, + }, + '*', + ); + }); +}; diff --git a/packages/browser/src/client/entry.ts b/packages/browser/src/client/entry.ts index 5593cefe2..82578213b 100644 --- a/packages/browser/src/client/entry.ts +++ b/packages/browser/src/client/entry.ts @@ -21,9 +21,14 @@ import { normalize } from 'pathe'; import type { BrowserClientMessage, BrowserDispatchRequest, - BrowserHostConfig, BrowserProjectRuntime, } from '../protocol'; +import { + DISPATCH_MESSAGE_TYPE, + DISPATCH_NAMESPACE_RUNNER, + DISPATCH_RPC_REQUEST_TYPE, + RSTEST_CONFIG_MESSAGE_TYPE, +} from '../protocol'; import { BrowserSnapshotEnvironment } from './snapshot'; import { findNewScriptUrl, @@ -33,13 +38,6 @@ import { } from './sourceMapSupport'; declare global { - interface Window { - __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig; - __rstest_dispatch__?: (message: BrowserClientMessage) => void; - __rstest_dispatch_rpc__?: ( - request: BrowserDispatchRequest, - ) => Promise; - } // eslint-disable-next-line no-var var __coverage__: Record | undefined; } @@ -219,7 +217,7 @@ const send = (message: BrowserClientMessage): void => { // If in iframe, send to parent window (container) which will forward to host via RPC if (window.parent !== window) { window.parent.postMessage( - { type: '__rstest_dispatch__', payload: message }, + { type: DISPATCH_MESSAGE_TYPE, payload: message }, '*', ); return; @@ -235,7 +233,7 @@ const dispatchRunnerLifecycle = ( ): void => { const request: BrowserDispatchRequest = { requestId: `runner-lifecycle-${++runnerDispatchRequestId}`, - namespace: 'runner', + namespace: DISPATCH_NAMESPACE_RUNNER, method, args: payload, }; @@ -256,7 +254,7 @@ const dispatchRunnerLifecycle = ( } send({ - type: 'dispatch-rpc-request', + type: DISPATCH_RPC_REQUEST_TYPE, payload: request, }); }; @@ -276,7 +274,7 @@ const waitForConfig = (): Promise => { return new Promise((resolve, reject) => { const handleMessage = (event: MessageEvent) => { - if (event.data?.type === 'RSTEST_CONFIG') { + if (event.data?.type === RSTEST_CONFIG_MESSAGE_TYPE) { window.__RSTEST_BROWSER_OPTIONS__ = event.data.payload; debugLog( '[Runner] Received config from container:', @@ -369,6 +367,7 @@ const run = async () => { // Support reading testFile and testNamePattern from URL parameters const urlParams = new URLSearchParams(window.location.search); const urlTestFile = urlParams.get('testFile'); + const urlRunId = urlParams.get('runId'); const urlTestNamePattern = urlParams.get('testNamePattern'); if (urlTestFile && options) { @@ -379,6 +378,13 @@ const run = async () => { }; } + if (urlRunId && options) { + options = { + ...options, + runId: urlRunId, + }; + } + // Override testNamePattern from URL parameter if provided if (urlTestNamePattern && options) { options = { diff --git a/packages/browser/src/client/locator.ts b/packages/browser/src/client/locator.ts new file mode 100644 index 000000000..8028ff4fa --- /dev/null +++ b/packages/browser/src/client/locator.ts @@ -0,0 +1,452 @@ +import type { + BrowserLocatorIR, + BrowserLocatorText, + BrowserRpcRequest, +} from '../rpcProtocol'; +import { callBrowserRpc } from './browserRpc'; + +export const serializeText = (value: string | RegExp): BrowserLocatorText => { + if (typeof value === 'string') { + return { type: 'string', value }; + } + return { type: 'regexp', source: value.source, flags: value.flags }; +}; + +export type LocatorGetByRoleOptions = { + name?: string | RegExp; + exact?: boolean; + checked?: boolean; + disabled?: boolean; + expanded?: boolean; + selected?: boolean; + pressed?: boolean; + includeHidden?: boolean; + level?: number; +}; + +export type LocatorTextOptions = { + exact?: boolean; +}; + +export type LocatorKeyboardModifier = + | 'Alt' + | 'Control' + | 'ControlOrMeta' + | 'Meta' + | 'Shift'; + +export type LocatorMouseButton = 'left' | 'right' | 'middle'; + +export type LocatorPosition = { + x: number; + y: number; +}; + +export type LocatorClickOptions = { + button?: LocatorMouseButton; + clickCount?: number; + delay?: number; + force?: boolean; + modifiers?: LocatorKeyboardModifier[]; + position?: LocatorPosition; + timeout?: number; + trial?: boolean; +}; + +export type LocatorDblclickOptions = Omit; + +export type LocatorHoverOptions = Pick< + LocatorClickOptions, + 'force' | 'modifiers' | 'position' | 'timeout' | 'trial' +>; + +export type LocatorPressOptions = { + delay?: number; + timeout?: number; +}; + +export type LocatorFillOptions = { + force?: boolean; + timeout?: number; +}; + +export type LocatorCheckOptions = { + force?: boolean; + position?: LocatorPosition; + timeout?: number; + trial?: boolean; +}; + +export type LocatorFocusOptions = { + timeout?: number; +}; + +export type LocatorBlurOptions = { + timeout?: number; +}; + +export type LocatorScrollIntoViewIfNeededOptions = { + timeout?: number; +}; + +export type LocatorWaitForOptions = { + state?: 'attached' | 'detached' | 'visible' | 'hidden'; + timeout?: number; +}; + +export type BrowserSerializable = + | null + | boolean + | number + | string + | BrowserSerializable[] + | { [key: string]: BrowserSerializable }; + +export type LocatorDispatchEventInit = BrowserSerializable; + +export type LocatorSelectOptionOptions = { + force?: boolean; + timeout?: number; +}; + +export type LocatorSetInputFilesOptions = { + timeout?: number; +}; + +export type LocatorFilterOptions = { + hasText?: string | RegExp; + hasNotText?: string | RegExp; + has?: Locator; + hasNot?: Locator; +}; + +export class Locator { + readonly ir: BrowserLocatorIR; + + constructor(ir: BrowserLocatorIR) { + this.ir = ir; + } + + getByRole(role: string, options?: LocatorGetByRoleOptions): Locator { + const next = { + steps: [ + ...this.ir.steps, + { + type: 'getByRole', + role, + options: options + ? { + ...options, + name: + options.name === undefined + ? undefined + : serializeText(options.name), + } + : undefined, + }, + ], + } satisfies BrowserLocatorIR; + return new Locator(next); + } + + locator(selector: string): Locator { + return new Locator({ + steps: [...this.ir.steps, { type: 'locator', selector }], + }); + } + + getByText(text: string | RegExp, options?: LocatorTextOptions): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { type: 'getByText', text: serializeText(text), options }, + ], + }); + } + + getByLabel(text: string | RegExp, options?: LocatorTextOptions): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { type: 'getByLabel', text: serializeText(text), options }, + ], + }); + } + + getByPlaceholder( + text: string | RegExp, + options?: LocatorTextOptions, + ): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { type: 'getByPlaceholder', text: serializeText(text), options }, + ], + }); + } + + getByAltText(text: string | RegExp, options?: LocatorTextOptions): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { type: 'getByAltText', text: serializeText(text), options }, + ], + }); + } + + getByTitle(text: string | RegExp, options?: LocatorTextOptions): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { type: 'getByTitle', text: serializeText(text), options }, + ], + }); + } + + getByTestId(text: string | RegExp): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { type: 'getByTestId', text: serializeText(text) }, + ], + }); + } + + filter(options: LocatorFilterOptions): Locator { + return new Locator({ + steps: [ + ...this.ir.steps, + { + type: 'filter', + options: { + hasText: options.hasText + ? serializeText(options.hasText) + : undefined, + hasNotText: options.hasNotText + ? serializeText(options.hasNotText) + : undefined, + has: + options.has === undefined + ? undefined + : isLocator(options.has) + ? options.has.ir + : (() => { + throw new TypeError( + 'Locator.filter({ has }) expects a Locator returned from @rstest/browser page.getBy* APIs.', + ); + })(), + hasNot: + options.hasNot === undefined + ? undefined + : isLocator(options.hasNot) + ? options.hasNot.ir + : (() => { + throw new TypeError( + 'Locator.filter({ hasNot }) expects a Locator returned from @rstest/browser page.getBy* APIs.', + ); + })(), + }, + }, + ], + }); + } + + and(other: Locator): Locator { + if (!isLocator(other)) { + throw new TypeError( + 'Locator.and() expects a Locator returned from @rstest/browser page.getBy* APIs.', + ); + } + return new Locator({ + steps: [...this.ir.steps, { type: 'and', locator: other.ir }], + }); + } + + or(other: Locator): Locator { + if (!isLocator(other)) { + throw new TypeError( + 'Locator.or() expects a Locator returned from @rstest/browser page.getBy* APIs.', + ); + } + return new Locator({ + steps: [...this.ir.steps, { type: 'or', locator: other.ir }], + }); + } + + nth(index: number): Locator { + return new Locator({ steps: [...this.ir.steps, { type: 'nth', index }] }); + } + + first(): Locator { + return new Locator({ steps: [...this.ir.steps, { type: 'first' }] }); + } + + last(): Locator { + return new Locator({ steps: [...this.ir.steps, { type: 'last' }] }); + } + + async click(options?: LocatorClickOptions): Promise { + await this.callLocator('click', options === undefined ? [] : [options]); + } + + async dblclick(options?: LocatorDblclickOptions): Promise { + await this.callLocator('dblclick', options === undefined ? [] : [options]); + } + + async fill(value: string, options?: LocatorFillOptions): Promise { + await this.callLocator( + 'fill', + options === undefined ? [value] : [value, options], + ); + } + + async hover(options?: LocatorHoverOptions): Promise { + await this.callLocator('hover', options === undefined ? [] : [options]); + } + + async press(key: string, options?: LocatorPressOptions): Promise { + await this.callLocator( + 'press', + options === undefined ? [key] : [key, options], + ); + } + + async clear(): Promise { + await this.callLocator('clear', []); + } + + async check(options?: LocatorCheckOptions): Promise { + await this.callLocator('check', options === undefined ? [] : [options]); + } + + async uncheck(options?: LocatorCheckOptions): Promise { + await this.callLocator('uncheck', options === undefined ? [] : [options]); + } + + async focus(options?: LocatorFocusOptions): Promise { + await this.callLocator('focus', options === undefined ? [] : [options]); + } + + async blur(options?: LocatorBlurOptions): Promise { + await this.callLocator('blur', options === undefined ? [] : [options]); + } + + async scrollIntoViewIfNeeded( + options?: LocatorScrollIntoViewIfNeededOptions, + ): Promise { + await this.callLocator( + 'scrollIntoViewIfNeeded', + options === undefined ? [] : [options], + ); + } + + async waitFor(options?: LocatorWaitForOptions): Promise { + await this.callLocator('waitFor', options === undefined ? [] : [options]); + } + + async dispatchEvent( + type: string, + eventInit?: LocatorDispatchEventInit, + ): Promise { + if (typeof type !== 'string' || !type) { + throw new TypeError( + 'Locator.dispatchEvent() expects a non-empty event type string.', + ); + } + await this.callLocator( + 'dispatchEvent', + eventInit === undefined ? [type] : [type, eventInit], + ); + } + + async selectOption( + value: string | string[], + options?: LocatorSelectOptionOptions, + ): Promise { + if ( + typeof value !== 'string' && + !(Array.isArray(value) && value.every((v) => typeof v === 'string')) + ) { + throw new TypeError( + 'Locator.selectOption() only supports string or string[] values in browser mode.', + ); + } + await this.callLocator( + 'selectOption', + options === undefined ? [value] : [value, options], + ); + } + + async setInputFiles( + files: string | string[], + options?: LocatorSetInputFilesOptions, + ): Promise { + if ( + typeof files !== 'string' && + !(Array.isArray(files) && files.every((v) => typeof v === 'string')) + ) { + throw new TypeError( + 'Locator.setInputFiles() only supports file path string or string[] in browser mode.', + ); + } + await this.callLocator( + 'setInputFiles', + options === undefined ? [files] : [files, options], + ); + } + + private async callLocator(method: string, args: unknown[]): Promise { + await callBrowserRpc({ + kind: 'locator', + locator: this.ir, + method, + args, + } satisfies Omit); + } +} + +const browserPageQueryMethods = [ + 'locator', + 'getByRole', + 'getByText', + 'getByLabel', + 'getByPlaceholder', + 'getByAltText', + 'getByTitle', + 'getByTestId', +] as const; + +type BrowserPageQueryMethod = (typeof browserPageQueryMethods)[number]; + +export type BrowserPage = Pick; + +const rootLocator = new Locator({ steps: [] }); + +const createBrowserPage = (): BrowserPage => { + return Object.fromEntries( + browserPageQueryMethods.map((methodName) => { + return [methodName, rootLocator[methodName].bind(rootLocator)]; + }), + ) as BrowserPage; +}; + +export const page: BrowserPage = createBrowserPage(); + +export const isLocator = (value: unknown): value is Locator => { + return value instanceof Locator; +}; + +/** + * Configure the attribute used by `getByTestId()` queries. + * Forwards to the host provider (e.g. Playwright `selectors.setTestIdAttribute()`). + * + * @default 'data-testid' + */ +export const setTestIdAttribute = async (attribute: string): Promise => { + await callBrowserRpc({ + kind: 'config', + locator: { steps: [] }, + method: 'setTestIdAttribute', + args: [attribute], + } satisfies Omit); +}; diff --git a/packages/browser/src/client/snapshot.ts b/packages/browser/src/client/snapshot.ts index 2134ad8e9..e7354c36e 100644 --- a/packages/browser/src/client/snapshot.ts +++ b/packages/browser/src/client/snapshot.ts @@ -1,95 +1,13 @@ -import type { - BrowserDispatchRequest, - BrowserDispatchResponse, - BrowserHostConfig, - SnapshotRpcRequest, -} from '../protocol'; +import type { BrowserDispatchRequest, SnapshotRpcRequest } from '../protocol'; +import { DISPATCH_NAMESPACE_SNAPSHOT } from '../protocol'; +import { + createRequestId, + dispatchRpc, + getRpcTimeout, +} from './dispatchTransport'; import { mapStackFrame } from './sourceMapSupport'; -declare global { - interface Window { - __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig; - __rstest_dispatch_rpc__?: ( - request: BrowserDispatchRequest, - ) => Promise; - } -} - const SNAPSHOT_HEADER = '// Rstest Snapshot'; -const DISPATCH_RESPONSE_TYPE = '__rstest_dispatch_response__'; - -/** Default RPC timeout if not specified in config (30 seconds) */ -const DEFAULT_RPC_TIMEOUT_MS = 30_000; - -/** - * Get RPC timeout from browser options or use default. - */ -const getRpcTimeout = (): number => { - return ( - window.__RSTEST_BROWSER_OPTIONS__?.rpcTimeout ?? DEFAULT_RPC_TIMEOUT_MS - ); -}; - -/** - * Pending RPC requests waiting for responses from the container. - */ -const pendingRequests = new Map< - string, - { - resolve: (value: unknown) => void; - reject: (error: Error) => void; - } ->(); - -let requestIdCounter = 0; -let messageListenerInitialized = false; - -const isDispatchResponse = ( - value: unknown, -): value is BrowserDispatchResponse => { - return ( - typeof value === 'object' && - value !== null && - 'requestId' in value && - typeof (value as { requestId: unknown }).requestId === 'string' - ); -}; - -const settlePendingRequest = (response: BrowserDispatchResponse): void => { - const pending = pendingRequests.get(response.requestId); - if (!pending) { - return; - } - - pendingRequests.delete(response.requestId); - if (response.stale) { - pending.reject(new Error('Stale snapshot RPC request ignored.')); - return; - } - if (response.error) { - pending.reject(new Error(response.error)); - return; - } - pending.resolve(response.result); -}; - -/** - * Initialize the message listener for snapshot RPC responses. - * This is called once when the first RPC request is made. - */ -const initMessageListener = (): void => { - if (messageListenerInitialized) { - return; - } - messageListenerInitialized = true; - - window.addEventListener('message', (event: MessageEvent) => { - if (event.data?.type === DISPATCH_RESPONSE_TYPE) { - const response = event.data.payload as BrowserDispatchResponse; - settlePendingRequest(response); - } - }); -}; const createSnapshotDispatchRequest = ( requestId: string, @@ -100,34 +18,12 @@ const createSnapshotDispatchRequest = ( // Keep this mapping explicit so new runner-side RPC clients can mirror it. return { requestId, - namespace: 'snapshot', + namespace: DISPATCH_NAMESPACE_SNAPSHOT, method, args, }; }; -const unwrapDispatchBridgeResult = ( - requestId: string, - result: unknown, -): T => { - if (!isDispatchResponse(result)) { - throw new Error('Invalid dispatch bridge response payload.'); - } - - if (result.requestId !== requestId) { - throw new Error( - `Mismatched dispatch response id: expected ${requestId}, got ${result.requestId}`, - ); - } - if (result.stale) { - throw new Error('Stale snapshot RPC request ignored.'); - } - if (result.error) { - throw new Error(result.error); - } - return result.result as T; -}; - /** * Send a snapshot RPC request to the container (parent window). * The container will forward it to the host via WebSocket RPC. @@ -136,7 +32,7 @@ const sendRpcRequest = ( method: SnapshotRpcRequest['method'], args: SnapshotRpcRequest['args'], ): Promise => { - const requestId = `snapshot-rpc-${++requestIdCounter}`; + const requestId = createRequestId('snapshot-rpc'); const rpcTimeout = getRpcTimeout(); const dispatchRequest = createSnapshotDispatchRequest( requestId, @@ -144,75 +40,12 @@ const sendRpcRequest = ( args, ); - if (window.parent === window) { - // Headless top-level runner path: all RPC namespaces go through one bridge. - const dispatchBridge = window.__rstest_dispatch_rpc__; - if (!dispatchBridge) { - return Promise.reject( - new Error('Dispatch RPC bridge is not available in top-level runner.'), - ); - } - - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject( - new Error( - `Snapshot RPC timeout after ${rpcTimeout / 1000}s: ${method}`, - ), - ); - }, rpcTimeout); - - const call = Promise.resolve(dispatchBridge(dispatchRequest)).then( - (result) => unwrapDispatchBridgeResult(requestId, result), - ); - - call - .then((result) => { - clearTimeout(timeoutId); - resolve(result); - }) - .catch((error) => { - clearTimeout(timeoutId); - reject(error instanceof Error ? error : new Error(String(error))); - }); - }); - } - - initMessageListener(); - - return new Promise((resolve, reject) => { - // Set a timeout for the RPC call - const timeoutId = setTimeout(() => { - pendingRequests.delete(requestId); - reject( - new Error( - `Snapshot RPC timeout after ${rpcTimeout / 1000}s: ${method}`, - ), - ); - }, rpcTimeout); - - pendingRequests.set(requestId, { - resolve: (value) => { - clearTimeout(timeoutId); - resolve(value as T); - }, - reject: (error) => { - clearTimeout(timeoutId); - reject(error); - }, - }); - - // Send request to parent window (container) - window.parent.postMessage( - { - type: '__rstest_dispatch__', - payload: { - type: 'dispatch-rpc-request', - payload: dispatchRequest, - }, - }, - '*', - ); + return dispatchRpc({ + requestId, + request: dispatchRequest, + timeoutMs: rpcTimeout, + staleMessage: 'Stale snapshot RPC request ignored.', + timeoutMessage: `Snapshot RPC timeout after ${rpcTimeout / 1000}s: ${method}`, }); }; diff --git a/packages/browser/src/env.d.ts b/packages/browser/src/env.d.ts index e63061e6d..cd8b4c115 100644 --- a/packages/browser/src/env.d.ts +++ b/packages/browser/src/env.d.ts @@ -1,4 +1,8 @@ -import type { BrowserClientMessage, BrowserHostConfig } from './protocol'; +import type { + BrowserClientMessage, + BrowserDispatchRequest, + BrowserHostConfig, +} from './protocol'; declare module '@rstest/browser-manifest' { export type ManifestProjectConfig = { @@ -35,6 +39,9 @@ declare global { interface Window { __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig; __rstest_dispatch__?: (message: BrowserClientMessage) => void; + __rstest_dispatch_rpc__?: ( + request: BrowserDispatchRequest, + ) => Promise; __rstest_container_dispatch__?: (data: unknown) => void; __rstest_container_on__?: (data: unknown) => void; __RSTEST_DONE__?: boolean; diff --git a/packages/browser/src/headlessTransport.ts b/packages/browser/src/headlessTransport.ts index 96a424abc..eca2bc9c6 100644 --- a/packages/browser/src/headlessTransport.ts +++ b/packages/browser/src/headlessTransport.ts @@ -1,9 +1,10 @@ -import type { Page } from 'playwright'; import type { BrowserClientMessage, BrowserDispatchRequest, BrowserDispatchResponse, } from './protocol'; +import { DISPATCH_MESSAGE_TYPE, DISPATCH_RPC_BRIDGE_NAME } from './protocol'; +import type { BrowserProviderPage } from './providers'; type HeadlessRunnerTransportHandlers = { onDispatchMessage: (message: BrowserClientMessage) => Promise; @@ -17,11 +18,11 @@ type HeadlessRunnerTransportHandlers = { * This only binds page bridge functions and delegates all scheduling decisions upstream. */ export const attachHeadlessRunnerTransport = async ( - page: Page, + page: BrowserProviderPage, handlers: HeadlessRunnerTransportHandlers, ): Promise => { // Fire-and-forget runner lifecycle messages (ready/log/result/fatal). - await page.exposeFunction('__rstest_dispatch__', handlers.onDispatchMessage); + await page.exposeFunction(DISPATCH_MESSAGE_TYPE, handlers.onDispatchMessage); // Request/response RPC bridge shared by snapshot and future namespaces. - await page.exposeFunction('__rstest_dispatch_rpc__', handlers.onDispatchRpc); + await page.exposeFunction(DISPATCH_RPC_BRIDGE_NAME, handlers.onDispatchRpc); }; diff --git a/packages/browser/src/hostController.ts b/packages/browser/src/hostController.ts index 7b7a50571..e34308d3a 100644 --- a/packages/browser/src/hostController.ts +++ b/packages/browser/src/hostController.ts @@ -30,7 +30,6 @@ import { type BirpcReturn, createBirpc } from 'birpc'; import openEditor from 'open-editor'; import { basename, dirname, join, normalize, relative, resolve } from 'pathe'; import * as picomatch from 'picomatch'; -import type { BrowserContext, ConsoleMessage, Page } from 'playwright'; import sirv from 'sirv'; import { type WebSocket, WebSocketServer } from 'ws'; import { getHeadlessConcurrency } from './concurrency'; @@ -47,10 +46,24 @@ import type { BrowserDispatchResponse, BrowserHostConfig, BrowserProjectRuntime, + BrowserRpcRequest, BrowserViewport, SnapshotRpcRequest, TestFileInfo, } from './protocol'; +import { + DISPATCH_MESSAGE_TYPE, + DISPATCH_NAMESPACE_RUNNER, + validateBrowserRpcRequest, +} from './protocol'; +import { + type BrowserProvider, + type BrowserProviderBrowser, + type BrowserProviderContext, + type BrowserProviderImplementation, + type BrowserProviderPage, + getBrowserProviderImplementation, +} from './providers'; import { createRunSession, type RunSession, @@ -92,16 +105,22 @@ type VirtualModulesPluginInstance = InstanceType< (typeof rspack.experiments)['VirtualModulesPlugin'] >; -type PlaywrightModule = typeof import('playwright'); -type BrowserType = PlaywrightModule['chromium']; -type BrowserInstance = Awaited>; - type BrowserProjectEntries = { project: ProjectContext; setupFiles: string[]; testFiles: string[]; }; +type BrowserProviderProject = { + rootPath: string; + provider: BrowserProvider; +}; + +type BrowserLaunchOptions = Pick< + ProjectContext['normalizedConfig']['browser'], + 'provider' | 'browser' | 'headless' | 'port' | 'strictPort' +>; + /** Payload for test file start event */ type TestFileStartPayload = { testPath: string; @@ -271,14 +290,14 @@ class ContainerRpcManager { type BrowserRuntime = { rsbuildInstance: RsbuildInstance; devServer: RsbuildDevServer; - browser: BrowserInstance; + browser: BrowserProviderBrowser; port: number; wsPort: number; manifestPath: string; tempDir: string; manifestPlugin: VirtualModulesPluginInstance; - containerPage?: Page; - containerContext?: BrowserContext; + containerPage?: BrowserProviderPage; + containerContext?: BrowserProviderContext; setContainerOptions: (options: BrowserHostConfig) => void; // Reserved extension seam for host-side dispatch capabilities. dispatchHandlers: Map; @@ -610,6 +629,69 @@ const getBrowserProjects = (context: Rstest): ProjectContext[] => { ); }; +const getBrowserLaunchOptions = ( + project: ProjectContext, +): BrowserLaunchOptions => ({ + provider: project.normalizedConfig.browser.provider, + browser: project.normalizedConfig.browser.browser, + headless: project.normalizedConfig.browser.headless, + port: project.normalizedConfig.browser.port, + strictPort: project.normalizedConfig.browser.strictPort, +}); + +const ensureConsistentBrowserLaunchOptions = ( + projects: ProjectContext[], +): BrowserLaunchOptions => { + if (projects.length === 0) { + throw new Error('No browser-enabled projects found.'); + } + + const firstProject = projects[0]!; + const firstOptions = getBrowserLaunchOptions(firstProject); + + for (const project of projects.slice(1)) { + const options = getBrowserLaunchOptions(project); + if ( + options.provider !== firstOptions.provider || + options.browser !== firstOptions.browser || + options.headless !== firstOptions.headless || + options.port !== firstOptions.port || + options.strictPort !== firstOptions.strictPort + ) { + throw new Error( + `Browser launch config mismatch between projects "${firstProject.name}" and "${project.name}". ` + + 'All browser-enabled projects in one run must share provider/browser/headless/port/strictPort.', + ); + } + } + + return firstOptions; +}; + +const resolveProviderForTestPath = ({ + testPath, + browserProjects, +}: { + testPath: string; + browserProjects: BrowserProviderProject[]; +}): BrowserProvider => { + const normalizedTestPath = normalize(testPath); + const sortedProjects = [...browserProjects].sort( + (a, b) => b.rootPath.length - a.rootPath.length, + ); + + for (const project of sortedProjects) { + if (normalizedTestPath.startsWith(project.rootPath)) { + return project.provider; + } + } + + throw new Error( + `Cannot resolve browser provider for test path: ${JSON.stringify(testPath)}. ` + + `Known project roots: ${JSON.stringify(sortedProjects.map((p) => p.rootPath))}`, + ); +}; + const collectProjectEntries = async ( context: Rstest, ): Promise => { @@ -909,13 +991,15 @@ const createBrowserRuntime = async ({ } }; - // Get user Rsbuild config from the first browser project const browserProjects = getBrowserProjects(context); - const firstProject = browserProjects[0]; - const userPlugins = firstProject?.normalizedConfig.plugins || []; - const userRsbuildConfig = firstProject?.normalizedConfig ?? {}; - const browserConfig = - firstProject?.normalizedConfig.browser ?? context.normalizedConfig.browser; + const projectByEnvironmentName = new Map( + browserProjects.map((project) => [project.environmentName, project]), + ); + const userPlugins = browserProjects.flatMap( + (project) => project.normalizedConfig.plugins || [], + ); + const browserLaunchOptions = + ensureConsistentBrowserLaunchOptions(browserProjects); // Rstest internal aliases that must not be overridden by user config const browserRuntimePath = fileURLToPath( @@ -926,6 +1010,8 @@ const createBrowserRuntime = async ({ '@rstest/browser-manifest': manifestPath, // User test code: import { describe, it } from '@rstest/core' '@rstest/core': resolveBrowserFile('client/public.ts'), + // User test code: import { page } from '@rstest/browser' + '@rstest/browser': resolveBrowserFile('browser.ts'), // Browser runtime APIs for entry.ts and public.ts // Uses dist file with extractSourceMap to preserve sourcemap chain for inline snapshots '@rstest/core/browser-runtime': browserRuntimePath, @@ -940,8 +1026,8 @@ const createBrowserRuntime = async ({ plugins: userPlugins, server: { printUrls: false, - port: browserConfig.port ?? 4000, - strictPort: browserConfig.strictPort, + port: browserLaunchOptions.port ?? 4000, + strictPort: browserLaunchOptions.strictPort, }, dev: { client: { @@ -949,7 +1035,9 @@ const createBrowserRuntime = async ({ }, }, environments: { - [firstProject?.environmentName || 'web']: {}, + ...Object.fromEntries( + browserProjects.map((project) => [project.environmentName, {}]), + ), }, }, }); @@ -974,7 +1062,13 @@ const createBrowserRuntime = async ({ ); api.modifyEnvironmentConfig({ - handler: (config, { mergeEnvironmentConfig }) => { + handler: (config, { mergeEnvironmentConfig, name }) => { + const project = projectByEnvironmentName.get(name); + if (!project) { + return config; + } + + const userRsbuildConfig = project.normalizedConfig; // Merge order: current config -> userConfig -> rstest required config (highest priority) const merged = mergeEnvironmentConfig(config, userRsbuildConfig, { resolve: { @@ -1090,7 +1184,9 @@ const createBrowserRuntime = async ({ } // Register coverage plugin for browser mode - const coverage = firstProject?.normalizedConfig.coverage; + const coverage = browserProjects.find( + (project) => project.normalizedConfig.coverage?.enabled, + )?.normalizedConfig.coverage; if (coverage?.enabled && context.command !== 'list') { const { pluginCoverage } = await loadCoverageProvider( coverage, @@ -1264,50 +1360,33 @@ const createBrowserRuntime = async ({ const wsPort = (wss.address() as AddressInfo).port; logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`); - let browserLauncher: BrowserType; - const browserName = browserConfig.browser; - try { - const playwright = await import('playwright'); - browserLauncher = playwright[browserName]; - } catch (_error) { - wss.close(); - await devServer.close(); - throw _error; - } - - let browser: BrowserInstance; + const browserName = browserLaunchOptions.browser ?? 'chromium'; try { - browser = await browserLauncher.launch({ - headless: forceHeadless ?? browserConfig.headless, - // Chromium-specific args (ignored by other browsers) - args: - browserName === 'chromium' - ? [ - '--disable-popup-blocking', - '--no-first-run', - '--no-default-browser-check', - ] - : undefined, + const providerImplementation = getBrowserProviderImplementation( + browserLaunchOptions.provider, + ); + const runtime = await providerImplementation.launchRuntime({ + browserName, + headless: forceHeadless ?? browserLaunchOptions.headless, }); + return { + rsbuildInstance, + devServer, + browser: runtime.browser, + port, + wsPort, + manifestPath, + tempDir, + manifestPlugin: virtualManifestPlugin, + setContainerOptions, + dispatchHandlers, + wss, + }; } catch (_error) { wss.close(); await devServer.close(); throw _error; } - - return { - rsbuildInstance, - devServer, - browser, - port, - wsPort, - manifestPath, - tempDir, - manifestPlugin: virtualManifestPlugin, - setContainerOptions, - dispatchHandlers, - wss, - }; }; async function resolveProjectEntries( @@ -1656,6 +1735,87 @@ export const runBrowserController = async ( rpcTimeout: maxTestTimeoutForRpc, }; + const browserProviderProjects: BrowserProviderProject[] = browserProjects.map( + (project) => ({ + rootPath: normalize(project.rootPath), + provider: project.normalizedConfig.browser.provider, + }), + ); + const implementationByProvider = new Map< + BrowserProvider, + BrowserProviderImplementation + >(); + for (const browserProject of browserProviderProjects) { + if (!implementationByProvider.has(browserProject.provider)) { + implementationByProvider.set( + browserProject.provider, + getBrowserProviderImplementation(browserProject.provider), + ); + } + } + + let activeContainerPage: BrowserProviderPage | null = null; + let getHeadlessRunnerPageBySessionId: + | ((sessionId: string) => BrowserProviderPage | undefined) + | undefined; + + const dispatchBrowserRpcRequest = async ({ + request, + target, + }: { + request: BrowserRpcRequest; + target?: BrowserDispatchRequest['target']; + }): Promise => { + const timeoutFallbackMs = maxTestTimeoutForRpc; + const provider = resolveProviderForTestPath({ + testPath: request.testPath, + browserProjects: browserProviderProjects, + }); + const implementation = implementationByProvider.get(provider); + if (!implementation) { + throw new Error(`Browser provider implementation not found: ${provider}`); + } + + const runnerPage = target?.sessionId + ? getHeadlessRunnerPageBySessionId?.(target.sessionId) + : undefined; + + if (target?.sessionId && !runnerPage) { + throw new Error( + `Runner page session not found for browser dispatch: ${target.sessionId}`, + ); + } + + if (!runnerPage && !activeContainerPage) { + throw new Error('Browser container page is not initialized'); + } + + try { + return await implementation.dispatchRpc({ + containerPage: runnerPage + ? undefined + : (activeContainerPage ?? undefined), + runnerPage, + request, + timeoutFallbackMs, + }); + } catch (error) { + // birpc serializes thrown Errors as `{}` over JSON; throw a string instead. + if (error instanceof Error) { + throw error.message; + } + throw String(error); + } + }; + + runtime.dispatchHandlers.set('browser', async (dispatchRequest) => { + const request = validateBrowserRpcRequest(dispatchRequest.args); + return dispatchBrowserRpcRequest({ + request, + target: dispatchRequest.target, + }); + }); + runtime.setContainerOptions(hostOptions); // Track test results from browser runners @@ -1850,12 +2010,15 @@ export const runBrowserController = async ( if (useHeadlessDirect) { // Session-based scheduling path: lifecycle + session index + dispatch routing. type ActiveHeadlessRun = RunSession & { - contexts: Set; + contexts: Set; }; const viewportByProject = mapViewportByProject(projectRuntimeConfigs); const runLifecycle = new RunSessionLifecycle(); const sessionRegistry = new RunnerSessionRegistry(); + getHeadlessRunnerPageBySessionId = (sessionId) => { + return sessionRegistry.getById(sessionId)?.page; + }; let dispatchRequestCounter = 0; const nextDispatchRequestId = (namespace: string): string => { @@ -1863,7 +2026,7 @@ export const runBrowserController = async ( }; const closeContextSafely = async ( - browserContext: BrowserContext, + browserContext: BrowserProviderContext, ): Promise => { try { await browserContext.close(); @@ -1891,7 +2054,7 @@ export const runBrowserController = async ( const dispatchRouter = createDispatchRouter({ isRunTokenStale: (runToken) => runLifecycle.isTokenStale(runToken), onStale: (request) => { - if (request.namespace === 'runner') { + if (request.namespace === DISPATCH_NAMESPACE_RUNNER) { logger.debug( `[Headless] Dropped stale message "${request.method}" for ${request.target?.testFile ?? 'unknown'}`, ); @@ -1908,7 +2071,7 @@ export const runBrowserController = async ( const response = await dispatchRouter.dispatch({ requestId: nextDispatchRequestId('runner'), runToken: run.token, - namespace: 'runner', + namespace: DISPATCH_NAMESPACE_RUNNER, method: message.type, args: 'payload' in message ? message.payload : undefined, target: { @@ -1941,7 +2104,7 @@ export const runBrowserController = async ( }); run.contexts.add(browserContext); - let page: Page | null = null; + let page: BrowserProviderPage | null = null; let sessionId: string | null = null; let settled = false; let resolveDone: (() => void) | null = null; @@ -2019,6 +2182,7 @@ export const runBrowserController = async ( const inlineOptions: BrowserHostConfig = { ...hostOptions, testFile: file.testPath, + runId: `${run.token}:${session.id}`, }; const serializedOptions = serializeForInlineScript(inlineOptions); await page.addInitScript( @@ -2093,7 +2257,7 @@ export const runBrowserController = async ( const run = runLifecycle.createSession((token) => ({ ...createRunSession(token), - contexts: new Set(), + contexts: new Set(), })); const queue = [...files]; @@ -2306,8 +2470,8 @@ export const runBrowserController = async ( }); // Open a container page for user to view (reuse in watch mode) - let containerContext: BrowserContext; - let containerPage: Page; + let containerContext: BrowserProviderContext; + let containerPage: BrowserProviderPage; let isNewPage = false; if (isWatchMode && runtime.containerPage && runtime.containerContext) { @@ -2322,11 +2486,11 @@ export const runBrowserController = async ( containerPage = await containerContext.newPage(); // Prevent popup windows from being created - containerPage.on('popup', async (popup: Page) => { + containerPage.on('popup', async (popup: BrowserProviderPage) => { await popup.close().catch(() => {}); }); - containerContext.on('page', async (page: Page) => { + containerContext.on('page', async (page: BrowserProviderPage) => { if (page !== containerPage) { await page.close().catch(() => {}); } @@ -2338,7 +2502,7 @@ export const runBrowserController = async ( } // Forward browser console to terminal - containerPage.on('console', (msg: ConsoleMessage) => { + containerPage.on('console', (msg) => { const text = msg.text(); if (text.startsWith('[Container]') || text.startsWith('[Runner]')) { logger.log(color.gray(`[Browser Console] ${text}`)); @@ -2346,6 +2510,8 @@ export const runBrowserController = async ( }); } + activeContainerPage = containerPage; + const dispatchRouter = createDispatchRouter(); // Create RPC methods that can access test state variables @@ -2641,6 +2807,7 @@ export const listBrowserTests = async ( manifestPath, entries: projectEntries, }); + const browserProjects = getBrowserProjects(context); // Create a simplified browser runtime for collect mode let runtime: BrowserRuntime; @@ -2656,9 +2823,14 @@ export const listBrowserTests = async ( forceHeadless: true, // Always use headless for list command }); } catch (error) { + const providers = [ + ...new Set( + browserProjects.map((p) => p.normalizedConfig.browser.provider), + ), + ]; logger.error( color.red( - 'Failed to load Playwright. Please install "playwright" to use browser mode.', + `Failed to initialize browser provider runtime (${providers.join(', ')}).`, ), error, ); @@ -2669,7 +2841,6 @@ export const listBrowserTests = async ( // Get browser projects for runtime config // Normalize projectRoot to posix format for cross-platform compatibility - const browserProjects = getBrowserProjects(context); const projectRuntimeConfigs: BrowserProjectRuntime[] = browserProjects.map( (project: ProjectContext) => ({ name: project.name, @@ -2715,7 +2886,7 @@ export const listBrowserTests = async ( // Expose dispatch function for browser client to send messages await page.exposeFunction( - '__rstest_dispatch__', + DISPATCH_MESSAGE_TYPE, (message: { type: string; payload?: unknown }) => { switch (message.type) { case 'collect-result': { diff --git a/packages/browser/src/protocol.ts b/packages/browser/src/protocol.ts index 36ca1ffda..209460da5 100644 --- a/packages/browser/src/protocol.ts +++ b/packages/browser/src/protocol.ts @@ -7,6 +7,28 @@ import type { } from '@rstest/core/browser-runtime'; import type { SnapshotUpdateState } from '@vitest/snapshot'; +export type { + BrowserLocatorIR, + BrowserLocatorStep, + BrowserLocatorText, + BrowserRpcRequest, + BrowserRpcResponse, + SnapshotRpcRequest, + SnapshotRpcResponse, +} from './rpcProtocol'; +export { validateBrowserRpcRequest } from './rpcProtocol'; + +export const DISPATCH_MESSAGE_TYPE = '__rstest_dispatch__'; +export const DISPATCH_RESPONSE_TYPE = '__rstest_dispatch_response__'; +export const DISPATCH_RPC_BRIDGE_NAME = '__rstest_dispatch_rpc__'; +export const DISPATCH_RPC_REQUEST_TYPE = 'dispatch-rpc-request'; +export const RSTEST_CONFIG_MESSAGE_TYPE = 'RSTEST_CONFIG'; + +export const DISPATCH_NAMESPACE_RUNNER = 'runner'; +export const DISPATCH_NAMESPACE_BROWSER = 'browser'; +export const DISPATCH_NAMESPACE_SNAPSHOT = 'snapshot'; +export const DISPATCH_METHOD_RPC = 'rpc'; + export type SerializedRuntimeConfig = RuntimeConfig; export type BrowserViewport = @@ -47,6 +69,11 @@ export type BrowserHostConfig = { updateSnapshot: SnapshotUpdateState; }; testFile?: string; // Optional: if provided, only run this specific test file + /** + * Per-run identifier assigned by the container. + * Used by browser RPC calls to prevent stale requests from previous reruns. + */ + runId?: string; /** * Base URL for runner (iframe) pages. */ @@ -103,36 +130,10 @@ export type BrowserClientMessage = // Snapshot already uses this path via namespace "snapshot". Future PR #948 // capabilities can add new namespaces instead of adding new message types. | { - type: 'dispatch-rpc-request'; + type: typeof DISPATCH_RPC_REQUEST_TYPE; payload: BrowserDispatchRequest; }; -/** - * Snapshot RPC request from runner iframe. - * The container will forward these to the host via WebSocket RPC. - */ -export type SnapshotRpcRequest = - | { - id: string; - method: 'resolveSnapshotPath'; - args: { testPath: string }; - } - | { - id: string; - method: 'readSnapshotFile'; - args: { filepath: string }; - } - | { - id: string; - method: 'saveSnapshotFile'; - args: { filepath: string; content: string }; - } - | { - id: string; - method: 'removeSnapshotFile'; - args: { filepath: string }; - }; - /** * Transport-agnostic envelope used by host routing. * `namespace + method + args + target` describes an operation independent of @@ -166,7 +167,7 @@ export type BrowserDispatchResponse = { }; export type BrowserDispatchResponseEnvelope = { - type: '__rstest_dispatch_response__'; + type: typeof DISPATCH_RESPONSE_TYPE; payload: BrowserDispatchResponse; }; diff --git a/packages/browser/src/providers/index.ts b/packages/browser/src/providers/index.ts new file mode 100644 index 000000000..eb7ec0095 --- /dev/null +++ b/packages/browser/src/providers/index.ts @@ -0,0 +1,103 @@ +import type { BrowserRpcRequest } from '../rpcProtocol'; +import { playwrightProviderImplementation } from './playwright'; + +/** + * Browser provider contract hub. + * + * When adding a new built-in provider, implement `BrowserProviderImplementation` + * and register it in `providerImplementations` below. + */ +export type BrowserProvider = 'playwright'; + +/** Minimal console shape needed by host logging bridge. */ +export type BrowserConsoleMessage = { + text: () => string; +}; + +/** + * Minimal page API surface required by hostController. + * + * This is a structural type (shape interface), NOT a direct Playwright import. + * It currently mirrors a subset of Playwright's Page API because that is the + * only provider. When adding a second provider whose page primitive diverges + * (e.g. WebDriver BiDi), consider pushing page-level orchestration (goto, + * exposeFunction, addInitScript, event listeners) into provider-specific + * implementations so hostController only calls high-level semantic methods. + */ +export type BrowserProviderPage = { + goto: (url: string, options?: { waitUntil?: 'load' }) => Promise; + exposeFunction: (name: string, fn: (...args: any[]) => any) => Promise; + addInitScript: (script: string) => Promise; + on: { + (event: 'popup', listener: (page: BrowserProviderPage) => void): void; + ( + event: 'console', + listener: (message: BrowserConsoleMessage) => void, + ): void; + }; + close: () => Promise; +}; + +/** Minimal browser context API surface required by hostController. */ +export type BrowserProviderContext = { + newPage: () => Promise; + on: (event: 'page', listener: (page: BrowserProviderPage) => void) => void; + close: () => Promise; +}; + +/** Minimal browser API surface required by hostController. */ +export type BrowserProviderBrowser = { + close: () => Promise; + newContext: (options: { + viewport: { width: number; height: number } | null; + }) => Promise; +}; + +/** Provider launch result consumed by hostController. */ +export type BrowserProviderRuntime = { + browser: BrowserProviderBrowser; +}; + +/** Input contract for browser launch. */ +export type LaunchBrowserInput = { + browserName: 'chromium' | 'firefox' | 'webkit'; + headless: boolean | undefined; +}; + +/** Input contract for provider-side browser RPC dispatch. */ +export type DispatchBrowserRpcInput = { + containerPage?: BrowserProviderPage; + runnerPage?: BrowserProviderPage; + request: BrowserRpcRequest; + timeoutFallbackMs: number; +}; + +/** + * Core provider implementation contract. + * + * Any new built-in provider must: + * - launch browser runtime for test execution + * - execute browser RPC requests (locator actions + assertions) + */ +export type BrowserProviderImplementation = { + name: BrowserProvider; + launchRuntime: (input: LaunchBrowserInput) => Promise; + dispatchRpc: (input: DispatchBrowserRpcInput) => Promise; +}; + +const providerImplementations: Record< + BrowserProvider, + BrowserProviderImplementation +> = { + playwright: playwrightProviderImplementation, +}; + +export function getBrowserProviderImplementation( + provider: BrowserProvider, +): BrowserProviderImplementation { + const implementation = providerImplementations[provider]; + if (!implementation) { + throw new Error(`Unsupported browser provider: ${String(provider)}`); + } + return implementation; +} diff --git a/packages/browser/src/providers/playwright/compileLocator.ts b/packages/browser/src/providers/playwright/compileLocator.ts new file mode 100644 index 000000000..013eda102 --- /dev/null +++ b/packages/browser/src/providers/playwright/compileLocator.ts @@ -0,0 +1,130 @@ +import type { FrameLocator, Locator, Page } from 'playwright'; +import type { BrowserLocatorIR } from '../../protocol'; +import { reviveBrowserLocatorText } from './textMatcher'; + +export const compilePlaywrightLocator = ( + frame: FrameLocator | Page, + locatorIR: BrowserLocatorIR, +): Locator => { + const compileFromFrame = (ir: BrowserLocatorIR): Locator => { + let current: FrameLocator | Page | Locator = frame; + + const ensureLocator = (): Locator => { + if ((current as any).filter) { + return current as Locator; + } + // Convert FrameLocator to a Locator within the frame. + current = (current as FrameLocator).locator(':root'); + return current as Locator; + }; + + for (const step of ir.steps as any[]) { + switch (step.type) { + case 'getByRole': { + const name = step.options?.name + ? reviveBrowserLocatorText(step.options.name) + : undefined; + const options = step.options ? { ...step.options, name } : undefined; + current = (current as any).getByRole(step.role, options); + break; + } + case 'locator': + current = (current as any).locator(step.selector); + break; + case 'getByText': + current = (current as any).getByText( + reviveBrowserLocatorText(step.text), + step.options, + ); + break; + case 'getByLabel': + current = (current as any).getByLabel( + reviveBrowserLocatorText(step.text), + step.options, + ); + break; + case 'getByPlaceholder': + current = (current as any).getByPlaceholder( + reviveBrowserLocatorText(step.text), + step.options, + ); + break; + case 'getByAltText': + current = (current as any).getByAltText( + reviveBrowserLocatorText(step.text), + step.options, + ); + break; + case 'getByTitle': + current = (current as any).getByTitle( + reviveBrowserLocatorText(step.text), + step.options, + ); + break; + case 'getByTestId': + current = (current as any).getByTestId( + reviveBrowserLocatorText(step.text) as any, + ); + break; + case 'filter': { + const locator = ensureLocator(); + const options: { + hasText?: string | RegExp; + hasNotText?: string | RegExp; + has?: Locator; + hasNot?: Locator; + } = {}; + if (step.options?.hasText) { + options.hasText = reviveBrowserLocatorText(step.options.hasText); + } + if (step.options?.hasNotText) { + options.hasNotText = reviveBrowserLocatorText( + step.options.hasNotText, + ); + } + if (step.options?.has) { + options.has = compileFromFrame(step.options.has); + } + if (step.options?.hasNot) { + options.hasNot = compileFromFrame(step.options.hasNot); + } + current = locator.filter(options); + break; + } + case 'and': { + const locator = ensureLocator(); + const other = compileFromFrame(step.locator); + current = locator.and(other); + break; + } + case 'or': { + const locator = ensureLocator(); + const other = compileFromFrame(step.locator); + current = locator.or(other); + break; + } + case 'nth': { + const locator = ensureLocator(); + current = locator.nth(step.index); + break; + } + case 'first': { + const locator = ensureLocator(); + current = locator.first(); + break; + } + case 'last': { + const locator = ensureLocator(); + current = locator.last(); + break; + } + default: + throw new Error(`Unknown locator step: ${String(step?.type)}`); + } + } + + return ensureLocator(); + }; + + return compileFromFrame(locatorIR); +}; diff --git a/packages/browser/src/providers/playwright/dispatchBrowserRpc.ts b/packages/browser/src/providers/playwright/dispatchBrowserRpc.ts new file mode 100644 index 000000000..61c34e3d6 --- /dev/null +++ b/packages/browser/src/providers/playwright/dispatchBrowserRpc.ts @@ -0,0 +1,372 @@ +/** + * Contains adapted logic from Playwright matchers: + * https://github.com/microsoft/playwright/blob/main/packages/playwright/src/matchers/matchers.ts + * Copyright (c) Microsoft Corporation, Apache-2.0. + */ +import type { FrameLocator, Locator, Page } from 'playwright'; +import { + supportedExpectElementMatchers, + supportedLocatorActions, +} from '../../browserRpcRegistry'; +import type { + BrowserLocatorIR, + BrowserLocatorText, + BrowserRpcRequest, +} from '../../rpcProtocol'; +import { compilePlaywrightLocator } from './compileLocator'; +import { formatExpectError, serializeExpectedText } from './expectUtils'; + +// --------------------------------------------------------------------------- +// Iframe lookup +// --------------------------------------------------------------------------- + +const escapeCssAttrValue = (value: string): string => { + // Minimal escaping for use in CSS attribute selectors with single quotes. + // https://www.w3.org/TR/selectors-4/#attribute-representation + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); +}; + +const getRunnerFrame = async ( + containerPage: Page, + testPath: string, + timeoutMs: number, +): Promise => { + const selector = `iframe[data-test-file='${escapeCssAttrValue(testPath)}']`; + const iframe = containerPage.locator(selector); + + const count = await iframe.count(); + if (count === 0) { + const known = await containerPage + .locator('iframe[data-test-file]') + .evaluateAll((nodes) => + nodes.map((n) => (n as HTMLIFrameElement).dataset.testFile), + ); + throw new Error( + `Runner iframe not found for testPath: ${JSON.stringify(testPath)}. ` + + `Known iframes: ${JSON.stringify(known)}. ` + + `Timeout: ${timeoutMs}ms`, + ); + } + + return containerPage.frameLocator(selector); +}; + +// --------------------------------------------------------------------------- +// Table-driven expect matcher dispatch +// --------------------------------------------------------------------------- + +/** + * Calls Playwright's internal `_expect()` and throws on mismatch. + * + * NOTE: `_expect()` is a Playwright semi-internal API used by its own test + * runner to implement all web-first assertions. It is not part of the public + * docs but is stable across minor versions. All Playwright-specific coupling + * is intentionally confined to this provider module. + * See: https://github.com/nicolo-ribaudo/playwright/blob/HEAD/packages/playwright-core/src/client/locator.ts + */ +const callExpect = async ( + locator: Locator, + expectMethod: string, + options: Record, + fallbackMessage: string, +): Promise => { + const result = await (locator as any)._expect(expectMethod, options); + if (result.matches !== !options.isNot) { + throw new Error(formatExpectError(result) || fallbackMessage); + } + return null; +}; + +const assertSerializedText = ( + value: unknown, + matcherName: string, +): BrowserLocatorText => { + const t = value as any; + if (!t || (t.type !== 'string' && t.type !== 'regexp')) { + throw new Error(`${matcherName} expects a serialized text matcher`); + } + return t as BrowserLocatorText; +}; + +const assertStringArg = ( + value: unknown, + matcherName: string, + label: string, +): string => { + if (typeof value !== 'string' || !value) { + throw new Error(`${matcherName} expects ${label}`); + } + return value; +}; + +/** Simple boolean state matchers — no extra args. */ +const simpleMatchers: Record = { + toBeVisible: 'to.be.visible', + toBeHidden: 'to.be.hidden', + toBeEnabled: 'to.be.enabled', + toBeDisabled: 'to.be.disabled', + toBeAttached: 'to.be.attached', + toBeDetached: 'to.be.detached', + toBeEditable: 'to.be.editable', + toBeFocused: 'to.be.focused', + toBeEmpty: 'to.be.empty', +}; + +/** Text matchers that take a single serialized text arg. */ +const textMatchers: Record< + string, + { + expectMethod: string; + textOptions?: { matchSubstring?: boolean; normalizeWhiteSpace?: boolean }; + } +> = { + toHaveId: { expectMethod: 'to.have.id' }, + toHaveText: { + expectMethod: 'to.have.text', + textOptions: { normalizeWhiteSpace: true }, + }, + toContainText: { + expectMethod: 'to.have.text', + textOptions: { matchSubstring: true, normalizeWhiteSpace: true }, + }, + toHaveValue: { expectMethod: 'to.have.value' }, + toHaveClass: { expectMethod: 'to.have.class' }, +}; + +/** + * Dispatches an expect matcher call on the given Playwright locator. + * Returns `null` on success, throws on mismatch or invalid args. + */ +const dispatchExpectMatcher = ( + locator: Locator, + request: BrowserRpcRequest, + isNot: boolean, + timeout: number, +): Promise => { + const { method, args } = request; + + // --- Simple boolean state matchers --- + const simpleExpect = simpleMatchers[method]; + if (simpleExpect) { + return callExpect( + locator, + simpleExpect, + { isNot, timeout }, + `Expected element ${method + .replace('toBe', 'to be ') + .replace(/([A-Z])/g, ' $1') + .trim() + .toLowerCase()}`, + ); + } + + // --- Text matchers (single serialized text arg) --- + const textDef = textMatchers[method]; + if (textDef) { + const expected = assertSerializedText(args[0], method); + return callExpect( + locator, + textDef.expectMethod, + { + isNot, + timeout, + expectedText: serializeExpectedText(expected, textDef.textOptions), + }, + `Expected element ${method}`, + ); + } + + // --- Matchers with custom arg handling --- + switch (method) { + case 'toBeInViewport': { + const ratio = args[0]; + if (ratio !== undefined && typeof ratio !== 'number') { + throw new Error( + `toBeInViewport expects ratio to be a number, got ${typeof ratio}`, + ); + } + return callExpect( + locator, + 'to.be.in.viewport', + { isNot, timeout, expectedNumber: ratio }, + 'Expected element to be in viewport', + ); + } + case 'toBeChecked': + return callExpect( + locator, + 'to.be.checked', + { isNot, timeout, expectedValue: { checked: true } }, + 'Expected element to be checked', + ); + case 'toBeUnchecked': + return callExpect( + locator, + 'to.be.checked', + { isNot, timeout, expectedValue: { checked: false } }, + 'Expected element to be unchecked', + ); + case 'toHaveCount': { + const expected = args[0]; + if (typeof expected !== 'number') { + throw new Error(`toHaveCount expects a number, got ${typeof expected}`); + } + return callExpect( + locator, + 'to.have.count', + { isNot, timeout, expectedNumber: expected }, + `Expected count ${expected}`, + ); + } + case 'toHaveAttribute': { + const name = assertStringArg( + args[0], + 'toHaveAttribute', + 'an attribute name', + ); + if (args.length < 2) { + return callExpect( + locator, + 'to.have.attribute', + { isNot, timeout, expressionArg: name }, + `Expected attribute ${name} to be present`, + ); + } + const expected = assertSerializedText(args[1], 'toHaveAttribute'); + return callExpect( + locator, + 'to.have.attribute.value', + { + isNot, + timeout, + expressionArg: name, + expectedText: serializeExpectedText(expected), + }, + `Expected attribute ${name} to match`, + ); + } + case 'toHaveCSS': { + const name = assertStringArg(args[0], 'toHaveCSS', 'a CSS property name'); + const expected = assertSerializedText(args[1], 'toHaveCSS'); + return callExpect( + locator, + 'to.have.css', + { + isNot, + timeout, + expressionArg: name, + expectedText: serializeExpectedText(expected), + }, + `Expected CSS ${name} to match`, + ); + } + case 'toHaveJSProperty': { + const name = assertStringArg( + args[0], + 'toHaveJSProperty', + 'a property name', + ); + const expectedValue = args[1]; + try { + JSON.stringify(expectedValue); + } catch { + throw new Error( + 'toHaveJSProperty expects a JSON-serializable expected value', + ); + } + return callExpect( + locator, + 'to.have.property', + { isNot, timeout, expressionArg: name, expectedValue }, + `Expected JS property ${name} to match`, + ); + } + } + + throw new Error(`Unhandled expect matcher: ${method}`); +}; + +// --------------------------------------------------------------------------- +// Config dispatch +// --------------------------------------------------------------------------- + +const dispatchConfigMethod = async ( + request: BrowserRpcRequest, +): Promise => { + switch (request.method) { + case 'setTestIdAttribute': { + const attr = request.args[0]; + if (typeof attr !== 'string' || !attr) { + throw new Error( + 'setTestIdAttribute expects a non-empty string argument', + ); + } + const playwright = await import('playwright'); + playwright.selectors.setTestIdAttribute(attr); + return null; + } + default: + throw new Error(`Unknown config method: ${request.method}`); + } +}; + +// --------------------------------------------------------------------------- +// Public entry +// --------------------------------------------------------------------------- + +export async function dispatchPlaywrightBrowserRpc({ + containerPage, + runnerPage, + request, + timeoutFallbackMs, +}: { + containerPage?: Page; + runnerPage?: Page; + request: BrowserRpcRequest; + timeoutFallbackMs: number; +}): Promise { + // Config operations don't need a locator or runner frame. + if (request.kind === 'config') { + return dispatchConfigMethod(request); + } + + const testPath = request.testPath; + if (!testPath) { + throw new Error('Browser RPC request is missing testPath'); + } + + const timeout = + typeof request.timeout === 'number' ? request.timeout : timeoutFallbackMs; + + const locatorRoot = runnerPage + ? runnerPage + : await getRunnerFrame( + containerPage ?? + (() => { + throw new Error('Browser container page is not initialized'); + })(), + testPath, + timeout, + ); + const locator = compilePlaywrightLocator( + locatorRoot, + request.locator as BrowserLocatorIR, + ); + + if (request.kind === 'locator') { + if (!supportedLocatorActions.has(request.method)) { + throw new Error(`Locator method not supported: ${request.method}`); + } + const target: any = locator as any; + return await target[request.method](...request.args); + } + + if (request.kind === 'expect') { + if (!supportedExpectElementMatchers.has(request.method)) { + throw new Error(`Expect matcher not supported: ${request.method}`); + } + return dispatchExpectMatcher(locator, request, !!request.isNot, timeout); + } + + throw new Error(`Unknown browser rpc kind: ${request.kind}`); +} diff --git a/packages/browser/src/providers/playwright/expectUtils.ts b/packages/browser/src/providers/playwright/expectUtils.ts new file mode 100644 index 000000000..46181a287 --- /dev/null +++ b/packages/browser/src/providers/playwright/expectUtils.ts @@ -0,0 +1,57 @@ +/** + * Contains adapted logic from Playwright matchers: + * https://github.com/microsoft/playwright/blob/main/packages/playwright/src/matchers/matchers.ts + * Copyright (c) Microsoft Corporation, Apache-2.0. + */ +import type { BrowserLocatorText } from '../../rpcProtocol'; + +type ExpectedTextValue = { + string?: string; + regexSource?: string; + regexFlags?: string; + matchSubstring?: boolean; + ignoreCase?: boolean; + normalizeWhiteSpace?: boolean; +}; + +export const serializeExpectedText = ( + text: BrowserLocatorText, + options?: { + matchSubstring?: boolean; + normalizeWhiteSpace?: boolean; + ignoreCase?: boolean; + }, +): ExpectedTextValue[] => { + const base: ExpectedTextValue = { + matchSubstring: options?.matchSubstring, + ignoreCase: options?.ignoreCase, + normalizeWhiteSpace: options?.normalizeWhiteSpace, + }; + + if (text.type === 'string') { + return [{ ...base, string: text.value }]; + } + + return [ + { + ...base, + regexSource: text.source, + regexFlags: text.flags, + }, + ]; +}; + +export const formatExpectError = (result: { + errorMessage?: string; + log?: string[]; +}): string => { + const parts: string[] = []; + if (result.errorMessage) { + parts.push(result.errorMessage); + } + if (result.log?.length) { + parts.push('Call log:'); + parts.push(...result.log.map((l) => `- ${l}`)); + } + return parts.join('\n'); +}; diff --git a/packages/browser/src/providers/playwright/implementation.ts b/packages/browser/src/providers/playwright/implementation.ts new file mode 100644 index 000000000..46f397a1b --- /dev/null +++ b/packages/browser/src/providers/playwright/implementation.ts @@ -0,0 +1,33 @@ +import type { Page } from 'playwright'; +import type { + BrowserProviderImplementation, + BrowserProviderRuntime, +} from '../index'; +import { dispatchPlaywrightBrowserRpc } from './dispatchBrowserRpc'; +import { launchPlaywrightBrowser } from './runtime'; + +export const playwrightProviderImplementation: BrowserProviderImplementation = { + name: 'playwright', + async launchRuntime({ + browserName, + headless, + }): Promise { + return launchPlaywrightBrowser({ + browserName, + headless, + }); + }, + async dispatchRpc({ + containerPage, + runnerPage, + request, + timeoutFallbackMs, + }): Promise { + return dispatchPlaywrightBrowserRpc({ + containerPage: containerPage as Page | undefined, + runnerPage: runnerPage as Page | undefined, + request, + timeoutFallbackMs, + }); + }, +}; diff --git a/packages/browser/src/providers/playwright/index.ts b/packages/browser/src/providers/playwright/index.ts new file mode 100644 index 000000000..b8710a0c8 --- /dev/null +++ b/packages/browser/src/providers/playwright/index.ts @@ -0,0 +1 @@ +export { playwrightProviderImplementation } from './implementation'; diff --git a/packages/browser/src/providers/playwright/runtime.ts b/packages/browser/src/providers/playwright/runtime.ts new file mode 100644 index 000000000..124793051 --- /dev/null +++ b/packages/browser/src/providers/playwright/runtime.ts @@ -0,0 +1,32 @@ +import type { BrowserProviderRuntime } from '../index'; + +type PlaywrightModule = typeof import('playwright'); +type PlaywrightBrowserType = PlaywrightModule['chromium']; + +export async function launchPlaywrightBrowser({ + browserName, + headless, +}: { + browserName: 'chromium' | 'firefox' | 'webkit'; + headless: boolean | undefined; +}): Promise { + const playwright = await import('playwright'); + const browserType = playwright[browserName] as PlaywrightBrowserType; + + const browser = await browserType.launch({ + headless, + // Chromium-specific args (ignored by other browsers) + args: + browserName === 'chromium' + ? [ + '--disable-popup-blocking', + '--no-first-run', + '--no-default-browser-check', + ] + : undefined, + }); + + return { + browser: browser as unknown as BrowserProviderRuntime['browser'], + }; +} diff --git a/packages/browser/src/providers/playwright/textMatcher.ts b/packages/browser/src/providers/playwright/textMatcher.ts new file mode 100644 index 000000000..87ca15fce --- /dev/null +++ b/packages/browser/src/providers/playwright/textMatcher.ts @@ -0,0 +1,10 @@ +export const reviveBrowserLocatorText = ( + text: + | { type: 'string'; value: string } + | { type: 'regexp'; source: string; flags?: string }, +): string | RegExp => { + if (text.type === 'string') { + return text.value; + } + return new RegExp(text.source, text.flags); +}; diff --git a/packages/browser/src/rpcProtocol.ts b/packages/browser/src/rpcProtocol.ts new file mode 100644 index 000000000..9122045ff --- /dev/null +++ b/packages/browser/src/rpcProtocol.ts @@ -0,0 +1,220 @@ +export type BrowserLocatorText = + | { type: 'string'; value: string } + | { type: 'regexp'; source: string; flags?: string }; + +export type BrowserLocatorStep = + | { + type: 'getByRole'; + role: string; + options?: { + name?: BrowserLocatorText; + exact?: boolean; + checked?: boolean; + disabled?: boolean; + expanded?: boolean; + selected?: boolean; + pressed?: boolean; + includeHidden?: boolean; + level?: number; + }; + } + | { + type: 'locator'; + selector: string; + } + | { + type: 'getByText'; + text: BrowserLocatorText; + options?: { exact?: boolean }; + } + | { + type: 'getByLabel'; + text: BrowserLocatorText; + options?: { exact?: boolean }; + } + | { + type: 'getByPlaceholder'; + text: BrowserLocatorText; + options?: { exact?: boolean }; + } + | { + type: 'getByAltText'; + text: BrowserLocatorText; + options?: { exact?: boolean }; + } + | { + type: 'getByTitle'; + text: BrowserLocatorText; + options?: { exact?: boolean }; + } + | { + type: 'getByTestId'; + text: BrowserLocatorText; + } + | { + type: 'filter'; + options: { + hasText?: BrowserLocatorText; + hasNotText?: BrowserLocatorText; + has?: BrowserLocatorIR; + hasNot?: BrowserLocatorIR; + }; + } + | { type: 'and'; locator: BrowserLocatorIR } + | { type: 'or'; locator: BrowserLocatorIR } + | { type: 'nth'; index: number } + | { type: 'first' } + | { type: 'last' }; + +export type BrowserLocatorIR = { + steps: BrowserLocatorStep[]; +}; + +export type BrowserRpcRequest = { + id: string; + /** Absolute test file path for locating runner iframe */ + testPath: string; + /** Run identifier generated by container for stale-request protection. */ + runId: string; + kind: 'locator' | 'expect' | 'config'; + locator: BrowserLocatorIR; + method: string; + args: unknown[]; + /** + * Negation for expect matchers (equivalent to Playwright expect(...).not). + * Only meaningful for kind === 'expect'. + */ + isNot?: boolean; + /** Optional timeout override (ms). Falls back to browser rpcTimeout. */ + timeout?: number; +}; + +const isRecord = (value: unknown): value is Record => { + return typeof value === 'object' && value !== null; +}; + +const readString = ( + value: Record, + key: string, + label: string, +): string => { + const result = value[key]; + if (typeof result !== 'string') { + throw new Error(`Invalid browser RPC request: ${label} must be a string`); + } + return result; +}; + +const readUnknownArray = ( + value: Record, + key: string, + label: string, +): unknown[] => { + const result = value[key]; + if (!Array.isArray(result)) { + throw new Error(`Invalid browser RPC request: ${label} must be an array`); + } + return result; +}; + +const parseBrowserLocatorIR = ( + value: unknown, + label: string, +): BrowserLocatorIR => { + if (!isRecord(value)) { + throw new Error(`Invalid browser RPC request: ${label} must be an object`); + } + + const steps = value.steps; + if (!Array.isArray(steps)) { + throw new Error( + `Invalid browser RPC request: ${label}.steps must be an array`, + ); + } + + return value as BrowserLocatorIR; +}; + +export const validateBrowserRpcRequest = ( + payload: unknown, +): BrowserRpcRequest => { + if (!isRecord(payload)) { + throw new Error('Invalid browser RPC request: payload must be an object'); + } + + const kind = readString(payload, 'kind', 'kind'); + if (kind !== 'locator' && kind !== 'expect' && kind !== 'config') { + throw new Error( + `Invalid browser RPC request: unsupported kind ${JSON.stringify(kind)}`, + ); + } + + const request: BrowserRpcRequest = { + id: readString(payload, 'id', 'id'), + testPath: readString(payload, 'testPath', 'testPath'), + runId: readString(payload, 'runId', 'runId'), + kind, + locator: parseBrowserLocatorIR(payload.locator, 'locator'), + method: readString(payload, 'method', 'method'), + args: readUnknownArray(payload, 'args', 'args'), + }; + + const isNot = payload.isNot; + if (isNot !== undefined) { + if (typeof isNot !== 'boolean') { + throw new Error('Invalid browser RPC request: isNot must be a boolean'); + } + request.isNot = isNot; + } + + const timeout = payload.timeout; + if (timeout !== undefined) { + if (typeof timeout !== 'number') { + throw new Error('Invalid browser RPC request: timeout must be a number'); + } + request.timeout = timeout; + } + + return request; +}; + +export type BrowserRpcResponse = { + id: string; + result?: unknown; + error?: string; +}; + +/** + * Snapshot RPC request from runner iframe. + * The container will forward these to the host via WebSocket RPC. + */ +export type SnapshotRpcRequest = + | { + id: string; + method: 'resolveSnapshotPath'; + args: { testPath: string }; + } + | { + id: string; + method: 'readSnapshotFile'; + args: { filepath: string }; + } + | { + id: string; + method: 'saveSnapshotFile'; + args: { filepath: string; content: string }; + } + | { + id: string; + method: 'removeSnapshotFile'; + args: { filepath: string }; + }; + +/** + * Snapshot RPC response from container to runner iframe. + */ +export type SnapshotRpcResponse = { + id: string; + result?: unknown; + error?: string; +}; diff --git a/packages/browser/src/sessionRegistry.ts b/packages/browser/src/sessionRegistry.ts index 6dc40a48d..5cc85757b 100644 --- a/packages/browser/src/sessionRegistry.ts +++ b/packages/browser/src/sessionRegistry.ts @@ -1,4 +1,4 @@ -import type { BrowserContext, Page } from 'playwright'; +import type { BrowserProviderContext, BrowserProviderPage } from './providers'; export type RunnerSessionRecord = { id: string; @@ -7,8 +7,8 @@ export type RunnerSessionRecord = { runToken: number; mode: 'headless-page' | 'headed-iframe'; createdAt: number; - context?: BrowserContext; - page?: Page; + context?: BrowserProviderContext; + page?: BrowserProviderPage; metadata?: Record; }; diff --git a/packages/browser/tests/browserRpc.test.ts b/packages/browser/tests/browserRpc.test.ts new file mode 100644 index 000000000..c5e9cdc9a --- /dev/null +++ b/packages/browser/tests/browserRpc.test.ts @@ -0,0 +1,134 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + rstest, +} from '@rstest/core'; +import type { BrowserDispatchResponse } from '../src/protocol'; +import { + DISPATCH_METHOD_RPC, + DISPATCH_NAMESPACE_BROWSER, + DISPATCH_RESPONSE_TYPE, +} from '../src/protocol'; + +type CallBrowserRpc = typeof import('../src/client/browserRpc').callBrowserRpc; + +describe('browserRpc client', () => { + let callBrowserRpc: CallBrowserRpc; + let mockPostMessage: ReturnType; + let messageHandler: ((event: MessageEvent) => void) | null = null; + + const respond = (response: BrowserDispatchResponse) => { + if (!messageHandler) { + throw new Error('message handler is not initialized'); + } + messageHandler({ + data: { + type: DISPATCH_RESPONSE_TYPE, + payload: response, + }, + } as MessageEvent); + }; + + beforeEach(async () => { + rstest.resetModules(); + mockPostMessage = rstest.fn(); + + rstest.stubGlobal('crypto', { + randomUUID: rstest.fn(() => 'rpc-id-1'), + }); + + rstest.stubGlobal('window', { + addEventListener: ( + _type: string, + handler: (event: MessageEvent) => void, + ) => { + messageHandler = handler; + }, + parent: { + postMessage: mockPostMessage, + }, + __RSTEST_BROWSER_OPTIONS__: { + testFile: '/tests/example.test.ts', + runId: 'run-1', + rpcTimeout: 1000, + }, + }); + + const module = await import('../src/client/browserRpc'); + callBrowserRpc = module.callBrowserRpc; + }); + + afterEach(() => { + rstest.unstubAllGlobals(); + messageHandler = null; + }); + + it('should include runId and uuid request id', async () => { + const requestPromise = callBrowserRpc({ + kind: 'locator', + locator: { steps: [] }, + method: 'click', + args: [], + }); + + expect(mockPostMessage).toHaveBeenCalledTimes(1); + const postedPayload = mockPostMessage.mock.calls[0]?.[0] as { + payload?: { + payload?: { + requestId?: string; + namespace?: string; + method?: string; + args?: { id?: string; testPath?: string; runId?: string }; + }; + }; + }; + const dispatchRequest = postedPayload.payload?.payload; + const request = dispatchRequest?.args; + + expect(request?.id).toBe('rpc-id-1'); + expect(request?.testPath).toBe('/tests/example.test.ts'); + expect(request?.runId).toBe('run-1'); + expect(dispatchRequest?.namespace).toBe(DISPATCH_NAMESPACE_BROWSER); + expect(dispatchRequest?.method).toBe(DISPATCH_METHOD_RPC); + + respond({ requestId: dispatchRequest!.requestId!, result: undefined }); + await expect(requestPromise).resolves.toBeUndefined(); + }); + + it('should reject when runId is missing', async () => { + (window as any).__RSTEST_BROWSER_OPTIONS__ = { + testFile: '/tests/example.test.ts', + rpcTimeout: 1000, + }; + + await expect( + callBrowserRpc({ + kind: 'locator', + locator: { steps: [] }, + method: 'click', + args: [], + }), + ).rejects.toThrow('Browser RPC requires runId'); + }); + + it('should noop config RPC in collect mode without run context', async () => { + (window as any).__RSTEST_BROWSER_OPTIONS__ = { + mode: 'collect', + rpcTimeout: 1000, + }; + + await expect( + callBrowserRpc({ + kind: 'config', + locator: { steps: [] }, + method: 'setTestIdAttribute', + args: ['data-testid'], + }), + ).resolves.toBeUndefined(); + + expect(mockPostMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/browser/tests/browserRpcRegistryConsistency.test.ts b/packages/browser/tests/browserRpcRegistryConsistency.test.ts new file mode 100644 index 000000000..6d2a1c70c --- /dev/null +++ b/packages/browser/tests/browserRpcRegistryConsistency.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from '@rstest/core'; +import type { BrowserElementExpect } from '../src/augmentExpect'; +import { + supportedExpectElementMatchers, + supportedLocatorActions, +} from '../src/browserRpcRegistry'; +import { Locator } from '../src/client/locator'; +import type { BrowserRpcRequest } from '../src/protocol'; +import { dispatchPlaywrightBrowserRpc } from '../src/providers/playwright/dispatchBrowserRpc'; + +class FakeLocator { + actionCalls: Array<{ method: string; args: unknown[] }> = []; + expectCalls: Array<{ method: string; options: Record }> = []; + + constructor() { + for (const method of supportedLocatorActions) { + (this as Record)[method] = async ( + ...args: unknown[] + ) => { + this.actionCalls.push({ method, args }); + return null; + }; + } + } + + locator(): FakeLocator { + return this; + } + + filter(): FakeLocator { + return this; + } + + and(): FakeLocator { + return this; + } + + or(): FakeLocator { + return this; + } + + nth(): FakeLocator { + return this; + } + + first(): FakeLocator { + return this; + } + + last(): FakeLocator { + return this; + } + + async _expect( + method: string, + options: Record, + ): Promise<{ matches: boolean }> { + this.expectCalls.push({ method, options }); + return { matches: !options.isNot }; + } +} + +class FakePage { + constructor(private readonly locatorImpl: FakeLocator) {} + + locator(): FakeLocator { + return this.locatorImpl; + } +} + +const createRequest = ( + overrides: Partial, +): BrowserRpcRequest => { + return { + id: 'rpc-1', + testPath: '/tests/example.test.ts', + runId: 'run-1', + kind: 'locator', + locator: { steps: [{ type: 'locator', selector: '#root' }] }, + method: 'click', + args: [], + ...overrides, + }; +}; + +const createSerializedText = (value: string) => ({ + type: 'string' as const, + value, +}); + +const createExpectArgs = (method: string): unknown[] => { + switch (method) { + case 'toHaveText': + case 'toContainText': + case 'toHaveValue': + case 'toHaveId': + case 'toHaveClass': + return [createSerializedText('expected')]; + case 'toBeInViewport': + return [0.5]; + case 'toHaveCount': + return [1]; + case 'toHaveAttribute': + return ['data-testid', createSerializedText('submit')]; + case 'toHaveCSS': + return ['color', createSerializedText('red')]; + case 'toHaveJSProperty': + return ['checked', true]; + default: + return []; + } +}; + +describe('browser RPC registry consistency', () => { + it('keeps locator action registry aligned with Locator class methods', () => { + for (const method of supportedLocatorActions) { + expect(typeof (Locator.prototype as any)[method]).toBe('function'); + } + }); + + it('keeps expect matcher registry aligned with BrowserElementExpect type surface', () => { + const expectApiShape: Omit = { + async toBeVisible() {}, + async toBeHidden() {}, + async toBeEnabled() {}, + async toBeDisabled() {}, + async toBeChecked() {}, + async toBeUnchecked() {}, + async toBeAttached() {}, + async toBeDetached() {}, + async toBeEditable() {}, + async toBeFocused() {}, + async toBeEmpty() {}, + async toBeInViewport() {}, + async toHaveText() {}, + async toContainText() {}, + async toHaveValue() {}, + async toHaveId() {}, + async toHaveAttribute() {}, + async toHaveClass() {}, + async toHaveCount() {}, + async toHaveCSS() {}, + async toHaveJSProperty() {}, + }; + + const typeMethods = new Set(Object.keys(expectApiShape)); + + expect(new Set(supportedExpectElementMatchers)).toEqual(typeMethods); + }); + + it('dispatches every allowlisted locator action without unsupported errors', async () => { + for (const method of supportedLocatorActions) { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ kind: 'locator', method, args: [] }), + timeoutFallbackMs: 500, + }), + ).resolves.toBeNull(); + } + }); + + it('dispatches every allowlisted expect matcher without unhandled errors', async () => { + for (const method of supportedExpectElementMatchers) { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ + kind: 'expect', + method, + args: createExpectArgs(method), + }), + timeoutFallbackMs: 800, + }), + ).resolves.toBeNull(); + } + }); +}); diff --git a/packages/browser/tests/dispatchBrowserRpc.test.ts b/packages/browser/tests/dispatchBrowserRpc.test.ts new file mode 100644 index 000000000..6c297fbad --- /dev/null +++ b/packages/browser/tests/dispatchBrowserRpc.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from '@rstest/core'; +import type { BrowserRpcRequest } from '../src/protocol'; +import { dispatchPlaywrightBrowserRpc } from '../src/providers/playwright/dispatchBrowserRpc'; + +class FakeLocator { + actionCalls: Array<{ method: string; args: unknown[] }> = []; + expectCalls: Array<{ method: string; options: Record }> = []; + + locator(): FakeLocator { + return this; + } + + filter(): FakeLocator { + return this; + } + + and(): FakeLocator { + return this; + } + + or(): FakeLocator { + return this; + } + + nth(): FakeLocator { + return this; + } + + first(): FakeLocator { + return this; + } + + last(): FakeLocator { + return this; + } + + async click(...args: unknown[]): Promise { + this.actionCalls.push({ method: 'click', args }); + return 'clicked'; + } + + async _expect( + method: string, + options: Record, + ): Promise<{ matches: boolean }> { + this.expectCalls.push({ method, options }); + return { matches: !options.isNot }; + } +} + +class FakePage { + constructor(private readonly locatorImpl: FakeLocator) {} + + locator(): FakeLocator { + return this.locatorImpl; + } +} + +const createRequest = ( + overrides: Partial, +): BrowserRpcRequest => { + return { + id: 'rpc-1', + testPath: '/tests/example.test.ts', + runId: 'run-1', + kind: 'locator', + locator: { steps: [{ type: 'locator', selector: '#root' }] }, + method: 'click', + args: [], + ...overrides, + }; +}; + +describe('dispatchPlaywrightBrowserRpc', () => { + it('dispatches supported locator actions', async () => { + const fakeLocator = new FakeLocator(); + const result = await dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ kind: 'locator', method: 'click', args: [123] }), + timeoutFallbackMs: 500, + }); + + expect(result).toBe('clicked'); + expect(fakeLocator.actionCalls).toHaveLength(1); + expect(fakeLocator.actionCalls[0]).toEqual({ + method: 'click', + args: [123], + }); + }); + + it('rejects unsupported locator actions', async () => { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ kind: 'locator', method: 'dragTo' }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('Locator method not supported: dragTo'); + }); + + it('dispatches supported expect matchers via _expect', async () => { + const fakeLocator = new FakeLocator(); + await dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ kind: 'expect', method: 'toBeVisible' }), + timeoutFallbackMs: 900, + }); + + expect(fakeLocator.expectCalls).toHaveLength(1); + expect(fakeLocator.expectCalls[0]?.method).toBe('to.be.visible'); + expect(fakeLocator.expectCalls[0]?.options).toEqual({ + isNot: false, + timeout: 900, + }); + }); + + it('rejects unsupported expect matchers', async () => { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ kind: 'expect', method: 'toHaveRole' }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('Expect matcher not supported: toHaveRole'); + }); + + it('validates matcher arguments for toHaveCount', async () => { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ + kind: 'expect', + method: 'toHaveCount', + args: ['1'], + }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('toHaveCount expects a number, got string'); + }); + + it('validates matcher arguments for toHaveCSS', async () => { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ + kind: 'expect', + method: 'toHaveCSS', + args: ['', { type: 'string', value: 'red' }], + }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('toHaveCSS expects a CSS property name'); + }); + + it('routes config requests and rejects unknown config methods', async () => { + await expect( + dispatchPlaywrightBrowserRpc({ + request: createRequest({ + kind: 'config', + method: 'unknownConfigMethod', + args: [], + }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('Unknown config method: unknownConfigMethod'); + }); + + it('rejects requests missing testPath for locator/expect kinds', async () => { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ testPath: '' }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('Browser RPC request is missing testPath'); + }); + + it('rejects unknown browser rpc kind', async () => { + const fakeLocator = new FakeLocator(); + await expect( + dispatchPlaywrightBrowserRpc({ + runnerPage: new FakePage(fakeLocator) as any, + request: createRequest({ kind: 'unknown' as any }), + timeoutFallbackMs: 500, + }), + ).rejects.toThrow('Unknown browser rpc kind: unknown'); + }); +}); diff --git a/packages/browser/tests/locatorIr.test.ts b/packages/browser/tests/locatorIr.test.ts new file mode 100644 index 000000000..6f6e1ab75 --- /dev/null +++ b/packages/browser/tests/locatorIr.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from '@rstest/core'; +import { page } from '../src/client/locator'; + +describe('browser locator IR', () => { + it('should expose query-only page entry', () => { + expect((page as any).click).toBeUndefined(); + expect((page as any).fill).toBeUndefined(); + + const locator = page.getByRole('button', { name: 'Submit' }); + expect((locator.ir.steps[0] as any).type).toBe('getByRole'); + }); + + it('should preserve empty role name in getByRole options', () => { + const locator = page.getByRole('button', { name: '' }); + const step = locator.ir.steps[0] as any; + + expect(step.type).toBe('getByRole'); + expect(step.options.name).toEqual({ type: 'string', value: '' }); + }); + + it('should build nested filter({ has }) IR', () => { + const base = page.locator('section'); + const has = page.locator('h2').filter({ hasText: 'Profile' }); + const out = base.filter({ has }); + + expect(out.ir.steps.length).toBe(2); + const step: any = out.ir.steps[1]; + expect(step.type).toBe('filter'); + expect(step.options.has).toBeDefined(); + expect(step.options.has.steps[0].type).toBe('locator'); + expect(step.options.has.steps[1].type).toBe('filter'); + }); + + it('should build and/or IR with nested locators', () => { + const a = page.locator('button'); + const b = page.getByText('Increment'); + const c = page.getByText('Cancel'); + + const out = a.and(b).or(c); + expect(out.ir.steps.length).toBe(3); + expect((out.ir.steps[1] as any).type).toBe('and'); + expect((out.ir.steps[2] as any).type).toBe('or'); + expect((out.ir.steps[1] as any).locator.steps.length).toBeGreaterThan(0); + expect((out.ir.steps[2] as any).locator.steps.length).toBeGreaterThan(0); + }); + + it('should allow composing has locator with and/or', () => { + const has = page + .getByText('Profile') + .or(page.getByText('Settings')) + .and(page.locator('h2')); + + const out = page.locator('section').filter({ has }); + const step: any = out.ir.steps[1]; + expect(step.type).toBe('filter'); + expect(step.options.has).toBeDefined(); + const hasSteps = step.options.has.steps as any[]; + expect(hasSteps.some((s) => s.type === 'or')).toBe(true); + expect(hasSteps.some((s) => s.type === 'and')).toBe(true); + }); + + it('should build filter({ hasNot }) IR', () => { + const base = page.locator('section'); + const hasNot = page.locator('h2').filter({ hasText: 'Draft' }); + const out = base.filter({ hasNot }); + + expect(out.ir.steps.length).toBe(2); + const step: any = out.ir.steps[1]; + expect(step.type).toBe('filter'); + expect(step.options.hasNot).toBeDefined(); + expect(step.options.hasNot.steps[0].type).toBe('locator'); + expect(step.options.hasNot.steps[1].type).toBe('filter'); + }); + + it('should build filter({ hasNotText }) IR', () => { + const out = page.locator('li').filter({ hasNotText: 'archived' }); + + const step: any = out.ir.steps[1]; + expect(step.type).toBe('filter'); + expect(step.options.hasNotText).toEqual({ + type: 'string', + value: 'archived', + }); + }); + + it('should build filter with both has and hasNot', () => { + const has = page.getByText('Active'); + const hasNot = page.getByText('Disabled'); + const out = page + .locator('div') + .filter({ has, hasNot, hasText: 'Item', hasNotText: /draft/i }); + + const step: any = out.ir.steps[1]; + expect(step.type).toBe('filter'); + expect(step.options.has).toBeDefined(); + expect(step.options.hasNot).toBeDefined(); + expect(step.options.hasText).toEqual({ type: 'string', value: 'Item' }); + expect(step.options.hasNotText).toEqual({ + type: 'regexp', + source: 'draft', + flags: 'i', + }); + }); +}); diff --git a/packages/browser/tests/playwrightCompileLocator.test.ts b/packages/browser/tests/playwrightCompileLocator.test.ts new file mode 100644 index 000000000..cdc3f1a9b --- /dev/null +++ b/packages/browser/tests/playwrightCompileLocator.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from '@rstest/core'; +import type { BrowserLocatorIR } from '../src/protocol'; +import { compilePlaywrightLocator } from '../src/providers/playwright/compileLocator'; + +class FakeLocator { + readonly ops: Array<{ name: string; args: any[] }>; + + constructor(ops: Array<{ name: string; args: any[] }> = []) { + this.ops = ops; + } + + private next(name: string, ...args: any[]): FakeLocator { + return new FakeLocator([...this.ops, { name, args }]); + } + + locator(selector: string): FakeLocator { + return this.next('locator', selector); + } + + getByText(text: any, options?: any): FakeLocator { + return this.next('getByText', text, options); + } + + filter(options: any): FakeLocator { + return this.next('filter', options); + } + + and(other: any): FakeLocator { + return this.next('and', other); + } + + or(other: any): FakeLocator { + return this.next('or', other); + } + + nth(index: number): FakeLocator { + return this.next('nth', index); + } + + first(): FakeLocator { + return this.next('first'); + } + + last(): FakeLocator { + return this.next('last'); + } + + // Methods below are unused by these tests but required by compiler switch. + getByRole(...args: any[]): FakeLocator { + return this.next('getByRole', ...args); + } + getByLabel(...args: any[]): FakeLocator { + return this.next('getByLabel', ...args); + } + getByPlaceholder(...args: any[]): FakeLocator { + return this.next('getByPlaceholder', ...args); + } + getByAltText(...args: any[]): FakeLocator { + return this.next('getByAltText', ...args); + } + getByTitle(...args: any[]): FakeLocator { + return this.next('getByTitle', ...args); + } + getByTestId(...args: any[]): FakeLocator { + return this.next('getByTestId', ...args); + } +} + +class FakeFrameLocator { + locator(selector: string): FakeLocator { + return new FakeLocator([{ name: 'frame.locator', args: [selector] }]); + } + + getByText(text: any, options?: any): FakeLocator { + return new FakeLocator([ + { name: 'frame.getByText', args: [text, options] }, + ]); + } + + getByRole(...args: any[]): FakeLocator { + return new FakeLocator([{ name: 'frame.getByRole', args }]); + } + + getByLabel(...args: any[]): FakeLocator { + return new FakeLocator([{ name: 'frame.getByLabel', args }]); + } + + getByPlaceholder(...args: any[]): FakeLocator { + return new FakeLocator([{ name: 'frame.getByPlaceholder', args }]); + } + + getByAltText(...args: any[]): FakeLocator { + return new FakeLocator([{ name: 'frame.getByAltText', args }]); + } + + getByTitle(...args: any[]): FakeLocator { + return new FakeLocator([{ name: 'frame.getByTitle', args }]); + } + + getByTestId(...args: any[]): FakeLocator { + return new FakeLocator([{ name: 'frame.getByTestId', args }]); + } +} + +describe('compilePlaywrightLocator', () => { + it('should compile nested filter({ has }) and and/or steps recursively', () => { + const ir: BrowserLocatorIR = { + steps: [ + { type: 'locator', selector: 'section' }, + { + type: 'filter', + options: { + has: { + steps: [ + { type: 'locator', selector: 'h2' }, + { + type: 'filter', + options: { hasText: { type: 'string', value: 'Profile' } }, + }, + ], + }, + }, + }, + { + type: 'and', + locator: { steps: [{ type: 'locator', selector: '#a' }] }, + }, + { + type: 'or', + locator: { steps: [{ type: 'locator', selector: '#b' }] }, + }, + ], + }; + + const frame = new FakeFrameLocator(); + const out = compilePlaywrightLocator( + frame as any, + ir, + ) as unknown as FakeLocator; + + const filterOp = out.ops.find((o) => o.name === 'filter'); + expect(filterOp).toBeTruthy(); + const hasLocator = filterOp!.args[0].has as FakeLocator; + expect(hasLocator).toBeInstanceOf(FakeLocator); + expect(hasLocator.ops.some((o) => o.name === 'filter')).toBe(true); + + const andOp = out.ops.find((o) => o.name === 'and'); + const orOp = out.ops.find((o) => o.name === 'or'); + expect(andOp).toBeTruthy(); + expect(orOp).toBeTruthy(); + expect(andOp!.args[0]).toBeInstanceOf(FakeLocator); + expect(orOp!.args[0]).toBeInstanceOf(FakeLocator); + }); + + it('should compile filter({ has }) where has includes and/or steps', () => { + const ir: BrowserLocatorIR = { + steps: [ + { type: 'locator', selector: 'section' }, + { + type: 'filter', + options: { + has: { + steps: [ + { + type: 'or', + locator: { + steps: [ + { + type: 'getByText', + text: { type: 'string', value: 'Profile' }, + }, + ], + }, + }, + { + type: 'and', + locator: { steps: [{ type: 'locator', selector: 'h2' }] }, + }, + ], + }, + }, + }, + ], + }; + + const frame = new FakeFrameLocator(); + const out = compilePlaywrightLocator( + frame as any, + ir, + ) as unknown as FakeLocator; + + const filterOp = out.ops.find((o) => o.name === 'filter'); + expect(filterOp).toBeTruthy(); + + const hasLocator = filterOp!.args[0].has as FakeLocator; + expect(hasLocator.ops.some((o) => o.name === 'or')).toBe(true); + expect(hasLocator.ops.some((o) => o.name === 'and')).toBe(true); + }); + + it('should compile filter({ hasNot }) recursively', () => { + const ir: BrowserLocatorIR = { + steps: [ + { type: 'locator', selector: 'section' }, + { + type: 'filter', + options: { + hasNot: { + steps: [{ type: 'locator', selector: '.draft' }], + }, + }, + }, + ], + }; + + const frame = new FakeFrameLocator(); + const out = compilePlaywrightLocator( + frame as any, + ir, + ) as unknown as FakeLocator; + + const filterOp = out.ops.find((o) => o.name === 'filter'); + expect(filterOp).toBeTruthy(); + const hasNotLocator = filterOp!.args[0].hasNot as FakeLocator; + expect(hasNotLocator).toBeInstanceOf(FakeLocator); + }); + + it('should compile filter({ hasNotText })', () => { + const ir: BrowserLocatorIR = { + steps: [ + { type: 'locator', selector: 'li' }, + { + type: 'filter', + options: { + hasNotText: { type: 'regexp', source: 'draft', flags: 'i' }, + }, + }, + ], + }; + + const frame = new FakeFrameLocator(); + const out = compilePlaywrightLocator( + frame as any, + ir, + ) as unknown as FakeLocator; + + const filterOp = out.ops.find((o) => o.name === 'filter'); + expect(filterOp).toBeTruthy(); + expect(filterOp!.args[0].hasNotText).toBeInstanceOf(RegExp); + expect(filterOp!.args[0].hasNotText.source).toBe('draft'); + expect(filterOp!.args[0].hasNotText.flags).toBe('i'); + }); +}); diff --git a/packages/browser/tests/protocol.test.ts b/packages/browser/tests/protocol.test.ts index 328757e8a..f61f32e6f 100644 --- a/packages/browser/tests/protocol.test.ts +++ b/packages/browser/tests/protocol.test.ts @@ -6,6 +6,7 @@ import type { BrowserHostConfig, BrowserProjectRuntime, } from '../src/protocol'; +import { validateBrowserRpcRequest } from '../src/protocol'; describe('browser protocol types', () => { describe('BrowserProjectRuntime', () => { @@ -148,4 +149,55 @@ describe('browser protocol types', () => { expect(response.result).toEqual({ ok: true }); }); }); + + describe('validateBrowserRpcRequest', () => { + it('accepts a valid browser RPC request payload', () => { + const request = validateBrowserRpcRequest({ + id: 'rpc-1', + testPath: '/tests/example.test.ts', + runId: 'run-1', + kind: 'locator', + locator: { steps: [] }, + method: 'click', + args: [], + }); + + expect(request.kind).toBe('locator'); + expect(request.method).toBe('click'); + }); + + it('throws when payload shape is invalid', () => { + expect(() => validateBrowserRpcRequest(null)).toThrow( + 'Invalid browser RPC request: payload must be an object', + ); + }); + + it('throws on unsupported kind', () => { + expect(() => + validateBrowserRpcRequest({ + id: 'rpc-1', + testPath: '/tests/example.test.ts', + runId: 'run-1', + kind: 'snapshot', + locator: { steps: [] }, + method: 'click', + args: [], + }), + ).toThrow('Invalid browser RPC request: unsupported kind "snapshot"'); + }); + + it('throws when locator.steps is not an array', () => { + expect(() => + validateBrowserRpcRequest({ + id: 'rpc-1', + testPath: '/tests/example.test.ts', + runId: 'run-1', + kind: 'expect', + locator: { steps: 'invalid' }, + method: 'toBeVisible', + args: [], + }), + ).toThrow('Invalid browser RPC request: locator.steps must be an array'); + }); + }); }); diff --git a/packages/browser/tests/snapshot.test.ts b/packages/browser/tests/snapshot.test.ts index d3d3ad62d..013dd0638 100644 --- a/packages/browser/tests/snapshot.test.ts +++ b/packages/browser/tests/snapshot.test.ts @@ -6,6 +6,11 @@ import { it, rstest, } from '@rstest/core'; +import { + DISPATCH_NAMESPACE_SNAPSHOT, + DISPATCH_RESPONSE_TYPE, + DISPATCH_RPC_REQUEST_TYPE, +} from '../src/protocol'; describe('BrowserSnapshotEnvironment', () => { let BrowserSnapshotEnvironment: any; @@ -46,7 +51,7 @@ describe('BrowserSnapshotEnvironment', () => { if (messageHandler) { messageHandler({ data: { - type: '__rstest_dispatch_response__', + type: DISPATCH_RESPONSE_TYPE, payload: { requestId: id, result }, }, } as MessageEvent); @@ -106,8 +111,8 @@ describe('BrowserSnapshotEnvironment', () => { const requestId = call[0].payload.payload.requestId; const payload = call[0].payload.payload; - expect(call[0].payload.type).toBe('dispatch-rpc-request'); - expect(payload.namespace).toBe('snapshot'); + expect(call[0].payload.type).toBe(DISPATCH_RPC_REQUEST_TYPE); + expect(payload.namespace).toBe(DISPATCH_NAMESPACE_SNAPSHOT); expect(payload.method).toBe('saveSnapshotFile'); expect(payload.args).toEqual({ filepath: '/test/snapshot.snap', @@ -128,8 +133,8 @@ describe('BrowserSnapshotEnvironment', () => { const requestId = call[0].payload.payload.requestId; const payload = call[0].payload.payload; - expect(call[0].payload.type).toBe('dispatch-rpc-request'); - expect(payload.namespace).toBe('snapshot'); + expect(call[0].payload.type).toBe(DISPATCH_RPC_REQUEST_TYPE); + expect(payload.namespace).toBe(DISPATCH_NAMESPACE_SNAPSHOT); expect(payload.method).toBe('readSnapshotFile'); expect(payload.args).toEqual({ filepath: '/test/snapshot.snap' }); @@ -163,8 +168,8 @@ describe('BrowserSnapshotEnvironment', () => { const requestId = call[0].payload.payload.requestId; const payload = call[0].payload.payload; - expect(call[0].payload.type).toBe('dispatch-rpc-request'); - expect(payload.namespace).toBe('snapshot'); + expect(call[0].payload.type).toBe(DISPATCH_RPC_REQUEST_TYPE); + expect(payload.namespace).toBe(DISPATCH_NAMESPACE_SNAPSHOT); expect(payload.method).toBe('removeSnapshotFile'); expect(payload.args).toEqual({ filepath: '/test/snapshot.snap' }); diff --git a/packages/core/src/cli/init/browser/templates.ts b/packages/core/src/cli/init/browser/templates.ts index fc91afe19..cb182e088 100644 --- a/packages/core/src/cli/init/browser/templates.ts +++ b/packages/core/src/cli/init/browser/templates.ts @@ -71,18 +71,19 @@ export function getReactTestTemplate(lang: 'ts' | 'js'): string { const componentExt = lang === 'ts' ? 'tsx' : 'jsx'; return `import { expect, test } from '@rstest/core'; -import { render } from '@rstest/browser-react'; -import Counter from './Counter.${componentExt}'; - -test('increments count on button click', async () => { - const screen = await render(); +import { page } from '@rstest/browser'; + import { render } from '@rstest/browser-react'; + import Counter from './Counter.${componentExt}'; + + test('increments count on button click', async () => { + await render(); - await expect.element(screen.getByText('Count: 5')).toBeInTheDocument(); + await expect.element(page.getByText('Count: 5')).toBeVisible(); - await screen.getByRole('button', { name: 'Increment' }).click(); - await expect.element(screen.getByText('Count: 6')).toBeInTheDocument(); -}); -`; + await page.getByRole('button', { name: 'Increment' }).click(); + await expect.element(page.getByText('Count: 6')).toBeVisible(); + }); + `; } /** @@ -156,15 +157,15 @@ export function getVanillaTestTemplate(lang: 'ts' | 'js'): string { import { page } from '@rstest/browser'; import { createCounter } from './Counter.${ext}'; -test('increments count on button click', async () => { - document.body.appendChild(createCounter(5)); - - await expect.element(page.getByText('Count: 5')).toBeInTheDocument(); - - await page.getByRole('button', { name: 'Increment' }).click(); - await expect.element(page.getByText('Count: 6')).toBeInTheDocument(); -}); -`; + test('increments count on button click', async () => { + document.body.appendChild(createCounter(5)); + + await expect.element(page.getByText('Count: 5')).toBeVisible(); + + await page.getByRole('button', { name: 'Increment' }).click(); + await expect.element(page.getByText('Count: 6')).toBeVisible(); + }); + `; } /** diff --git a/packages/core/src/core/browserLoader.ts b/packages/core/src/core/browserLoader.ts index 18b52462e..b70019d90 100644 --- a/packages/core/src/core/browserLoader.ts +++ b/packages/core/src/core/browserLoader.ts @@ -10,7 +10,7 @@ import { color, logger } from '../utils'; export type { BrowserTestRunOptions, BrowserTestRunResult } from '../types'; /** - * Type definition for the @rstest/browser package exports. + * Type definition for the @rstest/browser internal exports. */ export interface BrowserModule { validateBrowserConfig: (context: unknown) => void; @@ -38,7 +38,7 @@ export interface LoadBrowserModuleOptions { } /** - * Load @rstest/browser package with version validation. + * Load @rstest/browser internal module with version validation. * Throws an error if the package is not installed or version mismatches. * * Resolution strategy (in order): @@ -73,7 +73,7 @@ export async function loadBrowserModule( for (const base of uniqueBases) { try { const userRequire = createRequire(base); - const browserPath = userRequire.resolve('@rstest/browser'); + const browserPath = userRequire.resolve('@rstest/browser/internal'); const browserPkgPath = userRequire.resolve( '@rstest/browser/package.json', ); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d6b2c7f9d..20ff6ea5b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,7 @@ export function defineProject(config: RstestProjectConfigExport) { export type { Assertion, DescribeAPI as Describe, + ExpectStatic, ExtendConfig, ExtendConfigFn, ProjectConfig, diff --git a/packages/core/src/runtime/api/expect.ts b/packages/core/src/runtime/api/expect.ts index 3dc1133a4..4cf87f4b7 100644 --- a/packages/core/src/runtime/api/expect.ts +++ b/packages/core/src/runtime/api/expect.ts @@ -104,6 +104,13 @@ export function createExpect({ expect.poll = createExpectPoll(expect); + (expect as any).element = () => { + throw new Error( + 'expect.element() is only available in browser mode. ' + + 'Enable browser mode in config and import @rstest/browser to install the browser expect adapter.', + ); + }; + expect.unreachable = (message?: string) => { chai.assert.fail( `expected ${message ? `"${message}" ` : ''}not to be reached`, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3a1c8f11..0b785ff89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -569,12 +569,6 @@ importers: '@rstest/core': specifier: workspace:* version: link:../../packages/core - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/user-event': - specifier: ^14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) '@types/react': specifier: ^19.2.14 version: 19.2.14 diff --git a/website/docs/en/api/runtime-api/_meta.json b/website/docs/en/api/runtime-api/_meta.json index 02fd1a8fc..a83ceae61 100644 --- a/website/docs/en/api/runtime-api/_meta.json +++ b/website/docs/en/api/runtime-api/_meta.json @@ -9,6 +9,11 @@ "name": "test-api", "label": "Test API" }, + { + "type": "dir", + "name": "browser-mode", + "label": "Browser Mode" + }, { "type": "dir", "name": "rstest", diff --git a/website/docs/en/api/runtime-api/browser-mode/_meta.json b/website/docs/en/api/runtime-api/browser-mode/_meta.json new file mode 100644 index 000000000..62a15626f --- /dev/null +++ b/website/docs/en/api/runtime-api/browser-mode/_meta.json @@ -0,0 +1 @@ +["locator", "assertion"] diff --git a/website/docs/en/api/runtime-api/browser-mode/assertion.mdx b/website/docs/en/api/runtime-api/browser-mode/assertion.mdx new file mode 100644 index 000000000..fd6894d75 --- /dev/null +++ b/website/docs/en/api/runtime-api/browser-mode/assertion.mdx @@ -0,0 +1,380 @@ +--- +title: Assertion +overviewHeaders: [2, 3] +--- + +# Assertion + +`expect.element` is the API for [Locator](/api/runtime-api/browser-mode/locator) assertions in Browser Mode. It accepts a `Locator` and returns a chainable assertion object. Unlike `expect(value)` which primarily compares JS values, `expect.element` targets the real element state on the page, suitable for verifying visibility, text, form values, count, and other user-observable results. + +:::tip Auto-retry +With the Playwright provider, all `expect.element` matchers automatically retry until the assertion passes or the timeout is reached. You do not need to write manual `waitFor` or polling logic — just assert the expected state and the framework handles the waiting. +::: + +When you import `@rstest/browser` in a Browser Mode test (e.g., importing `page`), `expect` automatically gains the `element` capability. This allows queries, interactions, and assertions to be organized around the same `Locator`, focusing failure messages on element state. + +The following minimal example demonstrates common usage: first assert visibility, then verify a checked state change, and finally validate an element attribute. + +```ts title="src/assertion.test.ts" +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('asserts element states in browser mode', async () => { + await expect + .element(page.getByRole('button', { name: 'Save' })) + .toBeVisible(); + + await expect.element(page.getByLabel('Agree')).toBeUnchecked(); + await page.getByLabel('Agree').check(); + await expect.element(page.getByLabel('Agree')).toBeChecked(); + + await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveAttribute('id', 'save-btn'); +}); +``` + +## Type signature + +```ts +expect.element(locator: Locator): BrowserElementExpect +``` + +## Auto-retry and timeout + +Assertion behavior in `expect.element` depends on the provider. With the Playwright provider, `expect.element` matchers are **web-first assertions**: they continuously retry within the timeout duration until the assertion passes or times out. You do not need to write manual `waitFor` or polling logic. + +### Timeout priority + +The assertion timeout is determined by the following priority: + +1. **Per-assertion timeout**: The `timeout` parameter passed directly to the matcher, highest priority +2. **Global `testTimeout` configuration**: From `testTimeout` in `rstest.config.ts` (default 5000ms) +3. **RPC fallback timeout**: 30000ms (serves only as a communication-layer safety net, usually not reached) + +```ts +// Uses global testTimeout (default 5000ms) +await expect.element(page.getByText('Done')).toBeVisible(); + +// Override with a longer timeout +await expect.element(page.getByText('Done')).toBeVisible({ timeout: 10000 }); +``` + +### Failure output + +When an assertion times out, the error message includes: the expected element state, the actual state, and the timeout duration. For example: + +``` +Error: Expected element to be visible + Locator: getByText('Loading complete') + Timeout: 5000ms +``` + +### Use cases + +Auto-retry is especially useful for handling async rendering scenarios — such as waiting for a loading indicator to disappear or UI changes after data finishes loading: + +```ts +// Click and wait for async result to appear +await page.getByRole('button', { name: 'Submit' }).click(); +await expect.element(page.getByText('Submitted!')).toBeVisible(); + +// Wait for loading to disappear +await expect.element(page.getByTestId('spinner')).toBeHidden(); +``` + +## Input constraints + +- `locator` must be a `Locator` returned by `@rstest/browser`. This allows the runtime to recognize and replay the complete query chain; hand-crafted objects or locators from other libraries cannot be parsed by this mechanism. +- `expect.element` is not available outside of Browser Mode. It relies on Browser Mode's browser runtime and communication channel to execute element assertions; pure Node test environments only support regular `expect(value)` assertions. + +## Element assertions + +The matchers listed below represent the currently supported subset. Matchers not listed here are not yet available. + +### not + +- **Type:** `BrowserElementExpect` + +Negates the subsequent assertion. + +```ts +await expect + .element(page.getByRole('button', { name: 'Submit' })) + .not.toBeDisabled(); +``` + +All the following matchers support an optional `options?: { timeout?: number }` parameter (unless the type signature states otherwise). + +### toBeVisible + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The `Locator` resolves to a mounted and visible element within the `timeout`. + +Visibility is determined by: the element has a non-empty bounding box and `visibility` is not `hidden`. For example, `display: none` or zero dimensions are not considered visible; `opacity: 0` is still considered visible. + +```ts +await expect.element(page.getByRole('button', { name: 'Save' })).toBeVisible(); +``` + +### toBeHidden + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The `Locator` meets any of the following conditions within the `timeout`: does not match any DOM node, or matches a non-visible node. + +Think of it as the opposite of `toBeVisible`; for example, setting only `opacity: 0` typically will not cause `toBeHidden` to pass. + +```ts +await expect.element(page.getByTestId('loading')).toBeHidden(); +``` + +### toBeEnabled + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The element is not in a disabled state. + +Disabled determination: a native form control with the `disabled` attribute, within a disabled `fieldset`, or within an `aria-disabled=true` semantic context may all be considered disabled. + +```ts +await expect + .element(page.getByRole('button', { name: 'Submit' })) + .toBeEnabled(); +``` + +### toBeDisabled + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The element is determined to be disabled. + +The determination rules are the same as `toBeEnabled`, just with the opposite result. Recommended for scenarios like button submission and preventing duplicate clicks. + +```ts +await expect + .element(page.getByRole('button', { name: 'Submit' })) + .toBeDisabled(); +``` + +### toBeChecked + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The `checked` state of a checkbox or radio is `true`. + +Commonly used to verify user check actions or whether a default checked state has taken effect. + +```ts +await expect.element(page.getByLabel('Agree')).toBeChecked(); +``` + +### toBeUnchecked + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The `checked` state of a checkbox or radio is `false`. + +Suitable for use with `check` / `uncheck` to verify state changes before and after interaction. + +```ts +await expect.element(page.getByLabel('Agree')).toBeUnchecked(); +``` + +### toBeAttached + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The node pointed to by the `Locator` is connected to a `Document` or `ShadowRoot` (equivalent to `Node.isConnected === true`). + +This assertion only checks "whether it is in the DOM tree" and does not require the element to be visible. + +```ts +await expect.element(page.locator('#toast')).toBeAttached(); +``` + +### toBeDetached + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The `Locator` no longer points to a connected DOM node. + +Common for verification after conditional rendering, async unmounting, or delete operations. + +```ts +await expect.element(page.locator('#toast')).toBeDetached(); +``` + +### toBeEditable + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The element is both enabled and not readonly. + +Readonly determination includes both the native `readonly` attribute and `aria-readonly=true` semantics. + +```ts +await expect.element(page.getByLabel('Bio')).toBeEditable(); +``` + +### toBeFocused + +- **Type:** `(options?: { timeout?: number }) => Promise` + +The element is the current document's focus target (active element). + +Suitable for verifying keyboard navigation, auto-focus, or form focus-switching behavior. + +```ts +await expect.element(page.getByLabel('Username')).toBeFocused(); +``` + +### toBeEmpty + +- **Type:** `(options?: { timeout?: number }) => Promise` + +An editable element's content is empty, or a regular DOM node has no text content. + +It checks "whether the content is empty", not "whether the element exists" or "whether it is visible". + +```ts +await expect.element(page.locator('#empty-state')).toBeEmpty(); +``` + +### toBeInViewport + +- **Type:** `(options?: { timeout?: number; ratio?: number }) => Promise` + +The element intersects with the viewport (based on Intersection Observer semantics). + +`ratio` represents the minimum intersection ratio; for example, `ratio: 0.5` means at least half of the area is within the viewport. + +```ts +await expect.element(page.locator('#hero')).toBeInViewport({ ratio: 0.5 }); +``` + +### toHaveText + +- **Type:** `(text: string | RegExp, options?: { timeout?: number }) => Promise` + +The element's text fully matches the expected value (supports `string` / `RegExp`). + +Text computation includes nested child element content. When the expected value is a `string`, whitespace and line breaks are normalized before matching. + +```ts +await expect.element(page.getByRole('status')).toHaveText('Saved'); +``` + +### toContainText + +- **Type:** `(text: string | RegExp, options?: { timeout?: number }) => Promise` + +The element's text contains the expected substring, or matches the given regex. + +The difference from `toHaveText` is: `toContainText` does substring matching, while `toHaveText` does full matching. + +```ts +await expect.element(page.getByRole('status')).toContainText('Save'); +``` + +### toHaveValue + +- **Type:** `(value: string | RegExp, options?: { timeout?: number }) => Promise` + +The form control's current `value` matches the expected value (supports `string` / `RegExp`). + +Applicable to `input`, `textarea`, `select`, and other elements with retrievable values. + +```ts +await expect.element(page.getByLabel('Email')).toHaveValue('dev@rstest.rs'); +``` + +### toHaveId + +- **Type:** `(value: string | RegExp, options?: { timeout?: number }) => Promise` + +The element's `id` matches the expected value (supports `string` / `RegExp`). + +Suitable for validating dynamically generated IDs or fixed IDs injected after component mounting. + +```ts +await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveId('save-btn'); +``` + +### toHaveClass + +- **Type:** `(value: string | RegExp, options?: { timeout?: number }) => Promise` + +The element's `class` attribute matches the expected value (supports `string` / `RegExp`). + +When passing a `string`, it matches against the entire `class` string. If you only care about a specific class, consider using a more targeted regex. + +```ts +await expect.element(page.getByRole('alert')).toHaveClass(/error/); +``` + +### toHaveAttribute + +- **Type:** + - `(name: string, options?: { timeout?: number }) => Promise` + - `(name: string, value: string | RegExp, options?: { timeout?: number }) => Promise` + +When only `name` is passed, it asserts the attribute exists. When `name + value` is passed, it asserts the attribute value matches (`value` supports `string` / `RegExp`). + +Common use cases include validating structural attributes like `type`, `disabled`, `aria-*`, etc. + +```ts +await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveAttribute('type'); +await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveAttribute('type', 'submit'); +``` + +### toHaveCount + +- **Type:** `(count: number, options?: { timeout?: number }) => Promise` + +The number of elements resolved by the `Locator` exactly matches `count`. + +Suitable for list rendering, filter results, pagination item counts, and similar scenarios. + +```ts +await expect.element(page.getByRole('listitem')).toHaveCount(3); +``` + +### toHaveCSS + +- **Type:** `(name: string, value: string | RegExp, options?: { timeout?: number }) => Promise` + +The specified CSS property value in the element's computed style matches the expected value. + +`name` must be a non-empty string; `value` supports `string` / `RegExp`. + +```ts +await expect.element(page.getByRole('alert')).toHaveCSS('display', 'block'); +``` + +### toHaveJSProperty + +- **Type:** `(name: string, value: unknown, options?: { timeout?: number }) => Promise` + +The JS property on the element matches the expected value. + +`name` must be a non-empty string; `value` must be JSON-serializable (assertion parameters are transmitted through the Browser Mode channel). + +```ts +await expect + .element(page.getByLabel('Agree')) + .toHaveJSProperty('checked', true); +``` + +## Related API + +- [Locator](/api/runtime-api/browser-mode/locator) +- [Page (Locator entry point)](/api/runtime-api/browser-mode/locator#page) +- [Expect](/api/runtime-api/test-api/expect) diff --git a/website/docs/en/api/runtime-api/browser-mode/index.mdx b/website/docs/en/api/runtime-api/browser-mode/index.mdx new file mode 100644 index 000000000..a2d5e5eba --- /dev/null +++ b/website/docs/en/api/runtime-api/browser-mode/index.mdx @@ -0,0 +1,52 @@ +--- +title: Browser mode +--- + +# Browser mode + +The Browser Mode API is provided by [@rstest/browser](https://github.com/web-infra-dev/rstest/tree/main/packages/browser), designed to let you use a Playwright-style Locator workflow in browser tests. + +## Exported API + +`@rstest/browser` provides the runtime entry for Browser Mode. Its core responsibility is **locating elements and driving interactions**. + +`page` is the query entry point, `Locator` handles chaining and action execution, `BrowserPage` describes the query APIs supported by `page`, and `setTestIdAttribute` configures the test id attribute name. + +The following are the APIs currently exported by `@rstest/browser`: + +- [`page`](/api/runtime-api/browser-mode/locator#page) - `BrowserPage` query entry (queries only) +- [`Locator`](/api/runtime-api/browser-mode/locator) - Core class for chained queries and interactions +- [`BrowserPage`](/api/runtime-api/browser-mode/locator#browserpage) - Type definition for `page` +- [`setTestIdAttribute`](/api/runtime-api/browser-mode/locator#settestidattribute) - Configure the attribute name used by `getByTestId()` + +## Assertion API + +The assertion entry point is `expect.element(locator)`. + +When you import `@rstest/browser` in a Browser Mode test (e.g., `import { page } from '@rstest/browser'`), `expect` automatically gains the `element` capability. + +Its responsibility is to perform web-first assertions (with auto-waiting) on a `Locator`, such as `toBeVisible` and `toHaveText`. + +The relationship between the two can be understood as: `@rstest/browser` is responsible for **finding and operating on elements**, while `expect.element` is responsible for **verifying element state**. + +- [`expect.element(locator)`](/api/runtime-api/browser-mode/assertion) - Perform auto-waiting assertions on a `Locator` + +## Example: query, interaction, and assertion + +```ts title="browser-example.test.ts" +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('submits form', async () => { + await page.getByLabel('Email').fill('dev@rstest.rs'); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect.element(page.getByText('Done')).toBeVisible(); +}); +``` + +In this example, `page` first creates a `Locator`, the `Locator` executes `fill/click`, and finally `expect.element(locator)` performs a visibility assertion on the result, forming a complete test cycle. + +## Detailed reference + +- [Locator](/api/runtime-api/browser-mode/locator) +- [Assertion](/api/runtime-api/browser-mode/assertion) diff --git a/website/docs/en/api/runtime-api/browser-mode/locator.mdx b/website/docs/en/api/runtime-api/browser-mode/locator.mdx new file mode 100644 index 000000000..597b67a0f --- /dev/null +++ b/website/docs/en/api/runtime-api/browser-mode/locator.mdx @@ -0,0 +1,407 @@ +--- +title: Locator +overviewHeaders: [2, 3] +--- + +# Locator + +`Locator` is the core API for element querying and interaction in Browser Mode. You can build query chains via `page.getBy*` or `page.locator()`, then execute interaction actions. Concrete execution semantics are provided by the configured browser `provider`. + +:::tip Auto-wait +With the Playwright provider, Locator interaction methods (such as `click`, `fill`, `check`) automatically wait for the element to become actionable (visible, enabled, stable) before executing. You do not need to manually wait for elements before performing actions. This is distinct from the [auto-retry](/api/runtime-api/browser-mode/assertion#auto-retry-and-timeout) behavior of `expect.element` assertions. +::: + +The following example demonstrates the typical workflow: query elements, perform interactions, and combine with [expect.element](/api/runtime-api/browser-mode/assertion) for assertions. + +```ts title="src/locator.test.ts" +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('interacts with a form using locator', async () => { + await page.getByLabel('Username').fill('alice'); + await page.getByLabel('Password').fill('secret123'); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect.element(page.getByLabel('Username')).toHaveValue('alice'); +}); +``` + +## page + +- **Type:** `BrowserPage` + +`page` is the starting point in tests: first use `page` to locate elements, then execute interactions and assertions on the returned `Locator`. + +`page` is a query-only object: it only creates `Locator` instances and does not directly execute interaction actions. + +```ts +const submitButton = page.getByRole('button', { name: 'Submit' }); +``` + +The `submitButton` above is a `Locator`. You can continue chaining calls on it (e.g., `.click()`) or pass it to `expect.element(...)` for assertions. + +## BrowserPage + +- **Type:** `Pick` + +`BrowserPage` is the type definition for `page`. It only exposes query entry points and does not include interaction methods like `click`, `fill`, etc. + +This means you need to first obtain a `Locator` through `page`, then call interactions and assertions on the `Locator`. + +## Query API + +All the following APIs return a new `Locator` and support further chaining. + +### locator + +- **Type:** `(selector: string) => Locator` + +Query elements by CSS selector. + +```ts +const item = page.locator('.todo-item'); +``` + +### getByRole + +- **Type:** `(role: string, options?: LocatorGetByRoleOptions) => Locator` + +Query elements by semantic role. Recommended as the first choice. + +`LocatorGetByRoleOptions` supports: `name`, `exact`, `checked`, `disabled`, `expanded`, `selected`, `pressed`, `includeHidden`, `level`. + +```ts +const saveBtn = page.getByRole('button', { name: 'Save' }); +``` + +### getByText + +- **Type:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +Query by visible text. + +```ts +const successMessage = page.getByText('Saved successfully'); +``` + +### getByLabel + +- **Type:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +Query by form label. + +```ts +const emailInput = page.getByLabel('Email'); +``` + +### getByPlaceholder + +- **Type:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +Query by placeholder. + +```ts +const searchInput = page.getByPlaceholder('Search'); +``` + +### getByAltText + +- **Type:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +Query by `alt` text. + +```ts +const avatarImage = page.getByAltText('User avatar'); +``` + +### getByTitle + +- **Type:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +Query by `title`. + +```ts +const helpIcon = page.getByTitle('Help'); +``` + +### getByTestId + +- **Type:** `(text: string | RegExp) => Locator` + +Query by test id. + +```ts +const settingsPanel = page.getByTestId('settings-panel'); +``` + +## Configuration API + +### setTestIdAttribute + +- **Type:** `(attribute: string) => Promise` + +Set the attribute name used by `getByTestId()`. The default value is `data-testid`. + +This configuration is forwarded through the Browser Mode channel to the host provider (e.g., Playwright's `selectors.setTestIdAttribute()`). + +This is a global configuration. It is recommended to set it once during the test setup phase to avoid inconsistent query behavior caused by mid-test modifications. + +```ts +import { page, setTestIdAttribute } from '@rstest/browser'; + +await setTestIdAttribute('data-test'); +await page.getByTestId('settings-panel').click(); +``` + +:::tip +It is recommended to configure `setTestIdAttribute` in a setup file to ensure it applies to all tests consistently: + +```ts title="rstest.setup.ts" +import { setTestIdAttribute } from '@rstest/browser'; + +await setTestIdAttribute('data-qa'); +``` + +Then reference it in your config: + +```ts title="rstest.config.ts" +export default defineConfig({ + setupFiles: ['./rstest.setup.ts'], +}); +``` + +::: + +## Composition API + +### filter + +- **Type:** `(options: LocatorFilterOptions) => Locator` + +`LocatorFilterOptions` supports: + +- `hasText?: string | RegExp`: Keep elements matching the text +- `hasNotText?: string | RegExp`: Exclude elements matching the text +- `has?: Locator`: Keep elements containing the child Locator +- `hasNot?: Locator`: Exclude elements containing the child Locator + +Used for secondary filtering on existing query results. + +```ts +const profileSave = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Profile' }) }) + .getByRole('button', { name: 'Save' }); +``` + +```ts +const visibleItems = page.locator('li').filter({ + hasNotText: /archived/i, + hasNot: page.getByRole('img', { name: 'Locked' }), +}); +``` + +### and / or + +- **Type:** `(other: Locator) => Locator` + +Combine two Locator conditions. + +```ts +const byRole = page.getByRole('button', { name: 'Submit' }); +const byId = page.locator('#submit'); + +const exactOne = byRole.and(byId); +``` + +### nth / first / last + +- **Type:** + - `nth(index: number): Locator` + - `first(): Locator` + - `last(): Locator` + +Select a specific element from the matched set. + +## Interaction API + +The following APIs trigger actual browser actions and return `Promise`. + +:::warning Strictness +Locators are strict. If a Locator interaction action (like `click` or `fill`) resolves to more than one element, the operation will throw an error. Use [`first()`](#nth--first--last), [`last()`](#nth--first--last), or [`nth()`](#nth--first--last) to explicitly select a single element when the query matches multiple elements. +::: + +These `*Options` types represent the set of options currently supported in Browser Mode: + +- `LocatorClickOptions` / `LocatorDblclickOptions`: `button`, `delay`, `force`, `modifiers`, `position`, `timeout`, `trial` (`click` additionally supports `clickCount`) +- `LocatorHoverOptions`: `force`, `modifiers`, `position`, `timeout`, `trial` +- `LocatorPressOptions`: `delay`, `timeout` +- `LocatorFillOptions`: `force`, `timeout` +- `LocatorCheckOptions`: `force`, `position`, `timeout`, `trial` +- `LocatorFocusOptions` / `LocatorBlurOptions` / `LocatorScrollIntoViewIfNeededOptions`: `timeout` +- `LocatorWaitForOptions`: `state` (`attached` / `detached` / `visible` / `hidden`) and `timeout` +- `LocatorSelectOptionOptions`: `force`, `timeout` +- `LocatorSetInputFilesOptions`: `timeout` + +### click + +- **Type:** `(options?: LocatorClickOptions) => Promise` + +Click the element matched by the current Locator. + +```ts +await page.getByRole('button', { name: 'Submit' }).click(); +``` + +### dblclick + +- **Type:** `(options?: LocatorDblclickOptions) => Promise` + +Double-click the element. + +```ts +await page.getByText('Open details').dblclick(); +``` + +### hover + +- **Type:** `(options?: LocatorHoverOptions) => Promise` + +Hover the mouse over the element, commonly used to trigger hover menus or tooltips. + +```ts +await page.getByRole('button', { name: 'More' }).hover(); +``` + +### press + +- **Type:** `(key: string, options?: LocatorPressOptions) => Promise` + +Send a keyboard key press on the element. + +```ts +await page.getByLabel('Search').press('Enter'); +``` + +### fill + +- **Type:** `(value: string, options?: LocatorFillOptions) => Promise` + +Set the value of an input field. Applicable to `input`, `textarea`, and other editable elements. + +```ts +await page.getByPlaceholder('Email').fill('dev@rstest.rs'); +``` + +### clear + +- **Type:** `() => Promise` + +Clear the value of the current input element. + +```ts +await page.getByPlaceholder('Email').clear(); +``` + +### focus + +- **Type:** `(options?: LocatorFocusOptions) => Promise` + +Focus the element. + +```ts +await page.getByLabel('Username').focus(); +``` + +### blur + +- **Type:** `(options?: LocatorBlurOptions) => Promise` + +Remove focus from the element. + +```ts +await page.getByLabel('Username').blur(); +``` + +### check + +- **Type:** `(options?: LocatorCheckOptions) => Promise` + +Check a checkbox or radio button. + +```ts +await page.getByLabel('Agree').check(); +``` + +### uncheck + +- **Type:** `(options?: LocatorCheckOptions) => Promise` + +Uncheck a checkbox. + +```ts +await page.getByLabel('Agree').uncheck(); +``` + +### scrollIntoViewIfNeeded + +- **Type:** `(options?: LocatorScrollIntoViewIfNeededOptions) => Promise` + +Scroll the page if needed to bring the element into the visible area. + +```ts +await page.getByRole('button', { name: 'Load more' }).scrollIntoViewIfNeeded(); +``` + +### waitFor + +- **Type:** `(options?: LocatorWaitForOptions) => Promise` + +Wait until the Locator meets the specified condition before continuing execution. Useful for handling async rendering. + +```ts +await page.getByText('Ready').waitFor(); +``` + +### dispatchEvent + +- **Type:** `(type: string, eventInit?: LocatorDispatchEventInit) => Promise` + +Dispatch a custom or native event on the element. + +```ts +await page.getByRole('button', { name: 'Event' }).dispatchEvent('custom'); +``` + +### selectOption + +- **Type:** `(value: string | string[], options?: LocatorSelectOptionOptions) => Promise` + +Select an option of a `select` element. Currently only supports `string` or `string[]` as the value. + +```ts +await page.getByLabel('Choice').selectOption('b'); +``` + +### setInputFiles + +- **Type:** `(files: string | string[], options?: LocatorSetInputFilesOptions) => Promise` + +Set files for an `input[type="file"]`. Currently only supports file paths as `string` or `string[]`. + +```ts +await page.locator('#upload').setInputFiles('/tmp/demo.txt'); +``` + +## Usage constraints + +- The APIs listed on this page represent the currently supported subset of the Playwright Locator API. APIs not listed here are not yet available +- The argument to `and` / `or` / `filter({ has | hasNot })` must be a `Locator` returned by `@rstest/browser` +- The `type` argument to `dispatchEvent(type, eventInit?)` must be a non-empty string +- `selectOption` currently only supports `string` or `string[]` +- `setInputFiles` currently only supports file paths as `string` or `string[]` +- Some parameters are transmitted through the Browser Mode communication channel; it is recommended to keep them JSON-serializable + +## Using with expect.element + +`Locator` is typically used together with `expect.element(locator)`. For the full list of assertions, see [Assertion](/api/runtime-api/browser-mode/assertion). diff --git a/website/docs/en/api/runtime-api/index.mdx b/website/docs/en/api/runtime-api/index.mdx index 8a8e64cc3..39122c943 100644 --- a/website/docs/en/api/runtime-api/index.mdx +++ b/website/docs/en/api/runtime-api/index.mdx @@ -4,3 +4,6 @@ title: Runtime API Overview --- This page lists all the testing APIs for Rstest. + +- For general testing APIs, see [Test API](/api/runtime-api/test-api/expect) +- For Browser Mode specific APIs, see [Browser Mode](/api/runtime-api/browser-mode/) diff --git a/website/docs/en/api/runtime-api/test-api/expect.mdx b/website/docs/en/api/runtime-api/test-api/expect.mdx index 3bdab92b8..4c19d97f5 100644 --- a/website/docs/en/api/runtime-api/test-api/expect.mdx +++ b/website/docs/en/api/runtime-api/test-api/expect.mdx @@ -43,6 +43,26 @@ expect('hello').toBeDefined(); expect([1, 2, 3]).toContain(2); ``` +## expect.element (Browser Mode) + +- **Type:** `(locator: Locator) => BrowserElementExpect` + +In Browser Mode, `expect.element` is used to assert against a Locator provided by `@rstest/browser` (for example, `toBeVisible`, `toHaveText`, `toHaveValue`). + +```ts +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('asserts by locator', async () => { + document.body.innerHTML = ''; + await expect + .element(page.getByRole('button', { name: 'Save' })) + .toBeVisible(); +}); +``` + +`expect.element` is only available in Browser Mode and requires importing `@rstest/browser` to install the browser-side adapter. See [Assertion (Browser Mode)](/api/runtime-api/browser-mode/assertion) for the complete reference. + ## expect.not Negates the assertion. diff --git a/website/docs/en/config/test/browser.mdx b/website/docs/en/config/test/browser.mdx index b9fe28fff..4f56b1f79 100644 --- a/website/docs/en/config/test/browser.mdx +++ b/website/docs/en/config/test/browser.mdx @@ -65,6 +65,8 @@ npx playwright install chromium Browser driver provider. Currently only [Playwright](https://playwright.dev/) is supported. +Mixing multiple providers in the same test run is currently not supported. + ```ts title="rstest.config.ts" import { defineConfig } from '@rstest/core'; @@ -216,40 +218,52 @@ export default defineConfig({ }); ``` -## Multi-Browser Testing +## Current limitation: browser launch options must match in one run + +In one `rstest` process, all Browser Mode projects must share the same browser launch options: + +- `provider` +- `browser` +- `headless` +- `port` +- `strictPort` + +This means mixing multiple providers, or running Chromium/Firefox/WebKit together in the same run, is not supported yet. + +For cross-browser coverage, run tests in separate executions (for example, in a CI matrix): + +```bash +npx rstest --browser.browser chromium +npx rstest --browser.browser firefox +npx rstest --browser.browser webkit +``` + +## Multi-project config isolation + +When using `projects` in Browser Mode, each project still compiles and executes with its own build config (for example `plugins`, `include`, and framework-specific setup). -Use [projects](/config/test/projects) configuration to run tests in multiple browsers simultaneously: +Browser launch options still need to stay aligned across browser projects: `provider`, `browser`, `headless`, `port`, and `strictPort`. ```ts title="rstest.config.ts" import { defineConfig } from '@rstest/core'; export default defineConfig({ - projects: [ - { - name: 'chromium', - browser: { - enabled: true, - provider: 'playwright', - browser: 'chromium', - }, - }, - { - name: 'firefox', - browser: { - enabled: true, - provider: 'playwright', - browser: 'firefox', - }, - }, - { - name: 'webkit', - browser: { - enabled: true, - provider: 'playwright', - browser: 'webkit', - }, - }, - ], + projects: ['./project-b/rstest.config.ts', './project-a/rstest.config.ts'], +}); +``` + +```ts title="project-a/rstest.config.ts" +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-a', + plugins: [pluginReact()], + include: ['tests/**/*.test.tsx'], + browser: { + enabled: true, + provider: 'playwright', + }, }); ``` @@ -289,3 +303,4 @@ export default defineConfig({ - [Browser Mode Guide](/guide/browser-testing/) - Introduction and usage guide for Browser Mode - [Getting Started](/guide/browser-testing/getting-started) - Configure Browser Mode tests +- [User interactions](/guide/browser-testing/user-interactions#locator-api) - Write semantic tests with `page` + `expect.element` diff --git a/website/docs/en/config/test/projects.mdx b/website/docs/en/config/test/projects.mdx index da3537b8a..df95fc0e5 100644 --- a/website/docs/en/config/test/projects.mdx +++ b/website/docs/en/config/test/projects.mdx @@ -49,3 +49,55 @@ export default defineConfig({ ``` More information about projects can be found in [Test projects](/guide/basic/projects). + +## Projects in browser mode + +When you use `projects` with Browser Mode, each project compiles and executes with its own build config, which lets you keep different stacks or build setups in one repository. + +In the same run, browser launch options must stay consistent across browser projects: + +- `provider` +- `browser` +- `headless` +- `port` +- `strictPort` + +So mixing multiple providers, or multiple browser types, in one run is not supported yet. + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + projects: ['./project-b/rstest.config.ts', './project-a/rstest.config.ts'], +}); +``` + +```ts title="project-a/rstest.config.ts" +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-a', + plugins: [pluginReact()], + include: ['tests/**/*.test.tsx'], + browser: { + enabled: true, + provider: 'playwright', + }, +}); +``` + +```ts title="project-b/rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-b', + include: ['tests/**/*.test.ts'], + browser: { + enabled: true, + provider: 'playwright', + }, +}); +``` + +You can still mix Browser Mode projects with `testEnvironment: 'node'`/`'jsdom'` projects in one `projects` list, and results will be merged in the final report. diff --git a/website/docs/en/guide/browser-testing/getting-started.mdx b/website/docs/en/guide/browser-testing/getting-started.mdx index 25d56cb61..592b9696a 100644 --- a/website/docs/en/guide/browser-testing/getting-started.mdx +++ b/website/docs/en/guide/browser-testing/getting-started.mdx @@ -104,42 +104,32 @@ export default defineConfig({ ### 3. Write tests -Create a simple browser test file: +Create a browser test file. The recommended approach is to use the Locator API for element queries and interactions: -```ts title="src/dom.test.ts" +```ts title="tests/counter.test.ts" +import { page } from '@rstest/browser'; import { expect, test } from '@rstest/core'; -test('should work with DOM APIs', () => { - const div = document.createElement('div'); - div.textContent = 'Hello Browser!'; - document.body.appendChild(div); - - expect(document.body.textContent).toContain('Hello Browser!'); -}); - -test('should have real browser APIs', () => { - // These APIs may be incomplete or missing in jsdom - expect(typeof window.requestAnimationFrame).toBe('function'); - expect(typeof window.IntersectionObserver).toBe('function'); - expect(typeof window.ResizeObserver).toBe('function'); +test('counter increments on click', async () => { + document.body.innerHTML = ` + + `; + + let count = 0; + document.getElementById('count-btn')!.addEventListener('click', (e) => { + count++; + (e.target as HTMLButtonElement).textContent = `Count: ${count}`; + }); + + await expect + .element(page.getByRole('button', { name: 'Count: 0' })) + .toBeVisible(); + await page.getByRole('button', { name: 'Count: 0' }).click(); + await expect.element(page.getByText('Count: 1')).toBeVisible(); }); ``` -### 4. Run tests - -```bash -npx rstest -``` - -You should see output similar to: - -```bash - ✓ src/dom.test.ts (2) - - Test Files 1 passed - Tests 2 passed - Duration 1.2s -``` +This example uses `page.getByRole()` to locate a button by its semantic role, triggers an interaction with `click()`, and asserts the result with `expect.element().toBeVisible()`. The assertion automatically waits for the element state to change — no manual polling needed. ## Headless vs headed mode @@ -170,5 +160,6 @@ For complete configuration reference, see [browser configuration](/config/test/b ## Next steps -- [Framework Guides](/guide/browser-testing/framework-guides) - Complete configuration and component testing examples for each framework -- [User Interactions](/guide/browser-testing/user-interactions) - Simulate user actions +- [User interactions](/guide/browser-testing/user-interactions#locator-api) - Use `page` + `expect.element` for semantic queries and assertions +- [Framework guides](/guide/browser-testing/framework-guides) - Complete configuration and component testing examples for each framework +- [User interactions](/guide/browser-testing/user-interactions) - Simulate user actions diff --git a/website/docs/en/guide/browser-testing/index.mdx b/website/docs/en/guide/browser-testing/index.mdx index 456180f2d..f9c9793e7 100644 --- a/website/docs/en/guide/browser-testing/index.mdx +++ b/website/docs/en/guide/browser-testing/index.mdx @@ -23,6 +23,14 @@ Rstest provides Browser Mode, allowing you to run tests in a real browser instea Browser Mode uses [Playwright](https://playwright.dev/) to execute your test code in real browsers (Chromium, Firefox, or WebKit). This means your tests run with the exact same browser APIs and behaviors as in production. +## Locator API + +Browser Mode now supports a Playwright-style Locator workflow: you can use `page.getBy*` for element queries, then use `expect.element(locator)` for auto-waiting assertions. + +This approach is ideal when you want semantic queries (role/label/text) and chainable assertions, making component tests and DOM tests closer to real user interaction semantics. + +See [User interactions](/guide/browser-testing/user-interactions#locator-api) for detailed usage. + ## When to use browser mode Use this decision tree to determine if you need Browser Mode: @@ -61,6 +69,7 @@ Browser Mode and jsdom/happy-dom represent different trade-offs: Browser Mode pr ## Next steps -- [Getting Started](/guide/browser-testing/getting-started) - Configure and run your first browser test -- [Framework Guides](/guide/browser-testing/framework-guides) - Complete configuration and component testing examples for each framework -- [User Interactions](/guide/browser-testing/user-interactions) - Simulate user clicks, typing, and other actions +- [Getting started](/guide/browser-testing/getting-started) - Configure and run your first browser test +- [User interactions](/guide/browser-testing/user-interactions#locator-api) - Use `page` + `expect.element` for semantic tests +- [Framework guides](/guide/browser-testing/framework-guides) - Complete configuration and component testing examples for each framework +- [User interactions](/guide/browser-testing/user-interactions) - Simulate user clicks, typing, and other actions diff --git a/website/docs/en/guide/browser-testing/user-interactions.mdx b/website/docs/en/guide/browser-testing/user-interactions.mdx index 79f9fbb53..e83ae5a9e 100644 --- a/website/docs/en/guide/browser-testing/user-interactions.mdx +++ b/website/docs/en/guide/browser-testing/user-interactions.mdx @@ -1,17 +1,139 @@ # User interactions -This guide covers how to simulate user interactions in Browser Mode tests. +This guide covers how to simulate user interactions in Browser Mode tests, and helps you choose between stability, maintainability, and control granularity. -In Browser Mode, you can choose between Testing Library or native DOM APIs. Testing Library better simulates real user behavior by automatically handling complete event sequences and focus management, making it suitable for most UI tests. Native DOM APIs are lighter and allow precise control over event properties, but require manual event sequencing—ideal for verifying low-level event logic or special interaction details. +In Browser Mode, choose your interaction approach in the following priority order (only fall back when needed): + +- **Locator API (preferred)**: Use [page.getBy\*](/api/runtime-api/browser-mode/locator#page) + [expect.element](/api/runtime-api/browser-mode/assertion) for semantic queries, interactions, and assertions, suitable for the vast majority of interaction tests +- **Testing Library**: Best for migrating existing tests or reusing an established [Testing Library](https://testing-library.com/) toolchain; generally not the first choice for new tests +- **Native DOM API (fallback)**: Lighter and allows precise control over event properties, but requires manual event sequencing — ideal for verifying low-level event logic or special interaction details import { PackageManagerTabs } from '@theme'; -## Testing library (Recommended) +## Locator API + +The Locator API is the default choice in Browser Mode. It is officially provided by Rstest: `@rstest/browser` provides the [page](/api/runtime-api/browser-mode/locator#page) query and interaction entry point, and `@rstest/core` provides [expect.element](/api/runtime-api/browser-mode/assertion) assertion capabilities. + +It adopts a Playwright-style Locator syntax ([page.getBy\*](/api/runtime-api/browser-mode/locator#page) + chaining + [expect.element](/api/runtime-api/browser-mode/assertion)), enabling both component tests and DOM tests to reuse the same interaction and assertion patterns. + +Reasons to prefer the Locator API: + +- More stable queries: prioritizes locating elements by user-perceivable semantics such as `role`, `label`, and `text` +- More direct interactions: actions like [click](/api/runtime-api/browser-mode/locator#click), [fill](/api/runtime-api/browser-mode/locator#fill), [check](/api/runtime-api/browser-mode/locator#check), and [press](/api/runtime-api/browser-mode/locator#press) are attached directly to the Locator +- More natural assertions: combined with [expect.element](/api/runtime-api/browser-mode/assertion), waiting and assertion semantics stay consistent +- Higher consistency: a single API set covers queries, actions, and assertions, reducing context-switching between multiple tools + +### Example + +The following example focuses on the most common workflow: filling forms, clicking, and asserting. + +```ts +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('interacts with form using locator api', async () => { + document.body.innerHTML = ` +
+ + + + + + + + + +
+ `; + + await page.getByLabel('Username').fill('alice'); + await page.getByLabel('Password').fill('secret123'); + await page.getByLabel('Remember me').check(); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect.element(page.getByLabel('Username')).toHaveValue('alice'); + await expect.element(page.getByLabel('Remember me')).toBeChecked(); +}); +``` + +### Common queries and composition + +You can compose Locators just like in Playwright, progressively narrowing the scope to the target element: + +```ts +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('composes locators', async () => { + document.body.innerHTML = ` +
+

Home

+ +
+
+

Profile

+ +
+ `; + + const saveInProfileSection = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Profile' }) }) + .getByRole('button', { name: 'Save' }); + + await expect.element(saveInProfileSection).toHaveCount(1); +}); +``` + +Currently available query/composition capabilities include: + +- Semantic and attribute queries: [getByRole](/api/runtime-api/browser-mode/locator#getbyrole), [getByText](/api/runtime-api/browser-mode/locator#getbytext), [getByLabel](/api/runtime-api/browser-mode/locator#getbylabel), [getByPlaceholder](/api/runtime-api/browser-mode/locator#getbyplaceholder), [getByAltText](/api/runtime-api/browser-mode/locator#getbyalttext), [getByTitle](/api/runtime-api/browser-mode/locator#getbytitle), [getByTestId](/api/runtime-api/browser-mode/locator#getbytestid) +- Basic selection and filtering: [locator](/api/runtime-api/browser-mode/locator#locator), [filter](/api/runtime-api/browser-mode/locator#filter) +- Set composition and positioning: [and](/api/runtime-api/browser-mode/locator#and--or), [or](/api/runtime-api/browser-mode/locator#and--or), [nth](/api/runtime-api/browser-mode/locator#nth--first--last), [first](/api/runtime-api/browser-mode/locator#nth--first--last), [last](/api/runtime-api/browser-mode/locator#nth--first--last) + +In practice, prefer semantic queries ([getByRole](/api/runtime-api/browser-mode/locator#getbyrole), [getByLabel](/api/runtime-api/browser-mode/locator#getbylabel)) first, and only fall back to [getByTestId](/api/runtime-api/browser-mode/locator#getbytestid) or CSS selectors when semantic information is insufficient. + +### Common interactions and assertions + +Locators support common interaction APIs (such as [click](/api/runtime-api/browser-mode/locator#click), [fill](/api/runtime-api/browser-mode/locator#fill), [check](/api/runtime-api/browser-mode/locator#check), [hover](/api/runtime-api/browser-mode/locator#hover), [press](/api/runtime-api/browser-mode/locator#press), [selectOption](/api/runtime-api/browser-mode/locator#selectoption)), and can be directly combined with [expect.element](/api/runtime-api/browser-mode/assertion) assertions: + +- State assertions: [toBeVisible](/api/runtime-api/browser-mode/assertion#tobevisible), [toBeHidden](/api/runtime-api/browser-mode/assertion#tobehidden), [toBeEnabled](/api/runtime-api/browser-mode/assertion#tobeenabled), [toBeDisabled](/api/runtime-api/browser-mode/assertion#tobedisabled) +- Form/structure assertions: [toBeChecked](/api/runtime-api/browser-mode/assertion#tobechecked), [toBeFocused](/api/runtime-api/browser-mode/assertion#tobefocused), [toBeEmpty](/api/runtime-api/browser-mode/assertion#tobeempty) +- Text and value assertions: [toHaveText](/api/runtime-api/browser-mode/assertion#tohavetext), [toContainText](/api/runtime-api/browser-mode/assertion#tocontaintext), [toHaveValue](/api/runtime-api/browser-mode/assertion#tohavevalue), [toHaveCount](/api/runtime-api/browser-mode/assertion#tohavecount) +- Attribute assertions: [toHaveAttribute](/api/runtime-api/browser-mode/assertion#tohaveattribute), [toHaveClass](/api/runtime-api/browser-mode/assertion#tohaveclass), [toHaveCSS](/api/runtime-api/browser-mode/assertion#tohavecss), [toHaveJSProperty](/api/runtime-api/browser-mode/assertion#tohavejsproperty) + +It's recommended to assert observable results immediately after key interactions (for example, status text, button state, field values) — this keeps failure messages focused and reduces debugging cost. + +:::info Auto-wait vs Auto-retry +These are two distinct mechanisms in the Locator API: + +- **Auto-wait (interactions)**: Methods like `click()`, `fill()`, `check()` automatically wait for the target element to be visible, enabled, and stable before executing the action. +- **Auto-retry (assertions)**: `expect.element` matchers continuously retry the assertion within the timeout until it passes, ideal for async rendering scenarios. + +In most cases, you only need to `await` each call — the framework handles all waiting and retrying internally. +::: + +You can also chain [not](/api/runtime-api/browser-mode/assertion#not) and use an optional `timeout`: + +```ts +await expect + .element(page.getByRole('button', { name: 'Save' })) + .not.toBeDisabled({ timeout: 1000 }); +``` + +:::warning Strictness +Locator actions are strict: if a locator matches more than one element, actions like `click` and `fill` will throw an error. Use `first()`, `last()`, or `nth()` to select a specific element. +::: + +## Testing library -[Testing Library](https://testing-library.com/) is a testing utility library focused on user behavior. It encourages writing tests that mirror how users actually interact with your application, rather than relying on implementation details. In Browser Mode, we recommend using these two packages together: +[Testing Library](https://testing-library.com/) is a testing utility library focused on user behavior. It encourages writing tests that mirror how users actually interact with your application, rather than relying on implementation details. In Browser Mode, it is better suited as a compatibility and migration solution: -- [@testing-library/dom](https://testing-library.com/docs/dom-testing-library/intro): Provides query methods like `getByRole`, `getByText`, and `getByLabelText` that let you find elements from a user's perspective (e.g., "find the input labeled Username") rather than relying on CSS selectors or test-specific attributes -- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro): Simulates real user interactions by triggering complete event sequences (e.g., `click` fires `mousedown`, `focus`, `mouseup`, `click` in order) and automatically handling focus, cursor position, and other details +- [@testing-library/dom](https://testing-library.com/docs/dom-testing-library/intro): Handles queries, providing methods like `getByRole`, `getByText`, and `getByLabelText` that let you find elements by user-perceivable semantics +- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro): Handles interactions, providing more complete event simulation flows; in Browser Mode, the Locator API is still recommended for new tests ### Installation @@ -53,11 +175,11 @@ test('submits login form with user credentials', async () => { }); ``` -Testing Library offers a rich set of interaction methods including click, text input, keyboard events, dropdown selection, drag and drop, and more. For detailed usage, see the [user-event documentation](https://testing-library.com/docs/user-event/intro). +If your project already uses Testing Library extensively, you can continue reusing its click, text input, keyboard event, dropdown selection, drag and drop, and other capabilities. For detailed usage, see the [user-event documentation](https://testing-library.com/docs/user-event/intro). -## Native DOM APIs +## Native DOM API -If you prefer not to add extra dependencies, or need lower-level event control (such as precisely specifying `clientX`, `ctrlKey`, etc.), you can use native browser DOM APIs directly. +If you prefer not to add extra dependencies, or need lower-level event control (such as precisely specifying `clientX`, `ctrlKey`, etc.), you can use native browser DOM APIs directly. This is typically used as a fallback, only when you need precise control over event parameters. ### Example diff --git a/website/docs/zh/api/runtime-api/_meta.json b/website/docs/zh/api/runtime-api/_meta.json index ef2124cbe..a684fbbb1 100644 --- a/website/docs/zh/api/runtime-api/_meta.json +++ b/website/docs/zh/api/runtime-api/_meta.json @@ -9,6 +9,11 @@ "name": "test-api", "label": "Test API" }, + { + "type": "dir", + "name": "browser-mode", + "label": "Browser Mode" + }, { "type": "dir", "name": "rstest", diff --git a/website/docs/zh/api/runtime-api/browser-mode/_meta.json b/website/docs/zh/api/runtime-api/browser-mode/_meta.json new file mode 100644 index 000000000..62a15626f --- /dev/null +++ b/website/docs/zh/api/runtime-api/browser-mode/_meta.json @@ -0,0 +1 @@ +["locator", "assertion"] diff --git a/website/docs/zh/api/runtime-api/browser-mode/assertion.mdx b/website/docs/zh/api/runtime-api/browser-mode/assertion.mdx new file mode 100644 index 000000000..b710fe480 --- /dev/null +++ b/website/docs/zh/api/runtime-api/browser-mode/assertion.mdx @@ -0,0 +1,380 @@ +--- +title: Assertion +overviewHeaders: [2, 3] +--- + +# Assertion + +`expect.element` 是 Browser Mode 中用于 [Locator](/api/runtime-api/browser-mode/locator) 断言的 API。它接收 `Locator`,返回可链式调用的断言对象。与 `expect(value)` 主要比较 JS 值不同,`expect.element` 面向页面上的真实元素状态,适合验证可见性、文本、表单值、数量等用户可观察结果。 + +:::tip 自动重试 +使用 Playwright provider 时,所有 `expect.element` matcher 都会自动重试,直到断言通过或超时。你不需要手写 `waitFor` 或轮询逻辑——只需断言期望状态,框架会自动处理等待。 +::: + +当你在 Browser Mode 测试里导入 `@rstest/browser`(例如导入 `page`)时,`expect` 会自动具备 `element` 能力。这让查询、交互、断言围绕同一 `Locator` 组织,失败信息更聚焦于元素状态。 + +下面这个最小示例展示了常见用法:先断言可见,再验证勾选状态变化,最后校验元素属性。 + +```ts title="src/assertion.test.ts" +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('asserts element states in browser mode', async () => { + await expect + .element(page.getByRole('button', { name: 'Save' })) + .toBeVisible(); + + await expect.element(page.getByLabel('Agree')).toBeUnchecked(); + await page.getByLabel('Agree').check(); + await expect.element(page.getByLabel('Agree')).toBeChecked(); + + await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveAttribute('id', 'save-btn'); +}); +``` + +## 类型签名 + +```ts +expect.element(locator: Locator): BrowserElementExpect +``` + +## 自动重试与超时 + +`expect.element` 的断言行为依赖具体 provider。使用 Playwright provider 时,`expect.element` 的 matcher 是 **web-first assertion**:它们会在超时时间内持续重试,直到断言通过或超时失败。你不需要手写 `waitFor` 或轮询逻辑。 + +### 超时优先级 + +断言的超时按以下优先级决定: + +1. **Per-assertion timeout**:直接传给 matcher 的 `timeout` 参数,优先级最高 +2. **全局 `testTimeout` 配置**:来自 `rstest.config.ts` 中的 `testTimeout`(默认 5000ms) +3. **RPC 兜底超时**:30000ms(仅作为通信层保底,通常不会触及) + +```ts +// 使用全局 testTimeout(默认 5000ms) +await expect.element(page.getByText('Done')).toBeVisible(); + +// 覆盖为更长的超时 +await expect.element(page.getByText('Done')).toBeVisible({ timeout: 10000 }); +``` + +### 失败输出 + +当断言超时失败时,错误信息会包含:期望的元素状态、实际状态、以及超时时间。例如: + +``` +Error: Expected element to be visible + Locator: getByText('Loading complete') + Timeout: 5000ms +``` + +### 适用场景 + +自动重试特别适合处理异步渲染场景——例如等待 loading 消失、等待数据加载完成后的 UI 变化: + +```ts +// 点击后等待异步结果出现 +await page.getByRole('button', { name: 'Submit' }).click(); +await expect.element(page.getByText('Submitted!')).toBeVisible(); + +// 等待 loading 消失 +await expect.element(page.getByTestId('spinner')).toBeHidden(); +``` + +## 输入约束 + +- `locator` 必须是 `@rstest/browser` 返回的 `Locator`。这样运行时才能识别并回放完整查询链路;手写对象或其他库的 locator 无法被这套机制解析。 +- 在非 Browser Mode 中,`expect.element` 不可用。因为它依赖 Browser Mode 的浏览器运行时与通信通道来执行元素断言;纯 Node 测试环境只支持普通 `expect(value)` 断言。 + +## 元素断言 + +以下列出的 matcher 是当前支持的子集,未列出的 matcher 暂不可用。 + +### not + +- **类型:** `BrowserElementExpect` + +对后续断言取反。 + +```ts +await expect + .element(page.getByRole('button', { name: 'Submit' })) + .not.toBeDisabled(); +``` + +以下 matcher 都支持可选参数 `options?: { timeout?: number }`(除非类型签名里另有说明)。 + +### toBeVisible + +- **类型:** `(options?: { timeout?: number }) => Promise` + +`Locator` 在 `timeout` 内解析到已挂载且 visible 的元素。 + +visible 的判定:元素具有非空 bounding box,且 `visibility` 不是 `hidden`。例如 `display: none` 或尺寸为 `0` 不算 visible;`opacity: 0` 仍算 visible。 + +```ts +await expect.element(page.getByRole('button', { name: 'Save' })).toBeVisible(); +``` + +### toBeHidden + +- **类型:** `(options?: { timeout?: number }) => Promise` + +`Locator` 在 `timeout` 内满足以下任一条件:不匹配任何 DOM 节点,或匹配到 non-visible 节点。 + +可把它理解为 `toBeVisible` 的反面条件;例如仅设置 `opacity: 0` 通常不会让 `toBeHidden` 通过。 + +```ts +await expect.element(page.getByTestId('loading')).toBeHidden(); +``` + +### toBeEnabled + +- **类型:** `(options?: { timeout?: number }) => Promise` + +元素不处于 disabled 状态。 + +disabled 判定:原生表单控件带 `disabled`、处于 `disabled` 的 `fieldset` 中,或处于 `aria-disabled=true` 的语义禁用上下文,都可能被视为 disabled。 + +```ts +await expect + .element(page.getByRole('button', { name: 'Submit' })) + .toBeEnabled(); +``` + +### toBeDisabled + +- **类型:** `(options?: { timeout?: number }) => Promise` + +元素被判定为 disabled。 + +判定规则与 `toBeEnabled` 相同,只是结果相反;推荐在按钮提交、防重复点击等场景里使用。 + +```ts +await expect + .element(page.getByRole('button', { name: 'Submit' })) + .toBeDisabled(); +``` + +### toBeChecked + +- **类型:** `(options?: { timeout?: number }) => Promise` + +checkbox 或 radio 的 `checked` 状态为 `true`。 + +常用于验证用户勾选行为或默认选中状态是否生效。 + +```ts +await expect.element(page.getByLabel('Agree')).toBeChecked(); +``` + +### toBeUnchecked + +- **类型:** `(options?: { timeout?: number }) => Promise` + +checkbox 或 radio 的 `checked` 状态为 `false`。 + +适合与 `check` / `uncheck` 连用,验证交互前后状态变化。 + +```ts +await expect.element(page.getByLabel('Agree')).toBeUnchecked(); +``` + +### toBeAttached + +- **类型:** `(options?: { timeout?: number }) => Promise` + +`Locator` 指向的节点与 `Document` 或 `ShadowRoot` 保持连接(可理解为 `Node.isConnected === true`)。 + +这个断言只关注“是否在 DOM 树中”,不要求元素可见。 + +```ts +await expect.element(page.locator('#toast')).toBeAttached(); +``` + +### toBeDetached + +- **类型:** `(options?: { timeout?: number }) => Promise` + +`Locator` 不再指向已连接的 DOM 节点。 + +常见于条件渲染、异步卸载或删除操作后的校验。 + +```ts +await expect.element(page.locator('#toast')).toBeDetached(); +``` + +### toBeEditable + +- **类型:** `(options?: { timeout?: number }) => Promise` + +元素同时满足 enabled 且非 readonly。 + +readonly 的判断既包括原生 `readonly`,也包括支持该语义的 `aria-readonly=true` 场景。 + +```ts +await expect.element(page.getByLabel('Bio')).toBeEditable(); +``` + +### toBeFocused + +- **类型:** `(options?: { timeout?: number }) => Promise` + +该元素是当前文档的焦点目标(active element)。 + +适合验证键盘导航、自动聚焦或表单切换焦点行为。 + +```ts +await expect.element(page.getByLabel('Username')).toBeFocused(); +``` + +### toBeEmpty + +- **类型:** `(options?: { timeout?: number }) => Promise` + +可编辑元素内容为空,或普通 DOM 节点没有文本内容。 + +它关注“内容是否为空”,而不是“元素是否存在”或“是否可见”。 + +```ts +await expect.element(page.locator('#empty-state')).toBeEmpty(); +``` + +### toBeInViewport + +- **类型:** `(options?: { timeout?: number; ratio?: number }) => Promise` + +元素与 viewport 有交集(基于 Intersection Observer 语义)。 + +`ratio` 表示最小交集比例;例如 `ratio: 0.5` 表示至少一半区域进入视口。 + +```ts +await expect.element(page.locator('#hero')).toBeInViewport({ ratio: 0.5 }); +``` + +### toHaveText + +- **类型:** `(text: string | RegExp, options?: { timeout?: number }) => Promise` + +元素文本与期望值完整匹配(支持 `string` / `RegExp`)。 + +文本计算会包含嵌套子元素内容;当期望值是 `string` 时,会对空白与换行做归一化后再匹配。 + +```ts +await expect.element(page.getByRole('status')).toHaveText('Saved'); +``` + +### toContainText + +- **类型:** `(text: string | RegExp, options?: { timeout?: number }) => Promise` + +元素文本包含期望子串,或匹配给定正则。 + +和 `toHaveText` 的区别是:`toContainText` 做子串匹配,`toHaveText` 做完整匹配。 + +```ts +await expect.element(page.getByRole('status')).toContainText('Save'); +``` + +### toHaveValue + +- **类型:** `(value: string | RegExp, options?: { timeout?: number }) => Promise` + +表单控件的当前 `value` 与期望值匹配(支持 `string` / `RegExp`)。 + +适用于 `input`、`textarea`、`select` 等可取值元素。 + +```ts +await expect.element(page.getByLabel('Email')).toHaveValue('dev@rstest.rs'); +``` + +### toHaveId + +- **类型:** `(value: string | RegExp, options?: { timeout?: number }) => Promise` + +元素的 `id` 与期望值匹配(支持 `string` / `RegExp`)。 + +适合校验动态生成 ID 或组件挂载后注入的固定 ID。 + +```ts +await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveId('save-btn'); +``` + +### toHaveClass + +- **类型:** `(value: string | RegExp, options?: { timeout?: number }) => Promise` + +元素 `class` 属性与期望值匹配(支持 `string` / `RegExp`)。 + +当传入 `string` 时,按整体 `class` 字符串匹配;若只关心某个 class,建议使用更有针对性的正则。 + +```ts +await expect.element(page.getByRole('alert')).toHaveClass(/error/); +``` + +### toHaveAttribute + +- **类型:** + - `(name: string, options?: { timeout?: number }) => Promise` + - `(name: string, value: string | RegExp, options?: { timeout?: number }) => Promise` + +只传 `name` 时,断言属性存在;传 `name + value` 时,断言属性值匹配(`value` 支持 `string` / `RegExp`)。 + +常见用法是校验 `type`、`disabled`、`aria-*` 等结构性属性。 + +```ts +await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveAttribute('type'); +await expect + .element(page.getByRole('button', { name: 'Save' })) + .toHaveAttribute('type', 'submit'); +``` + +### toHaveCount + +- **类型:** `(count: number, options?: { timeout?: number }) => Promise` + +`Locator` 解析出的元素数量与 `count` 完全一致。 + +适合列表渲染、过滤结果和分页条目数等场景。 + +```ts +await expect.element(page.getByRole('listitem')).toHaveCount(3); +``` + +### toHaveCSS + +- **类型:** `(name: string, value: string | RegExp, options?: { timeout?: number }) => Promise` + +元素的 computed style 中,指定 CSS 属性值与期望值匹配。 + +`name` 必须是非空字符串;`value` 支持 `string` / `RegExp`。 + +```ts +await expect.element(page.getByRole('alert')).toHaveCSS('display', 'block'); +``` + +### toHaveJSProperty + +- **类型:** `(name: string, value: unknown, options?: { timeout?: number }) => Promise` + +元素上的 JS property 与期望值匹配。 + +`name` 必须是非空字符串;`value` 需要可被 JSON 序列化(断言参数会通过 Browser Mode 通道传输)。 + +```ts +await expect + .element(page.getByLabel('Agree')) + .toHaveJSProperty('checked', true); +``` + +## 相关 API + +- [Locator](/api/runtime-api/browser-mode/locator) +- [Page(Locator 入口)](/api/runtime-api/browser-mode/locator#page) +- [Expect](/api/runtime-api/test-api/expect) diff --git a/website/docs/zh/api/runtime-api/browser-mode/index.mdx b/website/docs/zh/api/runtime-api/browser-mode/index.mdx new file mode 100644 index 000000000..70c217d57 --- /dev/null +++ b/website/docs/zh/api/runtime-api/browser-mode/index.mdx @@ -0,0 +1,52 @@ +--- +title: Browser mode +--- + +# Browser mode + +Browser Mode API 由 [@rstest/browser](https://github.com/web-infra-dev/rstest/tree/main/packages/browser) 提供,目标是让你在浏览器测试中使用 Playwright 风格的 Locator 工作流。 + +## 导出 API + +`@rstest/browser` 提供的是 Browser Mode 的运行时入口能力,核心职责是**定位元素并驱动交互**。 + +其中,`page` 是查询起点,`Locator` 负责链式组合与动作执行,`BrowserPage` 用来描述 `page` 支持的查询 API,`setTestIdAttribute` 用于配置 test id 属性名。 + +以下是 `@rstest/browser` 当前导出的 API: + +- [`page`](/api/runtime-api/browser-mode/locator#page) - `BrowserPage` 查询入口(仅查询) +- [`Locator`](/api/runtime-api/browser-mode/locator) - 链式查询与交互的核心类 +- [`BrowserPage`](/api/runtime-api/browser-mode/locator#browserpage) - `page` 的类型定义 +- [`setTestIdAttribute`](/api/runtime-api/browser-mode/locator#settestidattribute) - 配置 `getByTestId()` 使用的属性名 + +## 断言 API + +断言入口是 `expect.element(locator)`。 + +当你在 Browser Mode 测试里导入 `@rstest/browser`(例如 `import { page } from '@rstest/browser'`)时,`expect` 会自动具备 `element` 能力。 + +它的职责是对 `Locator` 执行 web-first assertion(带自动等待),例如 `toBeVisible`、`toHaveText`。 + +两者关系可以理解为:`@rstest/browser` 负责**找元素和操作元素**,`expect.element` 负责**验证元素状态**。 + +- [`expect.element(locator)`](/api/runtime-api/browser-mode/assertion) - 对 `Locator` 执行自动等待断言 + +## 示例:查询、交互与断言 + +```ts title="browser-example.test.ts" +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('submits form', async () => { + await page.getByLabel('Email').fill('dev@rstest.rs'); + await page.getByRole('button', { name: 'Submit' }).click(); + await expect.element(page.getByText('Done')).toBeVisible(); +}); +``` + +这个例子里,`page` 先生成 `Locator`,`Locator` 执行 `fill/click`,最后 `expect.element(locator)` 对结果做可见性断言,形成完整测试闭环。 + +## 详细参考 + +- [Locator](/api/runtime-api/browser-mode/locator) +- [Assertion](/api/runtime-api/browser-mode/assertion) diff --git a/website/docs/zh/api/runtime-api/browser-mode/locator.mdx b/website/docs/zh/api/runtime-api/browser-mode/locator.mdx new file mode 100644 index 000000000..22534e512 --- /dev/null +++ b/website/docs/zh/api/runtime-api/browser-mode/locator.mdx @@ -0,0 +1,407 @@ +--- +title: Locator +overviewHeaders: [2, 3] +--- + +# Locator + +`Locator` 是 Browser Mode 的元素查询与交互核心 API。你可以通过 `page.getBy*` 或 `page.locator()` 构建查询链,再执行交互操作。具体执行语义由 browser `provider` 决定。 + +:::tip 自动等待 +使用 Playwright provider 时,Locator 交互方法(如 `click`、`fill`、`check`)会自动等待元素进入可操作状态(可见、启用、稳定)后再执行,你不需要在操作前手动等待元素。这与 `expect.element` 断言的[自动重试](/api/runtime-api/browser-mode/assertion#自动重试与超时)机制不同。 +::: + +下面的示例展示了典型工作流:查询元素、执行交互,并配合 [expect.element](/api/runtime-api/browser-mode/assertion) 进行断言。 + +```ts title="src/locator.test.ts" +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('interacts with a form using locator', async () => { + await page.getByLabel('Username').fill('alice'); + await page.getByLabel('Password').fill('secret123'); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect.element(page.getByLabel('Username')).toHaveValue('alice'); +}); +``` + +## page + +- **类型:** `BrowserPage` + +`page` 是测试里的起点:先用 `page` 定位元素,再在返回的 `Locator` 上执行交互和断言。 + +`page` 是仅用于查询的对象:只负责创建 `Locator`,不直接执行交互动作。 + +```ts +const submitButton = page.getByRole('button', { name: 'Submit' }); +``` + +上面的 `submitButton` 是一个 `Locator`,后续可以继续链式调用(例如 `.click()`)或传给 `expect.element(...)` 做断言。 + +## BrowserPage + +- **类型:** `Pick` + +`BrowserPage` 是 `page` 的类型定义,只暴露查询入口,不包含 `click`、`fill` 等交互方法。 + +也就是说,你需要先通过 `page` 拿到 `Locator`,再在 `Locator` 上调用交互和断言。 + +## 查询 API + +以下 API 都会返回新的 `Locator`,可继续链式调用。 + +### locator + +- **类型:** `(selector: string) => Locator` + +通过 CSS selector 查询元素。 + +```ts +const item = page.locator('.todo-item'); +``` + +### getByRole + +- **类型:** `(role: string, options?: LocatorGetByRoleOptions) => Locator` + +按语义角色查询元素,推荐优先使用。 + +`LocatorGetByRoleOptions` 支持:`name`、`exact`、`checked`、`disabled`、`expanded`、`selected`、`pressed`、`includeHidden`、`level`。 + +```ts +const saveBtn = page.getByRole('button', { name: 'Save' }); +``` + +### getByText + +- **类型:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +按可见文本查询。 + +```ts +const successMessage = page.getByText('Saved successfully'); +``` + +### getByLabel + +- **类型:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +按表单 label 查询。 + +```ts +const emailInput = page.getByLabel('Email'); +``` + +### getByPlaceholder + +- **类型:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +按 placeholder 查询。 + +```ts +const searchInput = page.getByPlaceholder('Search'); +``` + +### getByAltText + +- **类型:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +按 `alt` 文本查询。 + +```ts +const avatarImage = page.getByAltText('User avatar'); +``` + +### getByTitle + +- **类型:** `(text: string | RegExp, options?: { exact?: boolean }) => Locator` + +按 `title` 查询。 + +```ts +const helpIcon = page.getByTitle('Help'); +``` + +### getByTestId + +- **类型:** `(text: string | RegExp) => Locator` + +按 test id 查询。 + +```ts +const settingsPanel = page.getByTestId('settings-panel'); +``` + +## 配置 API + +### setTestIdAttribute + +- **类型:** `(attribute: string) => Promise` + +设置 `getByTestId()` 使用的属性名。默认值是 `data-testid`。 + +这个配置会通过 Browser Mode 通道转发到 host provider(例如 Playwright 的 `selectors.setTestIdAttribute()`)。 + +这是全局配置,建议在测试 setup 阶段统一设置一次,避免在用例执行中途修改导致查询行为不一致。 + +```ts +import { page, setTestIdAttribute } from '@rstest/browser'; + +await setTestIdAttribute('data-test'); +await page.getByTestId('settings-panel').click(); +``` + +:::tip +建议在 setup 文件中配置 `setTestIdAttribute`,确保所有测试统一生效: + +```ts title="rstest.setup.ts" +import { setTestIdAttribute } from '@rstest/browser'; + +await setTestIdAttribute('data-qa'); +``` + +然后在配置中引用: + +```ts title="rstest.config.ts" +export default defineConfig({ + setupFiles: ['./rstest.setup.ts'], +}); +``` + +::: + +## 组合 API + +### filter + +- **类型:** `(options: LocatorFilterOptions) => Locator` + +`LocatorFilterOptions` 支持: + +- `hasText?: string | RegExp`:保留文本匹配的元素 +- `hasNotText?: string | RegExp`:排除文本匹配的元素 +- `has?: Locator`:保留包含子 Locator 的元素 +- `hasNot?: Locator`:排除包含子 Locator 的元素 + +用于在已有查询结果上做二次过滤。 + +```ts +const profileSave = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Profile' }) }) + .getByRole('button', { name: 'Save' }); +``` + +```ts +const visibleItems = page.locator('li').filter({ + hasNotText: /archived/i, + hasNot: page.getByRole('img', { name: 'Locked' }), +}); +``` + +### and / or + +- **类型:** `(other: Locator) => Locator` + +合并两个 Locator 条件。 + +```ts +const byRole = page.getByRole('button', { name: 'Submit' }); +const byId = page.locator('#submit'); + +const exactOne = byRole.and(byId); +``` + +### nth / first / last + +- **类型:** + - `nth(index: number): Locator` + - `first(): Locator` + - `last(): Locator` + +在匹配集合中选择指定元素。 + +## 交互 API + +以下 API 会触发实际浏览器操作,并返回 `Promise`。 + +:::warning 严格模式 +Locator 是严格的。如果交互操作(如 `click`、`fill`)匹配到多个元素,会抛出错误。当查询匹配多个元素时,请使用 [`first()`](#nth--first--last)、[`last()`](#nth--first--last) 或 [`nth()`](#nth--first--last) 显式选择单个元素。 +::: + +这些 `*Options` 类型表示 Browser Mode 当前稳定支持的一组选项: + +- `LocatorClickOptions` / `LocatorDblclickOptions`: `button`、`delay`、`force`、`modifiers`、`position`、`timeout`、`trial`(`click` 额外支持 `clickCount`) +- `LocatorHoverOptions`: `force`、`modifiers`、`position`、`timeout`、`trial` +- `LocatorPressOptions`: `delay`、`timeout` +- `LocatorFillOptions`: `force`、`timeout` +- `LocatorCheckOptions`: `force`、`position`、`timeout`、`trial` +- `LocatorFocusOptions` / `LocatorBlurOptions` / `LocatorScrollIntoViewIfNeededOptions`: `timeout` +- `LocatorWaitForOptions`: `state`(`attached` / `detached` / `visible` / `hidden`)与 `timeout` +- `LocatorSelectOptionOptions`: `force`、`timeout` +- `LocatorSetInputFilesOptions`: `timeout` + +### click + +- **类型:** `(options?: LocatorClickOptions) => Promise` + +点击当前 Locator 匹配到的元素。 + +```ts +await page.getByRole('button', { name: 'Submit' }).click(); +``` + +### dblclick + +- **类型:** `(options?: LocatorDblclickOptions) => Promise` + +对元素执行双击操作。 + +```ts +await page.getByText('Open details').dblclick(); +``` + +### hover + +- **类型:** `(options?: LocatorHoverOptions) => Promise` + +将鼠标悬停到元素上,常用于触发 hover 菜单或 tooltip。 + +```ts +await page.getByRole('button', { name: 'More' }).hover(); +``` + +### press + +- **类型:** `(key: string, options?: LocatorPressOptions) => Promise` + +在元素上发送键盘按键。 + +```ts +await page.getByLabel('Search').press('Enter'); +``` + +### fill + +- **类型:** `(value: string, options?: LocatorFillOptions) => Promise` + +设置输入框值,适用于 `input`、`textarea` 等可输入元素。 + +```ts +await page.getByPlaceholder('Email').fill('dev@rstest.rs'); +``` + +### clear + +- **类型:** `() => Promise` + +清空当前输入元素的值。 + +```ts +await page.getByPlaceholder('Email').clear(); +``` + +### focus + +- **类型:** `(options?: LocatorFocusOptions) => Promise` + +让元素获得焦点。 + +```ts +await page.getByLabel('Username').focus(); +``` + +### blur + +- **类型:** `(options?: LocatorBlurOptions) => Promise` + +让元素失去焦点。 + +```ts +await page.getByLabel('Username').blur(); +``` + +### check + +- **类型:** `(options?: LocatorCheckOptions) => Promise` + +勾选 checkbox 或 radio。 + +```ts +await page.getByLabel('Agree').check(); +``` + +### uncheck + +- **类型:** `(options?: LocatorCheckOptions) => Promise` + +取消勾选 checkbox。 + +```ts +await page.getByLabel('Agree').uncheck(); +``` + +### scrollIntoViewIfNeeded + +- **类型:** `(options?: LocatorScrollIntoViewIfNeededOptions) => Promise` + +必要时滚动页面,使元素进入可视区域。 + +```ts +await page.getByRole('button', { name: 'Load more' }).scrollIntoViewIfNeeded(); +``` + +### waitFor + +- **类型:** `(options?: LocatorWaitForOptions) => Promise` + +等待 Locator 满足条件后继续执行,适合处理异步渲染。 + +```ts +await page.getByText('Ready').waitFor(); +``` + +### dispatchEvent + +- **类型:** `(type: string, eventInit?: LocatorDispatchEventInit) => Promise` + +在元素上派发自定义事件或原生事件。 + +```ts +await page.getByRole('button', { name: 'Event' }).dispatchEvent('custom'); +``` + +### selectOption + +- **类型:** `(value: string | string[], options?: LocatorSelectOptionOptions) => Promise` + +选择 `select` 元素的 option。当前只支持 `string` 或 `string[]` 作为 value。 + +```ts +await page.getByLabel('Choice').selectOption('b'); +``` + +### setInputFiles + +- **类型:** `(files: string | string[], options?: LocatorSetInputFilesOptions) => Promise` + +为 `input[type="file"]` 设置文件。当前只支持文件路径 `string` 或 `string[]`。 + +```ts +await page.locator('#upload').setInputFiles('/tmp/demo.txt'); +``` + +## 使用约束 + +- 本页列出的 API 是当前支持的 Playwright Locator API 子集,未列出的 API 暂不可用 +- `and` / `or` / `filter({ has | hasNot })` 的参数必须是 `@rstest/browser` 返回的 `Locator` +- `dispatchEvent(type, eventInit?)` 的 `type` 必须是非空字符串 +- `selectOption` 目前仅支持 `string` 或 `string[]` +- `setInputFiles` 目前仅支持文件路径 `string` 或 `string[]` +- 部分参数会通过 Browser Mode 通信通道传输,建议保持可被 JSON 序列化 + +## 与 expect.element 配合 + +`Locator` 一般与 `expect.element(locator)` 配合使用。完整断言列表见 [Assertion](/api/runtime-api/browser-mode/assertion)。 diff --git a/website/docs/zh/api/runtime-api/index.mdx b/website/docs/zh/api/runtime-api/index.mdx index f9a9c6213..2812111e3 100644 --- a/website/docs/zh/api/runtime-api/index.mdx +++ b/website/docs/zh/api/runtime-api/index.mdx @@ -4,3 +4,6 @@ title: API 总览 --- 当前页面列出了 Rstest 所有的测试 API。 + +- 通用测试 API 见 [Test API](/api/runtime-api/test-api/expect) +- Browser Mode 专用 API 见 [Browser Mode](/api/runtime-api/browser-mode/) diff --git a/website/docs/zh/api/runtime-api/test-api/expect.mdx b/website/docs/zh/api/runtime-api/test-api/expect.mdx index 588d8a810..edaf5008c 100644 --- a/website/docs/zh/api/runtime-api/test-api/expect.mdx +++ b/website/docs/zh/api/runtime-api/test-api/expect.mdx @@ -43,6 +43,26 @@ expect('hello').toBeDefined(); expect([1, 2, 3]).toContain(2); ``` +## expect.element(Browser Mode) + +- **类型:** `(locator: Locator) => BrowserElementExpect` + +在 Browser Mode 中,`expect.element` 用于对 `@rstest/browser` 提供的 Locator 执行断言(例如 `toBeVisible`、`toHaveText`、`toHaveValue`)。 + +```ts +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('asserts by locator', async () => { + document.body.innerHTML = ''; + await expect + .element(page.getByRole('button', { name: 'Save' })) + .toBeVisible(); +}); +``` + +`expect.element` 仅在 Browser Mode 中可用,并且需要引入 `@rstest/browser` 来安装浏览器侧适配层。完整参考见 [Assertion(Browser Mode)](/api/runtime-api/browser-mode/assertion)。 + ## expect.not 否定该断言。 diff --git a/website/docs/zh/config/test/browser.mdx b/website/docs/zh/config/test/browser.mdx index 1bdae4bd5..2ca290a5e 100644 --- a/website/docs/zh/config/test/browser.mdx +++ b/website/docs/zh/config/test/browser.mdx @@ -65,6 +65,8 @@ npx playwright install chromium 浏览器驱动提供者。目前仅支持 [Playwright](https://playwright.dev/)。 +同一次测试运行(single run)暂不支持混用多个 provider。 + ```ts title="rstest.config.ts" import { defineConfig } from '@rstest/core'; @@ -216,40 +218,52 @@ export default defineConfig({ }); ``` -## 多浏览器测试 +## 当前限制:同一次 run 的 browser 启动配置必须一致 + +在一个 `rstest` 进程内,所有启用 Browser Mode 的项目需要共享同一组 browser 启动配置: + +- `provider` +- `browser` +- `headless` +- `port` +- `strictPort` + +这意味着目前还不支持在同一次 run 里通过 `projects` 混用多个 provider,或同时配置 Chromium/Firefox/WebKit。 + +如果你需要跨浏览器覆盖,建议拆成多次执行(例如在 CI matrix 中分别跑): + +```bash +npx rstest --browser.browser chromium +npx rstest --browser.browser firefox +npx rstest --browser.browser webkit +``` + +## 多项目配置隔离 + +在 Browser Mode 下使用 `projects` 时,每个项目会按自己的构建配置独立编译和执行(如 `plugins`、`include`、框架设置),不会复用其他项目的构建配置。 -通过 [projects](/config/test/projects) 配置可以同时在多个浏览器中运行测试: +但 browser 启动配置仍需保持一致:`provider`、`browser`、`headless`、`port`、`strictPort` 必须在所有 browser 项目中对齐。 ```ts title="rstest.config.ts" import { defineConfig } from '@rstest/core'; export default defineConfig({ - projects: [ - { - name: 'chromium', - browser: { - enabled: true, - provider: 'playwright', - browser: 'chromium', - }, - }, - { - name: 'firefox', - browser: { - enabled: true, - provider: 'playwright', - browser: 'firefox', - }, - }, - { - name: 'webkit', - browser: { - enabled: true, - provider: 'playwright', - browser: 'webkit', - }, - }, - ], + projects: ['./project-b/rstest.config.ts', './project-a/rstest.config.ts'], +}); +``` + +```ts title="project-a/rstest.config.ts" +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-a', + plugins: [pluginReact()], + include: ['tests/**/*.test.tsx'], + browser: { + enabled: true, + provider: 'playwright', + }, }); ``` @@ -289,3 +303,4 @@ export default defineConfig({ - [浏览器模式指南](/guide/browser-testing/) - 浏览器模式介绍和使用指南 - [快速开始](/guide/browser-testing/getting-started) - 配置浏览器模式测试 +- [浏览器交互](/guide/browser-testing/user-interactions#locator-api) - 使用 `page` + `expect.element` 编写语义化测试 diff --git a/website/docs/zh/config/test/projects.mdx b/website/docs/zh/config/test/projects.mdx index 4ca693556..cef8a8653 100644 --- a/website/docs/zh/config/test/projects.mdx +++ b/website/docs/zh/config/test/projects.mdx @@ -47,3 +47,55 @@ export default defineConfig({ ``` 更多关于项目配置的信息,请参考[多项目测试](/guide/basic/projects)。 + +## Browser mode 中的 projects + +如果你在 Browser Mode 下使用 `projects`,每个项目都会按自己的构建配置独立编译与执行,这样可以在一个仓库里同时管理不同技术栈或不同构建配置。 + +但在同一次 run 中,Browser 启动配置必须保持一致: + +- `provider` +- `browser` +- `headless` +- `port` +- `strictPort` + +也就是说,目前不支持在同一次 run 里混用多个 provider,或同时配置多个 browser 类型。 + +```ts title="rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + projects: ['./project-b/rstest.config.ts', './project-a/rstest.config.ts'], +}); +``` + +```ts title="project-a/rstest.config.ts" +import { pluginReact } from '@rsbuild/plugin-react'; +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-a', + plugins: [pluginReact()], + include: ['tests/**/*.test.tsx'], + browser: { + enabled: true, + provider: 'playwright', + }, +}); +``` + +```ts title="project-b/rstest.config.ts" +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + name: 'project-b', + include: ['tests/**/*.test.ts'], + browser: { + enabled: true, + provider: 'playwright', + }, +}); +``` + +你也可以在同一个 `projects` 列表里混合 Browser Mode 与 `testEnvironment: 'node'`/`'jsdom'` 项目,最终会合并输出测试结果。 diff --git a/website/docs/zh/guide/_meta.json b/website/docs/zh/guide/_meta.json index 133ec79a5..fc855b7d7 100644 --- a/website/docs/zh/guide/_meta.json +++ b/website/docs/zh/guide/_meta.json @@ -12,7 +12,7 @@ { "type": "dir-section-header", "name": "browser-testing", - "label": "浏览器测试" + "label": "浏览器测试(实验性)" }, { "type": "dir-section-header", diff --git a/website/docs/zh/guide/browser-testing/framework-guides.mdx b/website/docs/zh/guide/browser-testing/framework-guides.mdx index 617d12455..8ba6adcc6 100644 --- a/website/docs/zh/guide/browser-testing/framework-guides.mdx +++ b/website/docs/zh/guide/browser-testing/framework-guides.mdx @@ -1,4 +1,4 @@ -# 框架指南 +# 框架集成 本指南提供各前端框架在浏览器模式下的测试配置示例。 @@ -33,6 +33,8 @@ import { PackageManagerTabs } from '@theme'; `render` 函数将组件渲染到 DOM 后,返回 `container`(组件的容器元素)。你可以用原生 DOM API 或 Testing Library 查询元素并进行断言: +如果你偏好 Playwright 风格写法,也可以结合 `@rstest/browser` 的 `page` 与 `expect.element`,详见 [浏览器交互](/guide/browser-testing/user-interactions#locator-api)。 + ```tsx title="src/Counter.test.tsx" import { render } from '@rstest/browser-react'; import { expect, test } from '@rstest/core'; diff --git a/website/docs/zh/guide/browser-testing/getting-started.mdx b/website/docs/zh/guide/browser-testing/getting-started.mdx index 61b0da76a..342390f30 100644 --- a/website/docs/zh/guide/browser-testing/getting-started.mdx +++ b/website/docs/zh/guide/browser-testing/getting-started.mdx @@ -104,27 +104,33 @@ export default defineConfig({ ### 3. 编写测试 -创建一个简单的浏览器测试文件: +创建一个浏览器测试文件。推荐使用 Locator API 进行元素定位和交互: -```ts title="src/dom.test.ts" +```ts title="tests/counter.test.ts" +import { page } from '@rstest/browser'; import { expect, test } from '@rstest/core'; -test('should work with DOM APIs', () => { - const div = document.createElement('div'); - div.textContent = 'Hello Browser!'; - document.body.appendChild(div); - - expect(document.body.textContent).toContain('Hello Browser!'); -}); - -test('should have real browser APIs', () => { - // These APIs may be incomplete or missing in jsdom - expect(typeof window.requestAnimationFrame).toBe('function'); - expect(typeof window.IntersectionObserver).toBe('function'); - expect(typeof window.ResizeObserver).toBe('function'); +test('counter increments on click', async () => { + document.body.innerHTML = ` + + `; + + let count = 0; + document.getElementById('count-btn')!.addEventListener('click', (e) => { + count++; + (e.target as HTMLButtonElement).textContent = `Count: ${count}`; + }); + + await expect + .element(page.getByRole('button', { name: 'Count: 0' })) + .toBeVisible(); + await page.getByRole('button', { name: 'Count: 0' }).click(); + await expect.element(page.getByText('Count: 1')).toBeVisible(); }); ``` +这个示例使用 `page.getByRole()` 按语义角色定位按钮,通过 `click()` 触发交互,再用 `expect.element().toBeVisible()` 断言结果。断言会自动等待元素状态变化,无需手写轮询。 + ### 4. 运行测试 ```bash @@ -170,5 +176,6 @@ export default defineConfig({ ## 下一步 -- [框架指南](/guide/browser-testing/framework-guides) - 各框架的完整配置和组件测试示例 +- [浏览器交互](/guide/browser-testing/user-interactions#locator-api) - 使用 `page` + `expect.element` 编写语义化定位与断言 +- [框架集成](/guide/browser-testing/framework-guides) - 各框架的完整配置和组件测试示例 - [用户交互](/guide/browser-testing/user-interactions) - 模拟用户操作 diff --git a/website/docs/zh/guide/browser-testing/index.mdx b/website/docs/zh/guide/browser-testing/index.mdx index 8393f7715..1cdcc19da 100644 --- a/website/docs/zh/guide/browser-testing/index.mdx +++ b/website/docs/zh/guide/browser-testing/index.mdx @@ -1,4 +1,4 @@ -# 浏览器模式(实验性) +# 浏览器模式 Rstest 提供了浏览器模式(Browser Mode),允许你在真实浏览器中运行测试,而不是使用 jsdom 或 happy-dom 等模拟环境。 @@ -23,6 +23,14 @@ Rstest 提供了浏览器模式(Browser Mode),允许你在真实浏览器 浏览器模式使用 [Playwright](https://playwright.dev/) 在真实浏览器(Chromium、Firefox 或 WebKit)中执行你的测试代码。这意味着你的测试将在与生产环境完全一致的浏览器 API 和行为下运行。 +## Locator API + +Browser Mode 现在支持 Playwright 风格的 Locator 工作流:你可以使用 `page.getBy*` 进行元素定位,再通过 `expect.element(locator)` 完成自动等待断言。 + +这种写法适合希望使用语义化定位(role/label/text)和链式断言的场景,让组件测试和 DOM 测试更接近真实用户交互语义。 + +详细用法见 [浏览器交互](/guide/browser-testing/user-interactions#locator-api)。 + ## 何时使用浏览器模式 使用以下决策树来判断是否需要浏览器模式: @@ -62,5 +70,6 @@ jsdom 中行为异常? ─── 是 ─▶ ✅ 浏览器模式 ## 下一步 - [快速开始](/guide/browser-testing/getting-started) - 配置并运行你的第一个浏览器测试 -- [框架指南](/guide/browser-testing/framework-guides) - 各框架的完整配置和组件测试示例 +- [浏览器交互](/guide/browser-testing/user-interactions#locator-api) - 使用 `page` + `expect.element` 编写语义化测试 +- [框架集成](/guide/browser-testing/framework-guides) - 各框架的完整配置和组件测试示例 - [用户交互](/guide/browser-testing/user-interactions) - 模拟用户点击、输入等操作 diff --git a/website/docs/zh/guide/browser-testing/user-interactions.mdx b/website/docs/zh/guide/browser-testing/user-interactions.mdx index d9f6e0214..c9e3d0497 100644 --- a/website/docs/zh/guide/browser-testing/user-interactions.mdx +++ b/website/docs/zh/guide/browser-testing/user-interactions.mdx @@ -1,17 +1,139 @@ # 浏览器交互 -本指南介绍如何在浏览器模式测试中模拟用户交互。 +本指南介绍在 Browser Mode 测试中如何模拟用户交互,并帮助你在稳定性、可维护性和控制粒度之间做选择。 -在浏览器模式下,你可以选择 Testing Library 或原生 DOM API。Testing Library 更贴近真实用户行为,能自动处理完整事件序列与焦点细节,适合大多数 UI 测试;原生 DOM API 更轻量、可精确控制事件属性,但需要手动拼装事件流程,适合验证底层事件逻辑或特殊交互细节。 +在 Browser Mode 下,推荐按以下优先级选择交互方案(仅在需要时再向后降级): + +- **Locator API(首选)**:使用 [page.getBy\*](/api/runtime-api/browser-mode/locator#page) + [expect.element](/api/runtime-api/browser-mode/assertion) 做语义化查询、交互与断言,适合绝大多数交互测试 +- **Testing Library**:适合迁移存量用例或复用既有 [Testing Library](https://testing-library.com/) 工具链;新测试一般不作为首选 +- **原生 DOM API(兜底)**:更轻量、可精确控制事件属性,但需要手动拼装事件序列,适合验证底层事件逻辑或特殊交互细节 import { PackageManagerTabs } from '@theme'; -## Testing Library(推荐) +## Locator API + +Locator API 是 Browser Mode 下的默认选择。它由 Rstest 官方提供:`@rstest/browser` 提供 [page](/api/runtime-api/browser-mode/locator#page) 查询与交互入口,`@rstest/core` 提供 [expect.element](/api/runtime-api/browser-mode/assertion) 断言能力。 + +它采用 Playwright 风格的 Locator 写法([page.getBy\*](/api/runtime-api/browser-mode/locator#page) + 链式调用 + [expect.element](/api/runtime-api/browser-mode/assertion)),让组件测试和 DOM 测试都能复用同一套交互和断言模式。 + +推荐优先使用 Locator API 的原因: + +- 查询更稳定:优先按 `role`、`label`、`text` 等用户可感知语义定位 +- 交互更直接:[click](/api/runtime-api/browser-mode/locator#click)、[fill](/api/runtime-api/browser-mode/locator#fill)、[check](/api/runtime-api/browser-mode/locator#check)、[press](/api/runtime-api/browser-mode/locator#press) 等动作直接挂在 Locator 上 +- 断言更自然:和 [expect.element](/api/runtime-api/browser-mode/assertion) 配合,等待与断言语义一致 +- 一致性更高:同一套 API 覆盖查询、操作、断言,减少在多套工具间切换 + +### 示例 + +下面示例聚焦最常见链路:填表、点击、断言。 + +```ts +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('interacts with form using locator api', async () => { + document.body.innerHTML = ` +
+ + + + + + + + + +
+ `; + + await page.getByLabel('Username').fill('alice'); + await page.getByLabel('Password').fill('secret123'); + await page.getByLabel('Remember me').check(); + await page.getByRole('button', { name: 'Login' }).click(); + + await expect.element(page.getByLabel('Username')).toHaveValue('alice'); + await expect.element(page.getByLabel('Remember me')).toBeChecked(); +}); +``` + +### 常用查询与组合 + +你可以像 Playwright 一样组合 Locator,把范围逐步收窄到目标元素: + +```ts +import { page } from '@rstest/browser'; +import { expect, test } from '@rstest/core'; + +test('composes locators', async () => { + document.body.innerHTML = ` +
+

Home

+ +
+
+

Profile

+ +
+ `; + + const saveInProfileSection = page + .locator('section') + .filter({ has: page.getByRole('heading', { name: 'Profile' }) }) + .getByRole('button', { name: 'Save' }); + + await expect.element(saveInProfileSection).toHaveCount(1); +}); +``` + +当前常用查询/组合能力包括: + +- 语义与属性查询:[getByRole](/api/runtime-api/browser-mode/locator#getbyrole)、[getByText](/api/runtime-api/browser-mode/locator#getbytext)、[getByLabel](/api/runtime-api/browser-mode/locator#getbylabel)、[getByPlaceholder](/api/runtime-api/browser-mode/locator#getbyplaceholder)、[getByAltText](/api/runtime-api/browser-mode/locator#getbyalttext)、[getByTitle](/api/runtime-api/browser-mode/locator#getbytitle)、[getByTestId](/api/runtime-api/browser-mode/locator#getbytestid) +- 基础选择与过滤:[locator](/api/runtime-api/browser-mode/locator#locator)、[filter](/api/runtime-api/browser-mode/locator#filter) +- 集合组合与定位:[and](/api/runtime-api/browser-mode/locator#and--or)、[or](/api/runtime-api/browser-mode/locator#and--or)、[nth](/api/runtime-api/browser-mode/locator#nth--first--last)、[first](/api/runtime-api/browser-mode/locator#nth--first--last)、[last](/api/runtime-api/browser-mode/locator#nth--first--last) + +实践中建议优先使用语义查询([getByRole](/api/runtime-api/browser-mode/locator#getbyrole)、[getByLabel](/api/runtime-api/browser-mode/locator#getbylabel)),仅在语义信息不足时再考虑 [getByTestId](/api/runtime-api/browser-mode/locator#getbytestid) 或 CSS 选择器。 + +### 常用交互与断言 + +Locator 支持常见交互 API(如 [click](/api/runtime-api/browser-mode/locator#click)、[fill](/api/runtime-api/browser-mode/locator#fill)、[check](/api/runtime-api/browser-mode/locator#check)、[hover](/api/runtime-api/browser-mode/locator#hover)、[press](/api/runtime-api/browser-mode/locator#press)、[selectOption](/api/runtime-api/browser-mode/locator#selectoption)),并可直接配合 [expect.element](/api/runtime-api/browser-mode/assertion) 断言: + +- 状态断言:[toBeVisible](/api/runtime-api/browser-mode/assertion#tobevisible)、[toBeHidden](/api/runtime-api/browser-mode/assertion#tobehidden)、[toBeEnabled](/api/runtime-api/browser-mode/assertion#tobeenabled)、[toBeDisabled](/api/runtime-api/browser-mode/assertion#tobedisabled) +- 表单/结构断言:[toBeChecked](/api/runtime-api/browser-mode/assertion#tobechecked)、[toBeFocused](/api/runtime-api/browser-mode/assertion#tobefocused)、[toBeEmpty](/api/runtime-api/browser-mode/assertion#tobeempty) +- 文本和值断言:[toHaveText](/api/runtime-api/browser-mode/assertion#tohavetext)、[toContainText](/api/runtime-api/browser-mode/assertion#tocontaintext)、[toHaveValue](/api/runtime-api/browser-mode/assertion#tohavevalue)、[toHaveCount](/api/runtime-api/browser-mode/assertion#tohavecount) +- 属性断言:[toHaveAttribute](/api/runtime-api/browser-mode/assertion#tohaveattribute)、[toHaveClass](/api/runtime-api/browser-mode/assertion#tohaveclass)、[toHaveCSS](/api/runtime-api/browser-mode/assertion#tohavecss)、[toHaveJSProperty](/api/runtime-api/browser-mode/assertion#tohavejsproperty) + +建议在关键交互后立即断言可观察结果(例如状态文案、按钮状态、字段值),这样失败信息更聚焦、调试成本更低。 + +:::info 自动等待 vs 自动重试 +Locator API 中有两种不同的等待机制: + +- **自动等待(交互)**:`click()`、`fill()`、`check()` 等方法会自动等待目标元素可见、启用、稳定后再执行操作。 +- **自动重试(断言)**:`expect.element` 的 matcher 会在超时时间内持续重试,直到断言通过,适合处理异步渲染场景。 + +大多数情况下,你只需要 `await` 每个调用——框架会在内部处理所有等待和重试。 +::: + +你也可以链式使用 [not](/api/runtime-api/browser-mode/assertion#not) 和可选 `timeout`: + +```ts +await expect + .element(page.getByRole('button', { name: 'Save' })) + .not.toBeDisabled({ timeout: 1000 }); +``` + +:::warning 严格模式 +Locator 的交互操作是严格的:如果一个 locator 匹配到多个元素,`click`、`fill` 等操作会抛出错误。请使用 `first()`、`last()` 或 `nth()` 选择特定元素。 +::: + +## Testing library -[Testing Library](https://testing-library.com/) 是一套专注于用户行为的测试工具库,它鼓励你以用户实际操作的方式编写测试,而非依赖内部实现细节。在浏览器模式下,我们推荐配合使用以下两个包: +[Testing Library](https://testing-library.com/) 是一套专注于用户行为的测试工具库,它鼓励你以用户实际操作方式编写测试,而非依赖内部实现细节。在 Browser Mode 中,它更适合作为兼容与迁移方案: -- [@testing-library/dom](https://testing-library.com/docs/dom-testing-library/intro):提供 `getByRole`、`getByText`、`getByLabelText` 等查询方法,让你能以用户视角查找元素(如"找到标签为 Username 的输入框"),而非依赖 CSS 选择器或测试专用属性 -- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro):模拟真实用户交互,会触发完整的事件序列(如 `click` 会依次触发 `mousedown`、`focus`、`mouseup`、`click`),并自动处理焦点、光标位置等细节 +- [@testing-library/dom](https://testing-library.com/docs/dom-testing-library/intro):负责查询,提供 `getByRole`、`getByText`、`getByLabelText` 等方法,让你按用户可感知语义查找元素 +- [@testing-library/user-event](https://testing-library.com/docs/user-event/intro):负责交互,提供更完整的事件模拟流程;在 Browser Mode 中,新测试仍建议优先 Locator API ### 安装 @@ -53,11 +175,11 @@ test('submits login form with user credentials', async () => { }); ``` -Testing Library 还提供了丰富的交互方法,包括点击、文本输入、键盘事件、下拉选择、拖拽等。详细用法请参考 [user-event 官方文档](https://testing-library.com/docs/user-event/intro)。 +如果你的项目已经大量使用 Testing Library,可以继续复用它的点击、文本输入、键盘事件、下拉选择、拖拽等能力。详细用法请参考 [user-event 官方文档](https://testing-library.com/docs/user-event/intro)。 ## 原生 DOM API -如果你不想引入额外依赖,或者需要更底层的事件控制(如精确指定 `clientX`、`ctrlKey` 等属性),可以直接使用浏览器原生 DOM API。 +如果你不想引入额外依赖,或者需要更底层的事件控制(如精确指定 `clientX`、`ctrlKey` 等属性),可以直接使用浏览器原生 DOM API。通常将它作为兜底方案,仅在你需要精确控制事件参数时使用。 ### 示例 diff --git a/website/docs/zh/guide/framework/react.mdx b/website/docs/zh/guide/framework/react.mdx index 9d9a35a47..8b12443ea 100644 --- a/website/docs/zh/guide/framework/react.mdx +++ b/website/docs/zh/guide/framework/react.mdx @@ -174,7 +174,7 @@ SSR 测试在 Node.js 环境中运行,不需要 DOM 模拟器,使用默认 对于需要真实浏览器行为的场景(例如 CSS 渲染、Web API、视觉测试),使用 Rstest 的浏览器模式配合 Playwright。 -详细的配置和使用说明请参阅[浏览器测试 - 框架指南](/guide/browser-testing/framework-guides#react)。 +详细的配置和使用说明请参阅[浏览器测试 - 框架集成](/guide/browser-testing/framework-guides#react)。 **建议:**