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 filessrc/model/— Page Object Models (webview-aware, take bothpageandwebview)src/utility/— test helpers (webview handling, extension lifecycle, cleanup)
# 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:
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:
- Clicks the "Bootable Containers" link in Podman Desktop's main navigation
- Waits for the webview document to appear
- Gets the second Electron window via
runner.getWindows() - Focuses the webview element
- 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:
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:
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:
global-setup.tscounts spec files and writes the total to a temp file- Each spec's
afterAllcallsmarkTestFileComplete(fileId)which decrements - When the counter reaches zero, the last spec file triggers removal
extension-reporter.tshandles fully-skipped files (their afterAll never runs)
Every spec file MUST include this in its outer test.afterAll:
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:
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:
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
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()inafterAllto handle orphaned webview recordings customFolder: 'bootc-tests-pd'— all specs use this folder name for consistent profile isolation
Platform Skip Patterns
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:
// 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
# 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
# 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:
# 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