Playwright E2E Testing for Podman Desktop
This skill covers the Podman Desktop-specific Playwright framework. It is an
Electron desktop app — tests launch the app via Runner, not a browser URL.
Project Structure
playwright.config.ts # At repo root, not under tests/
tests/playwright/
├── package.json # @podman-desktop/tests-playwright
├── tsconfig.json # Path alias: /@/ → src/
├── vite.config.js # Library build config
├── src/
│ ├── specs/ # Test spec files
│ │ ├── *-smoke.spec.ts # Smoke test suites
│ │ ├── *.spec.ts # Other specs
│ │ └── z-*.spec.ts # Ordered-last suites (Podman machine)
│ ├── special-specs/ # Isolated/focused suites
│ │ ├── installation/
│ │ ├── managed-configuration/
│ │ ├── podman-remote/
│ │ └── ui-stress/
│ ├── model/ # Page Object Models
│ │ ├── pages/ # Page POMs (base-page, main-page, details-page, ...)
│ │ │ ├── base-page.ts # Abstract base: holds readonly page
│ │ │ ├── main-page.ts # Abstract: list pages (Images, Containers, Volumes, Pods)
│ │ │ ├── details-page.ts # Abstract: resource detail views
│ │ │ ├── *-page.ts # Concrete page POMs
│ │ │ ├── forms/ # Form-specific POMs
│ │ │ └── compose-onboarding/ # Compose onboarding flow POMs
│ │ ├── workbench/ # App shell POMs
│ │ │ ├── navigation.ts # NavigationBar — sidebar nav, returns page POMs
│ │ │ └── status-bar.ts # StatusBar
│ │ ├── components/ # Reusable widget POMs
│ │ └── core/ # Enums, types, states, settings helpers
│ ├── runner/ # Electron app launcher
│ │ ├── podman-desktop-runner.ts # Runner singleton
│ │ └── runner-options.ts # RunnerOptions config class
│ ├── utility/ # Shared helpers
│ │ ├── fixtures.ts # Custom Playwright test + fixtures
│ │ ├── operations.ts # UI workflow helpers
│ │ ├── wait.ts # waitUntil, waitWhile, waitForPodmanMachineStartup
│ │ ├── kubernetes.ts # K8s helpers
│ │ ├── cluster-operations.ts # Kind cluster helpers
│ │ ├── platform.ts # isLinux, isMac, isWindows, isCI
│ │ └── auth-utils.ts # Browser-based auth flows (Chromium)
│ ├── setupFiles/ # Feature gate helpers
│ └── globalSetup/ # Setup/teardown (exported, not in config)
├── resources/ # Containerfiles, YAML, fixtures
└── output/ # Traces, videos, reports (gitignored)
Imports and Fixtures
Spec files must import test and expect from the project fixtures:
import { expect as playExpect, test } from '/@/utility/fixtures';
Model/POM files must import from @playwright/test directly — never from
/@/utility/fixtures. The fixtures module imports NavigationBar, which imports
page models, which extend base classes like DetailsPage and MainPage. If any
of those base classes import from fixtures, it creates a circular dependency that
causes ReferenceError: Cannot access '<ClassName>' before initialization at
runtime. This is a hard crash with no tests running.
// In model/pages/*.ts and model/workbench/*.ts:
import type { Locator, Page } from '@playwright/test';
import test, { expect as playExpect } from '@playwright/test';
All source imports use the /@/ path alias, which Vite resolves to src/.
Available Test Fixtures
The custom test provides these fixtures:
| Fixture | Type | Description |
|---|---|---|
runner |
Runner |
Electron app lifecycle (singleton via Runner.getInstance) |
page |
Page |
The Electron renderer window (runner.getPage()) |
navigationBar |
NavigationBar |
Sidebar navigation POM |
welcomePage |
WelcomePage |
Welcome/onboarding page POM |
statusBar |
StatusBar |
Bottom status bar POM |
runnerOptions |
RunnerOptions |
Configurable option (override with test.use) |
Destructure these directly in test hooks and test functions:
test.beforeAll(async ({ runner, welcomePage, page }) => { ... });
test('my test', async ({ navigationBar }) => { ... });
Page Object Model Hierarchy
Three-Level Inheritance
BasePage (abstract)
├── MainPage (abstract) — list pages with tables (Images, Containers, Volumes, Pods)
│ ├── ImagesPage
│ ├── ContainersPage
│ ├── VolumesPage
│ └── PodsPage
├── DetailsPage (abstract) — resource detail views with tabs
│ ├── ImageDetailsPage
│ ├── ContainerDetailsPage
│ └── ...
└── Other concrete pages (WelcomePage, DashboardPage, ...)
Workbench classes (not BasePage subclasses):
├── NavigationBar
└── StatusBar
BasePage
All page POMs extend BasePage:
import type { Page } from '@playwright/test';
export abstract class BasePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
}
MainPage
For list pages with header, search, content regions, and table rows:
export abstract class MainPage extends BasePage {
readonly title: string;
readonly mainPage: Locator;
readonly header: Locator;
readonly search: Locator;
readonly content: Locator;
readonly additionalActions: Locator;
readonly heading: Locator;
constructor(page: Page, title: string) {
super(page);
this.title = title;
this.mainPage = page.getByRole('region', { name: this.title });
this.header = this.mainPage.getByRole('region', { name: 'header' });
this.search = this.mainPage.getByRole('region', { name: 'search' });
this.content = this.mainPage.getByRole('region', { name: 'content' });
this.additionalActions = this.header.getByRole('group', { name: 'additionalActions' });
this.heading = this.header.getByRole('heading', { name: this.title });
}
}
Concrete pages call super(page, 'images'), super(page, 'containers'), etc.
DetailsPage
For resource detail views with tabs, breadcrumb, and control actions:
export abstract class DetailsPage extends BasePage {
readonly header: Locator;
readonly tabs: Locator;
readonly tabContent: Locator;
readonly closeButton: Locator;
readonly backLink: Locator;
readonly heading: Locator;
constructor(page: Page, resourceName: string) {
super(page);
this.tabContent = page.getByRole('region', { name: 'Tab Content' });
this.header = page.getByRole('region', { name: 'Header' });
this.tabs = page.getByRole('region', { name: 'Tabs' });
this.heading = this.header.getByRole('heading', { name: resourceName });
// ... breadcrumb, close, back locators
}
}
POM Rules
- Extend the correct base class:
MainPagefor list pages,DetailsPagefor detail views,BasePagefor other pages - Declare all locators as
readonlyin the constructor — eagerLocatorchains, not lazy getters - Wrap every method body in
test.step()for trace readability:
async pullImage(image: string): Promise<ImagesPage> {
return test.step(`Pull image: ${image}`, async () => {
const pullImagePage = await this.openPullImage();
await playExpect(pullImagePage.heading).toBeVisible();
return await pullImagePage.pullImage(image);
});
}
- Navigation methods return POM instances — e.g.
openPullImage()returnsPullImagePage - Use
playExpect(aliased from@playwright/test) inside POM files for assertions - Import
testandplayExpectfrom@playwright/testin POM files (fortest.stepand assertions). Never import from/@/utility/fixturesin model files — this creates a circular dependency (fixtures -> NavigationBar -> page models -> fixtures) that crashes at runtime withReferenceError: Cannot access '<ClassName>' before initialization. Only spec files import from fixtures.
NavigationBar
Returns page POMs from sidebar navigation. Each method wraps in test.step():
async openImages(): Promise<ImagesPage> {
return test.step('Open Images page', async () => {
await playExpect(this.imagesLink).toBeVisible({ timeout: 10_000 });
await this.imagesLink.click({ force: true });
return new ImagesPage(this.page);
});
}
Writing Spec Files
Template
import { RunnerOptions } from '/@/runner/runner-options';
import { expect as playExpect, test } from '/@/utility/fixtures';
import { waitForPodmanMachineStartup } from '/@/utility/wait';
// Optional: override runner options for isolated profile
test.use({ runnerOptions: new RunnerOptions({ customFolder: 'my-feature' }) });
test.beforeAll(async ({ runner, welcomePage, page }) => {
runner.setVideoAndTraceName('my-feature-e2e');
await welcomePage.handleWelcomePage(true);
await waitForPodmanMachineStartup(page);
});
test.afterAll(async ({ runner }) => {
await runner.close();
});
test.describe('Feature name', { tag: '@smoke' }, () => {
test.describe.configure({ mode: 'serial', retries: 1 });
test('first test', async ({ navigationBar }) => {
const imagesPage = await navigationBar.openImages();
await playExpect(imagesPage.heading).toBeVisible();
// ... test body
});
});
Key Patterns
- Serial suites: Use
test.describe.configure({ mode: 'serial' })inside the describe block — most Podman Desktop E2E tests share Electron state - Tags:
{ tag: '@smoke' },{ tag: '@k8s_e2e' },{ tag: ['@smoke', '@windows_sanity'] } - Retries:
test.describe.configure({ retries: 1 })inside the describe block - Timeouts:
test.setTimeout(180_000)per test or inbeforeAll - Conditional skip:
test.skip(isLinux, 'Not supported on Linux') - Runner options:
test.use({ runnerOptions: new RunnerOptions({ ... }) })for custom profiles - Cleanup in afterAll: Always wrap in
try/finallywithrunner.close()infinally
Naming Conventions
- Spec files:
kebab-case-smoke.spec.ts(use-smokesuffix for smoke tests) - Prefix with
z-for suites that must run last (e.g.z-podman-machine-tests.spec.ts) - POM files:
feature-page.tsinmodel/pages/,feature-component.tsinmodel/components/
Runner and RunnerOptions
Runner Lifecycle
Runner is a singleton that launches the Electron app:
Runner.getInstance({ runnerOptions })— creates/reuses the singleton, callselectron.launch()runner.getPage()— returns thePagefromfirstWindow()runner.setVideoAndTraceName('name')— sets artifact naming (call inbeforeAll)runner.close()— stops tracing, closes app, saves artifacts
RunnerOptions
Configure with test.use():
test.use({
runnerOptions: new RunnerOptions({
customFolder: 'my-test-profile', // isolated profile directory
extensionsDisabled: ['podman'], // disable specific extensions
autoUpdate: false, // disable auto-update checks
saveTracesOnPass: true, // keep traces even on pass
customSettings: { key: 'value' }, // inject settings.json values
}),
});
Environment Variables
| Variable | Purpose |
|---|---|
PODMAN_DESKTOP_BINARY |
Path to packaged binary (mutually exclusive with PODMAN_DESKTOP_ARGS) |
PODMAN_DESKTOP_ARGS |
Path to repo for dev mode |
KEEP_TRACES_ON_PASS |
Retain traces on passing tests |
KEEP_VIDEOS_ON_PASS |
Retain videos on passing tests |
Wait Utilities
Use the project's wait helpers from /@/utility/wait, not custom polling:
import { waitUntil, waitWhile, waitForPodmanMachineStartup } from '/@/utility/wait';
await waitUntil(() => someCondition(), { timeout: 10_000, diff: 500, message: 'Condition not met' });
await waitWhile(() => dialogIsOpen(), { timeout: 5_000 });
await waitForPodmanMachineStartup(page);
Parameters: timeout (ms, default 5000), diff (polling interval ms, default 500), sendError (throw on timeout, default true), message (error text).
Locator Strategy
getByRole— primary choice, useexact: truewhen neededgetByLabel— form inputs and ARIA-labeled elementsgetByText— visible textgetByTestId— when no semantic option exists- CSS locators — last resort
Scope locators to parent regions when possible:
this.pullImageButton = this.additionalActions.getByRole('button', { name: 'Pull', exact: true });
Running Tests
Tests execute from the repo root (not from tests/playwright/):
# All E2E tests (excluding k8s)
npx playwright test tests/playwright/src/specs/ --grep-invert @k8s_e2e
# Smoke tests only
npx playwright test tests/playwright/src/specs/ --grep @smoke
# Single spec file
npx playwright test tests/playwright/src/specs/image-smoke.spec.ts
# View report
pnpm exec playwright show-report tests/playwright/output/html-results
Troubleshooting
Podman machine stuck in STARTING
The waitForPodmanMachineStartup utility handles this by resetting via CLI. If tests time out waiting for RUNNING state, check that Podman is installed and the machine provider is available.
No Container Engine
Some POM methods (e.g. openPullImage) use waitWhile(() => this.noContainerEngine()) to gate on engine availability. If tests fail with "No Container Engine", the Podman machine likely didn't start.
Platform-specific skips
Use helpers from /@/utility/platform:
import { isLinux, isMac, isWindows, isCI } from '/@/utility/platform';
test.skip(isLinux, 'Not supported on Linux');
Additional Resources
- For the project's wait utilities, operation helpers, and framework API, see references/reference.md
- For concrete examples from actual spec files, see references/examples.md