From ignition-scada
Provides Playwright page objects, gateway API helpers, and Perspective DOM conventions for e2e testing Ignition Perspective views. Use when writing or debugging browser tests.
npx claudepluginhub thethoughtagen/ignition-ide-plugins --plugin ignition-scadaThis skill uses the workspace's default tool permissions.
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.
Guides creating page objects and refactoring Playwright tests using Page Object Model patterns for maintainability, reusability, and scalability. Covers locators, principles, and TypeScript examples.
Write Playwright E2E tests using fixtures and best practices. Use when creating E2E tests, writing browser automation tests, or testing user flows.
Generates E2E tests using Playwright CLI with Page Object Model and visual regression via @visual tags for web apps. Use with target URLs for exploration and test creation.
Share bugs, ideas, or general feedback.
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.
Page for Perspective conventions.auth/user.json for reusePerspective is a React SPA served over WebSocket. The DOM has specific conventions you MUST understand:
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 indicesExamples:
C — the root page content containerC:0:1 — second child of first child of page contentC:0$0:2 — embedded view boundary, then third childT[0]:0:1 — top dock, first container, second childL[0] — left dockThe component type attribute: data-component="ia.display.label", data-component="ia.input.button", etc.
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().C prefix — use perspective.pageContent() to exclude docks.T[0]) may be visible before page content (C).$ marks the boundary, child indices restart from 0.onDemand — check toBeAttached() not toBeVisible().import { PerspectivePage } from "../pages/PerspectivePage";
// Available methods:
perspective.openPage("/route") // Open a page route (call once per test)
perspective.waitForPageContent(timeout?) // Wait for C-prefixed content to render
perspective.waitForSession(timeout?) // Wait for any [data-component] elements
perspective.pageContent() // Locator scoped to page content (excludes docks)
perspective.componentByType("ia.display.label") // Find by component type in page content
perspective.pageLabelWithText("Title") // Find label with text in page content
perspective.pageText("some text") // Find visible text in page content
perspective.dismissPopups() // Close popup overlays
perspective.dumpComponentPaths() // Debug: list all component paths in DOM
const comp = new PerspectiveComponent(locator, page);
await comp.isVisible(timeout?) // Returns boolean
await comp.waitForVisible(timeout?) // Throws if not visible
await comp.getText() // Text content
ia.input.button)const btn = new Button(locator, page);
await btn.click() // Wait for visible, then click
await btn.isEnabled() // Checks for "ia_button--disabled" class
ia.display.table)const table = new Table(locator, page);
await table.waitForData(timeout?) // Wait for first row to render
await table.getRowCount() // Number of visible rows
await table.clickRow(index) // Click row by index
await table.getCellText(row, column) // Get cell text content
Row selector: .ia_table__body__row
Cell selector: .ia_table__body__cell
Call WebDev endpoints from test code:
import { readTags, writeTags, readTag, writeTag, callScript, isGatewayReachable, mirrorTags, deleteMirror } from "../helpers/gateway-api";
// Tag operations
const values = await readTags(["[WHK01]Path/To/Tag1", "[WHK01]Path/To/Tag2"]);
// Returns: { "[WHK01]Path/To/Tag1": { value: 42, quality: "Good", good: true } }
const val = await readTag("[WHK01]Path/To/Tag"); // Single tag, returns value or null
await writeTag("[default]Test/Tag", 42); // Single write, returns boolean
await writeTags([{ path: "[default]Test/Tag1", value: "hello" }, { path: "[default]Test/Tag2", value: 123 }]);
// Script invocation — call real Jython scripts on the gateway
const result = await callScript("core.mes.changeover.client.get_state", ["cooker"]);
// Returns: { success: true, result: { current_state: "idle", ... } }
// Tag mirroring — clone OPC tags to memory for testing without PLC
await mirrorTags("[WHK01]Distillery01/Mashing01", "[WHK01]Distillery01/Mashing01_MEM");
// ... run tests against memory tags ...
await deleteMirror("[WHK01]Distillery01/Mashing01_MEM"); // Cleanup
// Health check
const reachable = await isGatewayReachable(); // Quick connectivity check
The fixtures/auth.setup.ts handles authentication:
/data/perspective/client/<PROJECT>input.username-field) or live session ([data-component])IGNITION_USER/IGNITION_PASSWORD env vars.auth/user.json for reuse across testsIf auth fails: Run cd e2e && npx playwright test --project=setup to re-authenticate.
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.
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();
// Check for data rows
await expect(table.locator(".ia_table__body__row").first()).toBeVisible({ timeout: 10_000 });
});
});
import { test, expect } from "../../fixtures/perspective";
import { writeTag, readTag, callScript } from "../../helpers/gateway-api";
test.describe("Changeover integration", () => {
test.beforeAll(async () => {
// Set up test state via gateway API
await writeTag("[default]Test/State", "idle");
});
test.afterAll(async () => {
// Cleanup
await writeTag("[default]Test/State", "");
});
test("state change reflects in UI", async ({ perspective }) => {
await perspective.openPage("/changeover");
// Trigger state change via script
await callScript("core.mes.changeover.client.transition", ["cooker", "start"]);
// Verify UI updates
const label = perspective.pageText("running");
await expect(label).toBeVisible({ timeout: 10_000 });
});
});
test("top dock shows plant data", async ({ perspective }) => {
// Don't use openPage for dock-only tests — just navigate to session root
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");
// Left dock is onDemand — check attached, not visible
const menu = perspective.page.locator("[data-component='ia.navigation.menutree']");
await expect(menu).toBeAttached({ timeout: 10_000 });
});
cd e2e
# All tests
npx playwright test
# Specific area
npx playwright test tests/changeover/
# Smoke tests only
npx playwright test tests/smoke/
# Headed mode (see the browser)
npx playwright test --headed
# Single test file
npx playwright test tests/smoke/perspective-loads.spec.ts
# View HTML report after failures
npx playwright show-report
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 |
page.goto() mid-test. This kills the WebSocket session. Navigate within Perspective using component interactions or openPage() for the initial load.table.waitForData() or wait for .ia_table__body__row specifically.perspective.dismissPopups() after openPage() if needed.