---
title: Authentication
description: Log in for local YAML E2E tests — the shared-account Playwright pattern and per-test auth for tests that need their own identity.
---

# Authentication

<div class="view-markdown-wrapper">
<ViewMarkdown />
</div>

If your app requires login, run **`/shiplight auth`** and describe your login flow — your agent sets up (or repairs) the login and saved storage state using one of the patterns below. The patterns are documented here so you can review and adjust what it writes.

There are two approaches depending on whether all tests share the same account or individual tests need their own identity.

## Shared Account (Most Common)

All tests share one authenticated session. This is the standard [Playwright authentication pattern](https://playwright.dev/docs/auth) — a setup project logs in once, saves the browser state, and all tests reuse it.

```ts
// auth.setup.ts
import { test as setup } from "@playwright/test";

setup("login", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.USERNAME!);
  await page.getByLabel("Password").fill(process.env.PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("/dashboard");
  await page.context().storageState({ path: ".auth/default.json" });
});
```

```ts
// playwright.config.ts
export default defineConfig({
  ...shiplightConfig(),
  projects: [
    { name: "auth", testMatch: "auth.setup.ts" },
    {
      name: "default",
      dependencies: ["auth"],
      use: {
        baseURL: "https://staging.example.com",
        storageState: ".auth/default.json",
      },
    },
  ],
});
```

Tests don't need any `account` field — they're already authenticated via `storageState`. This is pure Playwright, no Shiplight-specific logic.

**Multiple pre-defined accounts:** If you have a small number of accounts (e.g., admin, viewer) but tests don't pick which one to use, parameterize `auth.setup.ts` with an environment variable and select the account at runtime:

```bash
TEST_ACCOUNT=admin npx shiplight test
```

This is still a shared account — just selected at runtime instead of hardcoded.

## Per-Test Auth (Advanced)

When individual tests need their own identity (e.g., one test runs as admin, another as viewer in the same run), each test declares its `auth` script and optional `args` inline. This allows login customization per test.

### 1. Auth login function

Create an `auth.login.ts` that exports a `login(args)` function. It receives the args from the test config, performs the login flow, manages storageState caching and expiration, and returns the path to a storageState JSON file.

```ts
// auth.login.ts
import { chromium } from "@playwright/test";
import * as fs from "fs";
import * as path from "path";

export async function login(args: Record<string, unknown>): Promise<string> {
  const stateFile = path.join(".auth", `${args.username}.json`);

  // Return cached state if it exists (you control expiration logic here)
  if (fs.existsSync(stateFile)) return stateFile;

  // Perform login
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto("/login");
  await page.getByLabel("Email").fill(args.username as string);
  await page.getByLabel("Password").fill(args.password as string);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("/dashboard");

  // Cache storageState (cookies + localStorage + IndexedDB)
  fs.mkdirSync(path.dirname(stateFile), { recursive: true });
  await context.storageState({ path: stateFile, indexedDB: true });
  await browser.close();

  return stateFile;
}
```

### 2. Use in tests

Each test declares `auth` (path to the login script) and optional `args` passed to the login function:

```yaml
use:
  auth: ./auth.login.ts
  args:
    username: admin@example.com
    password: "{{ADMIN_PASSWORD}}"
goal: Admin can manage users
statements:
  - URL: /admin/users
  - VERIFY: User management page is visible
```

The `args` object is passed directly to the login function. This means your auth script can receive any fields your login flow needs (TOTP secrets, API tokens, org IDs, etc.). Tests that don't need args can omit the `args` field entirely.

The fixture uses the storage state path returned by the `login()` function to create the browser context, so the test runs in an already-logged-in state.

Tests without `auth` use the default context — if a setup project is configured, they get its `storageState`; otherwise they run unauthenticated.
