Coming from Playwright
For AI agents: this page is Markdown at https://squiggle.sh/docs/flows/playwright.md. Every docs page is listed at https://squiggle.sh/llms.txt, and all of them are one file at https://squiggle.sh/llms-full.txt.
A Playwright test scripts a browser; a Squiggle flow lists the same journey as YAML steps, and runs on your React web app and your iOS app alike. Most Playwright calls have a step that does the same.
This page is for a React app that renders with react-dom. Squiggle drives it in Chrome, Chromium or Edge, served by your dev server. See React web apps for setup.
The same test, both ways
test('sign in', async ({ page }) => { await page.goto('http://localhost:5173/'); await page.getByTestId('email').fill('dana@example.com'); await page.getByTestId('password').fill('correct-horse'); await page.getByText('Sign in').click(); await expect(page).toHaveURL(/home/); await expect(page.getByText('Welcome back')).toBeVisible();});flow: sign insteps: - launchApp - type: { into: { testID: email }, value: dana@example.com } - type: { into: { testID: password }, value: correct-horse } - tap: { text: Sign in } - waitFor: { route: home } - assert: { visible: { text: Welcome back } }A flow has no await and no URL in it. Each step waits for the page to settle before the next one starts, and launchApp opens the address you gave squiggle up:
squiggle up --url 5173squiggle flow sign-in --udid web:http://localhost:5173When your iOS app uses the same testIDs, the same file runs on it too.
Calls and steps
| Playwright | Squiggle |
|---|---|
page.goto(url) | launchApp, or openLink: <url> for another page |
page.getByTestId('save').click() | tap: { testID: save } |
page.getByText('Save').click() | tap: { text: Save } |
page.getByRole('switch').nth(1).click() | tap: { role: switch, nth: 2 } (nth counts from 1) |
locator.dblclick() | doubleTap: <selector> |
locator.fill('dana@example.com') | type: { into: <selector>, value: dana@example.com } |
page.keyboard.press('Enter') | pressKey: Enter |
locator.scrollIntoViewIfNeeded() | scrollUntilVisible: { text: Item 20 } |
page.goBack() | back |
expect(locator).toBeVisible() | assert: { visible: { text: Saved } } |
expect(locator).toBeHidden() | assert: { notVisible: { text: Saving } } |
expect(locator).toBeChecked() | assert: { visible: { testID: remember, checked: true } } |
expect(page).toHaveURL(/checkout/) | waitFor: { route: checkout } |
page.waitForLoadState('networkidle') | waitFor: { networkIdle: true } |
page.route('**/orders', route => route.fulfill(…)) | network.stub: { urlMatch: /orders, status: 200, body: … } |
context.setOffline(true) | setAirplaneMode: enabled |
context.setGeolocation(…) | setLocation: { latitude: 45.81, longitude: 15.98 } |
context.grantPermissions(['geolocation']) | setPermissions: { permissions: { location: allow } } |
page.setViewportSize(…) · devices['iPhone 13'] | emulate: { viewport: { width: 390, height: 844 } } · emulate: { viewport: phone } |
page.emulateMedia({ colorScheme: 'dark' }) | emulate: { colorScheme: dark } |
newContext({ locale }) | emulate: { locale: de-DE } |
newContext({ userAgent }) | emulate: { userAgent: … } |
context.addCookies([…]) | launchApp: { seed: { cookies: [{ name: session, value: 7f3c2a91 }] } } |
newContext({ storageState }) | launchApp: { seed: { cookies, localStorage, sessionStorage } } |
fileChooser.setFiles('receipt.pdf') | addMedia: [receipt.pdf], then tap the file input |
page.evaluate(() => document.title) | evalScript: document.title |
page.screenshot({ path: 'home.png' }) | takeScreenshot: home |
Cookies in a seed take the same fields as Playwright’s addCookies. A seed always reopens the app, so its first page load reads them. Every option of each step is in the flow reference.
What works differently
- No timeouts to tune. A tap waits for what it causes, including a slow navigation, before the next step runs. Use
waitForonly for something that happens on its own, such as a request finishing. - Selectors name React components and controls.
testIDmatchesdata-testid, andtext,labelandrolematch what the page shows and its accessibility names. There are no CSS or XPath selectors. - A passed step means the action happened. To check what it caused, add an
assert. See Assertions. - Each flow drives one app. A popup or a second tab of the same app is driven like the first. Pages on other sites are not.
- Logic goes in steps, not code. Use
when,repeatandrunFlowfor branches, loops and shared parts, andrunScriptto run a JavaScript file in the page. See Repeatable flows.
What Squiggle doesn’t do
- Firefox and WebKit. Squiggle drives Chromium browsers only.
- A production build without
--production: its component names are minified, so Squiggle refuses it. See React web apps. - Apps that don’t render with React. Squiggle reads your app’s React tree, not the page’s HTML.
- Pinning the page’s clock.
deterministic_sessionturns off animations and pins the locale, but not the time.
What you get on top
The same flow runs on your iOS app. A moved control heals instead of failing (see Healing), and asserts can check what the app did, not only what it shows: renders, dropped frames, and store and query state (see Assertions). To get a first draft, record yourself using the app in Studio (see Record and crawl).