> ## Documentation Index
> Fetch the complete documentation index at: https://docs.qawolf.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Anatomy of a flow

> What each part of a QA Wolf flow does, and how the same structure carries across web, iOS, and Android.

If the agent generated a flow for you and you want to understand what it produced, here's what each part does — and how those same parts extend once you're testing the same behavior on more than one platform.

If your team has written Playwright or Appium tests before, QA Wolf flows will look familiar. The selectors, interactions, and assertions work the same way. What's different is the wrapper around them.

Every flow is wrapped in `flow()` from the `@qawolf/flows` package — it tells the runner what to run, where to run it, and which runtime objects to inject into the callback.

## AAA framework

QA Wolf flows follow the Arrange-Act-Assert (AAA) format.

* **Arrange** — sets up state before the interaction.
* **Act** — performs the interaction being tested.
* **Assert** — verifies the expected outcome.

```typescript theme={null}
export default flow(
  "Complete checkout",
  { target: "Web - Chrome", launch: true },
  async ({ page, test }) => {
    await test("arrange", async () => {
      //--------------------------------
      // Arrange:
      //--------------------------------
      await page.goto(process.env.BASE_URL);
    });

    await test("act", async () => {
      //--------------------------------
      // Act:
      //--------------------------------
      await page.fill("[data-testid='email']", "test@example.com");
      await page.click("[data-testid='submit']");
    });

    await test("assert", async () => {
      //--------------------------------
      // Assert:
      //--------------------------------
      await expect(page).toHaveURL(`${process.env.BASE_URL}/confirmation`);
    });
  },
);
```

<Note>
  iOS and Android use `driver.$(...)` calls instead of `page` calls.
</Note>

## Import statement

Import from the `@qawolf/flows` subpath that matches the platform you are testing.

<CodeGroup>
  ```typescript Web theme={null}
  import { flow, expect } from "@qawolf/flows/web";
  ```

  ```typescript iOS theme={null}
  import { flow, expect } from "@qawolf/flows/ios";
  ```

  ```typescript Android theme={null}
  import { flow, expect } from "@qawolf/flows/android";
  ```

  ```typescript Node theme={null}
  import { flow } from "@qawolf/flows/cli";
  ```
</CodeGroup>

<Warning>
  For web flows, always import `expect` from `@qawolf/flows/web` — not from `@playwright/test`, which causes assertions to bypass QA Wolf's reporting. iOS, Android, and Node flows don't have this pitfall: `expect` is wired to the QA Wolf runner automatically.
</Warning>

## Flow wrapper

```typescript theme={null}
export default flow(
  "Name shown in QA Wolf",
  { target: "Web - Chrome", launch: true },
  async ({ page }) => {
    // test steps go here
  },
);
```

