| name | ignition-e2e |
| description | Ignition Perspective e2e testing reference โ Playwright page objects, gateway API helpers, and Perspective DOM conventions. Use when writing or debugging Playwright browser tests for Perspective views. |
| user-invocable | false |
Ignition Perspective E2E Testing Reference
This project uses Playwright to test Ignition Perspective views in a real browser. Tests run against a live gateway, authenticate through the Perspective login form, and interact with the actual Perspective SPA.
Architecture
- e2e/ directory at project root contains all Playwright tests
- PerspectivePage โ base page object that wraps Playwright's
Page for Perspective conventions
- Component wrappers โ typed wrappers for Perspective components (Button, Table, etc.)
- gateway-api.ts โ Node.js helper that calls WebDev endpoints for tag read/write, script invocation
- Auth fixture โ authenticates once, persists session to
.auth/user.json for reuse
Inheritable Projects
E2E tests target Perspective views rendered in a browser. Inheritable (parent) projects โ those where other projects list them as "parent" in project.json โ typically have no com.inductiveautomation.perspective/ directory and no views. If you're in a parent project, E2E tests can be proxied through a child project that has Perspective views. The /ignition-scada:test ui command detects this automatically, finds child projects with E2E setup, and runs Playwright from the child's e2e/ directory.
Perspective DOM Conventions (CRITICAL)
Perspective is a React SPA served over WebSocket. The DOM has specific conventions you MUST understand:
data-component-path
Every Perspective component has data-component-path using positional indices, NOT the named paths from view.json.
Prefix convention:
L[n] = left dock (e.g., L[0], L[1])
T[n] = top dock (e.g., T[0])
C = center (page content)
$ separates embedded view boundaries
: separates child indices
Examples:
C โ the root page content container
C:0:1 โ second child of first child of page content
C:0$0:2 โ embedded view boundary, then third child
T[0]:0:1 โ top dock, first container, second child
L[0] โ left dock
data-component
The component type attribute: data-component="ia.display.label", data-component="ia.input.button", etc.
Key rules
- Never use
page.goto() after initial session open โ it creates a new WebSocket session. Use PerspectivePage.openPage(route) instead. Exception: the very first navigation to the session root (e.g., for dock-only tests) may use page.goto().
- Page content is scoped by
C prefix โ use perspective.pageContent() to exclude docks.
- Docks render independently โ top dock (
T[0]) may be visible before page content (C).
- Embedded views reset the path counter โ
$ marks the boundary, child indices restart from 0.
- Left dock is often
onDemand โ check toBeAttached() not toBeVisible().
PerspectivePage (Base Page Object)
import { PerspectivePage } from "../pages/PerspectivePage";
perspective.openPage("/route")
perspective.waitForPageContent(timeout?)
perspective.waitForSession(timeout?)
perspective.pageContent()
perspective.componentByType("ia.display.label")
perspective.pageLabelWithText("Title")
perspective.pageText("some text")
perspective.dismissPopups()
perspective.dumpComponentPaths()
Component Wrappers
PerspectiveComponent (base)
const comp = new PerspectiveComponent(locator, page);
await comp.isVisible(timeout?)
await comp.waitForVisible(timeout?)
await comp.getText()
Button (ia.input.button)
const btn = new Button(locator, page);
await btn.click()
await btn.isEnabled()
Table (ia.display.table)
const table = new Table(locator, page);
await table.waitForData(timeout?)
await table.getRowCount()
await table.clickRow(index)
await table.getCellText(row, column)
Row selector: .ia_table__body__row
Cell selector: .ia_table__body__cell
Gateway API Helper
Call WebDev endpoints from test code:
import { readTags, writeTags, readTag, writeTag, callScript, isGatewayReachable, mirrorTags, deleteMirror } from "../helpers/gateway-api";
const values = await readTags(["[WHK01]Path/To/Tag1", "[WHK01]Path/To/Tag2"]);
const val = await readTag("[WHK01]Path/To/Tag");
await writeTag("[default]Test/Tag", 42);
await writeTags([{ path: "[default]Test/Tag1", value: "hello" }, { path: "[default]Test/Tag2", value: 123 }]);
const result = await callScript("core.mes.changeover.client.get_state", ["cooker"]);
await mirrorTags("[WHK01]Distillery01/Mashing01", "[WHK01]Distillery01/Mashing01_MEM");
await deleteMirror("[WHK01]Distillery01/Mashing01_MEM");
const reachable = await isGatewayReachable();
Auth Setup
The fixtures/auth.setup.ts handles authentication:
- Navigates to
/data/perspective/client/<PROJECT>
- Polls for login form (
input.username-field) or live session ([data-component])
- If login form: fills credentials from
IGNITION_USER/IGNITION_PASSWORD env vars
- Persists session to
.auth/user.json for reuse across tests
If auth fails: Run cd e2e && npx playwright test --project=setup to re-authenticate.
Custom Fixture
Use the perspective fixture instead of raw page:
import { test, expect } from "../fixtures/perspective";
test("my test", async ({ perspective }) => {
await perspective.openPage("/my-page");
const title = perspective.pageLabelWithText("My Title");
await expect(title.first()).toBeVisible();
});
This auto-wraps page in a PerspectivePage instance.
Writing Tests
Basic smoke test
import { test, expect } from "../../fixtures/perspective";
test.describe("My View smoke tests", () => {
test("page loads with expected title", async ({ perspective }) => {
await perspective.openPage("/my-view");
const title = perspective.pageLabelWithText("Expected Title");
await expect(title.first()).toBeVisible({ timeout: 15_000 });
});
test("table renders with data", async ({ perspective }) => {
await perspective.openPage("/my-view");
const table = perspective.componentByType("ia.display.table");
await expect(table.first()).toBeVisible();
await expect(table.locator(".ia_table__body__row").first()).toBeVisible({ timeout: 10_000 });
});
});
Integration test with tag setup
import { test, expect } from "../../fixtures/perspective";
import { writeTag, readTag, callScript } from "../../helpers/gateway-api";
test.describe("Changeover integration", () => {
test.beforeAll(async () => {
await writeTag("[default]Test/State", "idle");
});
test.afterAll(async () => {
await writeTag("[default]Test/State", "");
});
test("state change reflects in UI", async ({ perspective }) => {
await perspective.openPage("/changeover");
await callScript("core.mes.changeover.client.transition", ["cooker", "start"]);
const label = perspective.pageText("running");
await expect(label).toBeVisible({ timeout: 10_000 });
});
});
Testing docks
test("top dock shows plant data", async ({ perspective }) => {
await perspective.page.goto(`/data/perspective/client/${process.env.PERSPECTIVE_PROJECT}`);
await perspective.waitForSession();
const topDock = perspective.page.locator("[data-component-path^='T[0]']");
await expect(topDock.first()).toBeVisible();
});
test("left dock menu exists", async ({ perspective }) => {
await perspective.openPage("/some-page");
const menu = perspective.page.locator("[data-component='ia.navigation.menutree']");
await expect(menu).toBeAttached({ timeout: 10_000 });
});
Running Tests
cd e2e
npx playwright test
npx playwright test tests/changeover/
npx playwright test tests/smoke/
npx playwright test --headed
npx playwright test tests/smoke/perspective-loads.spec.ts
npx playwright show-report
Environment Variables
Set in e2e/.env:
| Variable | Purpose | Example |
|---|
IGNITION_URL | Gateway base URL | https://localhost:9043 |
IGNITION_USER | Login username | admin |
IGNITION_PASSWORD | Login password | password |
PERSPECTIVE_PROJECT | Perspective project name | QSI_WhiskeyHouseKentucky01 |
TAG_PROVIDER | Default tag provider | WHK01 |
Common Pitfalls
- Perspective takes 30-45s to reload after a project scan. The auth fixture has a 45s timeout for this reason. If tests fail immediately after a scan, wait and retry.
- Don't call
page.goto() mid-test. This kills the WebSocket session. Navigate within Perspective using component interactions or openPage() for the initial load.
- Embedded views load asynchronously. Wait for specific content inside them, don't rely on the parent container being visible.
- Tables render headers before data. Use
table.waitForData() or wait for .ia_table__body__row specifically.
- Popups from startup scripts can block interaction. Call
perspective.dismissPopups() after openPage() if needed.