# Coming from Playwright

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](https://squiggle.sh/docs/web) for setup.

## The same test, both ways

```ts
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();
});
```

```yaml
flow: sign in
steps:
  - 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`:

```shell
squiggle up --url 5173
squiggle flow sign-in --udid web:http://localhost:5173
```

When 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](https://squiggle.sh/docs/reference/flow).

> **Scroll names where the content goes**
>
> A Playwright wheel scroll names where the page goes; `scroll: down` moves the content down. To reach something further down a list, use `scrollUntilVisible`, which picks the direction for you. See [Gestures and scrolling](https://squiggle.sh/docs/flows/gestures).

## What works differently

- **No timeouts to tune.** A tap waits for what it causes, including a slow navigation, before the next step runs. Use `waitFor` only for something that happens on its own, such as a request finishing.
- **Selectors name React components and controls.** `testID` matches `data-testid`, and `text`, `label` and `role` match 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](https://squiggle.sh/docs/flows/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`, `repeat` and `runFlow` for branches, loops and shared parts, and `runScript` to run a JavaScript file in the page. See [Repeatable flows](https://squiggle.sh/docs/flows/repeatable).

## 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](https://squiggle.sh/docs/web).
- 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_session` turns 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](https://squiggle.sh/docs/flows/healing)), and asserts can check what the app did, not only what it shows: renders, dropped frames, and store and query state (see [Assertions](https://squiggle.sh/docs/flows/assertions)). To get a first draft, record yourself using the app in Studio (see [Record and crawl](https://squiggle.sh/docs/flows/record)).