* **Name** — what this flow is called in your results, bug reports, and QA Wolf dashboard. Make it descriptive enough that a failing flow name tells you where to look.
* **Configuration** — where the flow runs and how it starts. See [Launch styles](#launch-styles) below, and [Target literals](/libraries/flows/api-reference/top-level#target-literals) for the values `target` accepts.
* **Callback** — the `async` function containing your test logic. See [Callback parameters](#callback-parameters).

### Launch styles

| Style                                                 | When to use                                                                   |
| ----------------------------------------------------- | ----------------------------------------------------------------------------- |
| **Declarative** — `launch: true` or an options object | Startup is known before the flow runs. Most flows.                            |
| **Explicit** — `launch()` inside the callback         | Startup depends on runtime logic — e.g., branching on an environment variable |

**Declarative launch** <Badge color="green">Recommended</Badge>

Pass `launch: true` to use the default startup, or an options object to customize it. Either way, QA Wolf starts the browser or app before your callback runs, and the callback receives the platform object ready to use.

<Note>
  If you've used Playwright directly, this is the equivalent of calling `await browser.launch()` — QA Wolf handles that setup for you, so your callback starts with a ready-to-use `page`.
</Note>

<CodeGroup>
  ```typescript Web theme={null}
  export default flow(
    "Sign in",
    { target: "Web - Chrome", launch: true },
    async ({ page, test }) => {
      await test("navigate to sign in", async () => {
        await page.goto(process.env.BASE_URL);
      });
    },
  );
  ```

  ```typescript iOS theme={null}
  export default flow(
    "Sign in",
    { target: "iOS - iPhone 15 (iOS 26)", launch: true },
    async ({ driver, test }) => {
      await test("launch app", async () => {
        // driver is ready to use
      });
    },
  );
  ```

  ```typescript Android theme={null}
  export default flow(
    "Sign in",
    { target: "Android - Pixel 9", launch: true },
    async ({ driver, test }) => {
      await test("launch app", async () => {
        // driver is ready to use
      });
    },
  );
  ```
</CodeGroup>

Pass an options object instead of `true` when you need to customize startup, such as reusing a browser profile. See the [Web API Reference](/libraries/flows/api-reference/web) for the full list of options.

```typescript theme={null}
export default flow(
  "Sign in with saved profile",
  {
    target: "Web - Chrome",
    launch: {
      browserContext: "persistent",
      userDataDir: "/tmp/qawolf-profile",
    },
  },
  async ({ page, test }) => {
    await test("navigate to sign in", async () => {
      await page.goto(process.env.BASE_URL);
    });
  },
);
```

**Explicit launch**

Omit `launch` from the configuration and call `launch()` inside the callback when startup options aren't known until the flow runs. The callback receives no platform object: `launch()` returns a result you narrow with `isPersistent()`, `isAnonymous()` or `isElectron()` before reading `page` or `context`. See the [Web API Reference](/libraries/flows/api-reference/web) for details. For Electron desktop app testing, see [Testing Electron apps](/electron).

## Callback parameters

Every flow callback receives three parameters regardless of platform:

### `inputs`

Values passed into the flow from outside. Use this to read data published by another flow in the same run. Keys are uppercase by convention, e.g. `inputs["EMAIL"]`.

### `setOutput(...)`

Publishes one or more key-value pairs that a later flow in the same run can read via its `inputs`. Keys are uppercase by convention. You can publish multiple values in a single call:

```typescript theme={null}
setOutput("USER_ID", userId, "EMAIL", email);
```

<Info>
  A consumer flow will not run until its producer has called `setOutput`. See [Passing data between flows](/Pass-data-between-flows) for how producers and consumers work together.
</Info>

### `test(...)`

Wraps a named sub-step, grouping actions and assertions under a label that appears in your results. When a step fails, the label tells you exactly where in the flow the failure happened. All four parameters in one callback:

```typescript theme={null}
async ({ page, inputs, setOutput, test }) => {
  await test("fill registration form", async () => {
    await page.goto(process.env.BASE_URL);
    await page.getByLabel("Email").fill(inputs["EMAIL"]);
    await page.getByRole("button", { name: "Sign up" }).click();
  });

  await test("confirm account created", async () => {
    const userId = await page.getByTestId("user-id").textContent();
    setOutput("USER_ID", userId);
  });
}
```

### Platform object

A launch-enabled flow also receives the platform object, which differs by platform:

| Platform       | Callback receives                                           |
| -------------- | ----------------------------------------------------------- |
| Web browser    | `page`, `context`, optional `browser`                       |
| Web (Electron) | `page`                                                      |
| iOS            | `driver` — the Appium/WebdriverIO session for the device    |
| Android        | `driver` — the Appium/WebdriverIO session for the device    |
| Node           | nothing additional — only `inputs`, `setOutput`, and `test` |

For the full callback context shape, see the API Reference for [Web](/libraries/flows/api-reference/web#flow-callback-context), [Android](/libraries/flows/api-reference/android#flow-callback-context), and [iOS](/libraries/flows/api-reference/ios#flow-callback-context).

## Environment variables

An environment variable is set on the environment and holds the same value for every run, such as a base URL or a test account's password. [`inputs`](#inputs) holds values another flow published during the run.

Read one with `process.env.VAR_NAME`, usually in the Arrange section before any interactions begin.

```typescript theme={null}
await page.goto(process.env.BASE_URL);
```

Set them under **Workspace settings → Environments**, on the environment's **Environment variables** tab.

## Platform differences

Prefer a separate flow per platform, each importing from its own [`@qawolf/flows` subpath](#import-statement). When one flow has to cover several platforms, put the difference in a helper function so the flow stays linear. Log capture is a typical case:

```typescript theme={null}
// /src/helpers/console-logs-ios.ts
export async function getConsoleLogs(driver: WebdriverIO.Browser) {
  const raw = await driver.getLogs("safariConsole");
  return raw.map((entry) => JSON.parse(entry.message));
}

// /src/helpers/console-logs-android.ts
export async function getConsoleLogs(driver: WebdriverIO.Browser) {
  const raw = await driver.getLogs("browser");
  return raw.map((entry) => entry.message);
}
```

Helpers like these sit at module level, which holds only imports, constants and pure functions. To branch on the platform at runtime instead, read [`platform.target`](/libraries/flows/api-reference/top-level#platform-target) — sparingly.

<Warning>
  `launch()`, `device` and `platform.target` are stubs the runner replaces at execution time, so they work only inside the flow callback. At module level, `platform.target` throws.
</Warning>
