# Playwright Testing

> Guides writing and maintaining Playwright E2E tests for the bootc Podman Desktop extension. Covers the webview-aware test framework, extension lifecycle management, Page Object Models, and spec file patterns. Triggers when creating or modifying E2E spec files, building page object models, debugging Playwright failures, or asking about the test framework.

- Skill: `podman-desktop/playwright-testing-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add podman-desktop/playwright-testing-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/podman-desktop/playwright-testing-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools, DevOps & Infra
- Author: podman-desktop (https://skillmd.com/u/podman-desktop)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/podman-desktop/playwright-testing-2

---


# Playwright E2E Testing for Bootc Extension

This extension runs inside Podman Desktop as a webview. E2E tests launch Podman
Desktop via Electron, install the extension from an OCI image, then interact
with the extension's webview UI. This is fundamentally different from testing
Podman Desktop itself — every test must handle the webview boundary.

## Project Structure

Tests live in `tests/playwright/`. Key directories:

- `src/*.spec.ts` — test spec files
- `src/model/` — Page Object Models (webview-aware, take both `page` and `webview`)
- `src/utility/` — test helpers (webview handling, extension lifecycle, cleanup)

```bash
# See the full structure
find tests/playwright/src -type f -name '*.ts' | sort
```

## Critical Concept: Webview Handling

Extensions render in a webview (a separate Electron BrowserWindow). You must
get BOTH the main page and the webview page to interact with extension UI:

```typescript
import { handleWebview } from './utility/bootc-test-utils';

// Returns [mainPage, webViewPage] — use webViewPage for extension UI locators
const [page, webview] = await handleWebview(runner);
```

**How `handleWebview()` works:**

1. Clicks the "Bootable Containers" link in Podman Desktop's main navigation
2. Waits for the webview document to appear
3. Gets the second Electron window via `runner.getWindows()`
4. Focuses the webview element
5. Returns `[mainPage, webViewPage]`

**All extension Page Object Models take both `page` and `webview` parameters.**
Use `webview` for extension-specific locators, `page` for Podman Desktop dialogs.

## Imports

Import `test`, `expect`, and utilities from `@podman-desktop/tests-playwright`:

```typescript
import {
  test,
  expect as playExpect,
  RunnerOptions,
  removeFolderIfExists,
  waitForPodmanMachineStartup,
  NavigationBar,
  waitUntil,
} from '@podman-desktop/tests-playwright';
import type { Page } from '@playwright/test';
```

Import extension-specific models and utilities locally:

```typescript
import { BootcNavigationBar } from './model/bootc-navigationbar';
import {
  handleWebview,
  installBootcExtensionIfNeeded,
  removeBootcExtensionIfNeeded,
  cleanupRawVideoFiles,
} from './utility/bootc-test-utils';
import { markTestFileComplete } from './utility/extension-lifecycle';
```

## Extension Lifecycle Management

The extension is installed once before the first spec file and removed after the
last one completes. This is managed by a file-based counter:

1. `global-setup.ts` counts spec files and writes the total to a temp file
2. Each spec's `afterAll` calls `markTestFileComplete(fileId)` which decrements
3. When the counter reaches zero, the last spec file triggers removal
4. `extension-reporter.ts` handles fully-skipped files (their afterAll never runs)

**Every spec file MUST include this in its outer `test.afterAll`:**

```typescript
import { fileURLToPath } from 'node:url';
import { markTestFileComplete } from './utility/extension-lifecycle';

const __filename = fileURLToPath(import.meta.url);

test.afterAll(async ({ navigationBar }) => {
  if (markTestFileComplete(__filename)) {
    await removeBootcExtensionIfNeeded(navigationBar);
  }
});
```

## Page Object Model Pattern

### Two-Parameter POMs

Unlike upstream Podman Desktop POMs (which take only `page`), bootc POMs take
both `page` and `webview`:

```typescript
export class BootcDashboardPage {
  readonly page: Page;
  readonly webview: Page;
  readonly heading: Locator;

  constructor(page: Page, webview: Page) {
    this.page = page;
    this.webview = webview;
    // Extension UI locators use webview
    this.heading = webview.getByText('Welcome to bootable containers');
  }
}
```

### Using POMs

POMs are in `tests/playwright/src/model/`. Read them for current methods and locators:

```bash
ls tests/playwright/src/model/
```

The navigation bar POM navigates within the extension's webview. Other POMs
wrap specific pages (build, dashboard, disk images, examples, etc.).

### Dialog Handling

Build completion shows a dialog on the **main page** (not webview). Always
use `this.page` (not `this.webview`) for Podman Desktop dialogs.

## Writing a New Spec File

### Template

```typescript
import type { Page } from '@playwright/test';
import {
  removeFolderIfExists,
  waitForPodmanMachineStartup,
  test,
  expect as playExpect,
  RunnerOptions,
  isLinux,
} from '@podman-desktop/tests-playwright';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { BootcNavigationBar } from './model/bootc-navigationbar';
import {
  removeBootcExtensionIfNeeded,
  handleWebview,
  installBootcExtensionIfNeeded,
  cleanupRawVideoFiles,
} from './utility/bootc-test-utils';
import { markTestFileComplete } from './utility/extension-lifecycle';

let page: Page;
let webview: Page;
const __filename = fileURLToPath(import.meta.url);

test.use({
  runnerOptions: new RunnerOptions({
    customFolder: 'bootc-tests-pd',
    customOutputFolder: 'tests/output',
    autoUpdate: false,
    autoCheckUpdates: false,
  }),
});

test.beforeAll(async ({ runner, welcomePage, page }) => {
  await removeFolderIfExists('tests/output/images');
  runner.setVideoAndTraceName('my-feature-name');
  await welcomePage.handleWelcomePage(true);
  await waitForPodmanMachineStartup(page);
});

test.afterAll(async ({ runner }) => {
  test.setTimeout(320_000);
  const videoAndTraceName = runner.getVideoAndTraceName();
  try {
    await removeFolderIfExists('tests/output/images');
  } finally {
    await runner.close(200_000);
    cleanupRawVideoFiles('tests/output', videoAndTraceName);
  }
});

test.describe('My Feature', () => {
  test.skip(isLinux); // building bootable images not supported on Linux

  test.beforeAll(async ({ navigationBar }) => {
    test.setTimeout(200_000);
    await installBootcExtensionIfNeeded(navigationBar);
  });

  test.describe.serial('Feature flow', () => {
    test('Navigate to extension', async ({ runner }) => {
      test.setTimeout(60_000);
      [page, webview] = await handleWebview(runner);
      const bootcNav = new BootcNavigationBar(page, webview);
      const dashboard = await bootcNav.openBootcDashboard();
      await playExpect(dashboard.heading).toBeVisible();
    });

    test('Perform action', async ({ runner }) => {
      test.setTimeout(300_000);
      [page, webview] = await handleWebview(runner);
      // ... test body
    });
  });

  test.afterAll(async ({ navigationBar }) => {
    if (markTestFileComplete(__filename)) {
      await removeBootcExtensionIfNeeded(navigationBar);
    }
  });
});
```

### Key Patterns

- **Always call `handleWebview(runner)`** at the start of each test that interacts with the extension
- **Use `test.describe.serial()`** — tests share Electron state and must run in order
- **Use `test.skip(isLinux)`** — building bootable images is not supported on Linux
- **Long timeouts are normal** — image pulls take up to 12 minutes, builds up to 25 minutes
- **Clean up video files** — call `cleanupRawVideoFiles()` in `afterAll` to handle orphaned webview recordings
- **`customFolder: 'bootc-tests-pd'`** — all specs use this folder name for consistent profile isolation

### Platform Skip Patterns

```typescript
import { isLinux, isMac, isWindows } from '@podman-desktop/tests-playwright';

test.skip(isLinux); // no bootable image building on Linux
test.skip(isMac && arch === ArchitectureType.AMD64, 'amd64 not supported on macOS');
test.skip(isWindows && arch === ArchitectureType.ARM64, 'arm64 not supported on Windows');
```

## Extension Installation

The extension is installed from an OCI image. The URL is configurable:

```typescript
// Default: ghcr.io/podman-desktop/extension-bootc:next
// Override: set OCI_IMAGE env var
const extensionURL = process.env.OCI_IMAGE ?? 'ghcr.io/podman-desktop/extension-bootc:next';
```

Set `SKIP_INSTALLATION=true` to skip install/remove (when testing with a pre-installed extension).

## Running Tests

```bash
# From the repo root
pnpm test:e2e

# From tests/playwright directly
pnpm --dir tests/playwright test:e2e

# Run a single spec
pnpm --dir tests/playwright exec playwright test src/bootc-dashboard.spec.ts

# With custom extension image
OCI_IMAGE=ghcr.io/podman-desktop/extension-bootc:v1.15.0 \
  pnpm --dir tests/playwright exec playwright test src/

# Skip extension install/remove (pre-installed)
SKIP_INSTALLATION=true \
  pnpm --dir tests/playwright exec playwright test src/
```

### Environment Variables

| Variable                | Purpose                                      |
| ----------------------- | -------------------------------------------- |
| `PODMAN_DESKTOP_BINARY` | Path to Podman Desktop binary                |
| `PODMAN_DESKTOP_ARGS`   | Path to Podman Desktop repo (dev mode)       |
| `OCI_IMAGE`             | Extension OCI image URL (default: next tag)  |
| `SKIP_INSTALLATION`     | Skip extension install/remove lifecycle      |
| `BUILD_ISO_IMAGE`       | Enable ISO build tests (disabled by default) |

## Existing Spec Files

```bash
# List all spec files and their test descriptions
ls tests/playwright/src/*.spec.ts
grep -h "test.describe\|test('" tests/playwright/src/*.spec.ts
```

## Troubleshooting

### Webview Not Appearing

If `handleWebview()` times out waiting for the second window, the extension may
not have loaded. Check that the extension is installed and in ACTIVE state.

### Build Stuck in "creating" State

`BootcPage.refreshPageWhileInCreatingState()` navigates away and back to force
a UI refresh. The `waitUntil` wrapper retries until the state changes.

### Video File Cleanup

Webview windows generate orphaned raw video files (32-char hex names).
`cleanupRawVideoFiles()` handles this — always call it in `afterAll`.

## Discovering POM APIs

Read the actual Page Object Models to see current locators and methods:

```bash
# List all POMs
ls tests/playwright/src/model/

# Read a specific POM for its locators and methods
cat tests/playwright/src/model/bootc-page.ts

# List all test utilities
cat tests/playwright/src/utility/bootc-test-utils.ts
```

