Instruction file imported from bigale/agentidev (
.cursor/rules/smartclient-playwright.mdc). Copyright stays with the author.
description: Playwright E2E testing for SmartClient dashboard — locators, commands, interaction patterns globs: tests/e2e/, tests/playwright/ alwaysApply: false
SmartClient Playwright Testing
Setup
Tests use the SmartClientCommands helper from smartclientSDK/tools/playwright/commands.js.
Add to Playwright test file:
const { extendPage } = require(process.env.SMARTCLIENT_SDK + '/tools/playwright/commands.js');
test.beforeEach(async ({ page }) => {
extendPage(page);
await page.goto('chrome-extension://jgkjpplhfkpoagkobjfepkkmilbfdgcg/smartclient-app/wrapper.html?mode=dashboard');
await page.waitForSCDone();
});
AutoTest Locators
All SmartClient element refs start with //. Use isc.AutoTest.getLocator(element) in the browser console to discover them.
Common locator patterns:
// Component by ID
'//ListGrid[ID="schedulesGrid"]'
'//Button[ID="btnNewSession"]'
// Grid cell by row and column
'//ListGrid[ID="scriptsGrid"]/row[index=0]/col[name="name"]'
// Tab by title
'//TabSet[ID="scriptDetailTabs"]/tab[title="Artifacts"]'
// Form field
'//DynamicForm[ID="someForm"]/item[name="fieldName"]'
Never use CSS selectors or page.locator() for SmartClient components. SmartClient generates complex DOM that changes on redraws.
Core Commands (from extendPage)
// Resolve locator, wait for SC system done, return ElementHandle
const el = await page.getSC('//Button[ID="btnNewSession"]');
// Click (resolves locator + clicks center via mouse.move/down/up)
await page.clickSC('//Button[ID="btnNewSession"]');
// Type into a field (clicks first to focus, Ctrl+A to clear, then types)
await page.typeSC('//DynamicForm[ID="someForm"]/item[name="name"]', 'my-session');
// Hover
await page.hoverSC('//ListGrid[ID="scriptsGrid"]/row[index=0]');
// Scroll SmartClient Canvas (not native scroll — required for custom scrollbars)
await page.scrollSC('//ListGrid[ID="scriptsGrid"]', 0, 200);
// Wait for all SC async ops to complete
await page.waitForSCDone();
// Get SC component object (returns serializable JS object, not DOM element)
const grid = await page.getSCObject('//ListGrid[ID="schedulesGrid"]');
// Check element exists
const exists = await page.existsSCElement('//Button[ID="btnRun"]');
// Get text content
const text = await page.scGetLocatorText('//ListGrid[ID="scriptsGrid"]/row[index=0]/col[name="name"]');
// Drag and drop (recipe reorder, etc.)
await page.dragAndDropSC(
'//ListGrid[ID="preActionsGrid"]/row[index=1]',
'//ListGrid[ID="preActionsGrid"]/row[index=0]',
{ dropPosition: 'before' }
);
Configuration
page.configureSC({
scCommandTimeout: 15000, // default 30000ms
scLogCommands: true, // logs each SC command
scLogLevel: 'info', // 'debug' | 'info' | 'warn' | 'error' | 'silent'
scAutoWait: true, // auto-call waitForSCDone after each command
});
Critical Interaction Rules
Checkboxes and SelectItems
SmartClient CheckboxItem, SelectItem, ComboBoxItem are custom HTML — not native browser controls.
- Use
clickSC()— NOTpage.check(),page.selectOption(), or.fill() - For grid cell checkboxes (e.g.
enabledin schedulesGrid): double-click to enter edit mode first, then click the checkbox cell
// Enter edit mode on row
await page.clickSC('//ListGrid[ID="schedulesGrid"]/row[index=0]/col[name="name"]');
await page.clickSC('//ListGrid[ID="schedulesGrid"]/row[index=0]/col[name="name"]');
// (double-click = editEvent: 'doubleClick')
Click Masks
Some SC interactions (inline edit, dropdowns) show a click-mask. To dismiss:
// Use force: true option
await page.mouse.click(x, y, { force: true });
Grid Inline Editing
schedulesGrid uses editEvent: 'doubleClick'. Single click selects row, double-click opens edit.
After editing, pressing Tab or clicking elsewhere triggers editComplete and saves to bridge.
Waiting Patterns
getSC() automatically calls waitForSCDone() after resolving — no need to add extra waits after most operations.
Explicit waits needed for:
- Bridge async operations (use
page.waitForTimeout(500)after dispatch) - Grid data refresh after
invalidateCache()calls - Modal dialogs appearing after button click
Dashboard-Specific Locators
// Toolbar buttons
'//ToolStripButton[ID="tbRun"]'
'//ToolStripButton[ID="tbDebug"]'
// Sessions grid
'//ListGrid[ID="sessionsGrid"]/row[index=0]/col[name="name"]'
// Scripts library
'//ListGrid[ID="scriptsGrid"]/row[index=0]/col[name="name"]'
// Schedules grid
'//ListGrid[ID="schedulesGrid"]/row[index=0]/col[name="name"]'
'//ListGrid[ID="schedulesGrid"]/row[index=0]/col[name="enabled"]'
// Script History tabs
'//ListGrid[ID="scriptHistoryGrid"]'
'//Button[ID="btnHistoryLive"]'
'//Button[ID="btnHistoryArchive"]'
// Artifacts
'//ListGrid[ID="artifactsGrid"]'
'//HTMLFlow[ID="artifactPreview"]'
Test Structure
const { test, expect } = require('@playwright/test');
const { extendPage } = require(process.env.SMARTCLIENT_SDK + '/tools/playwright/commands.js');
test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
extendPage(page);
await page.goto('chrome-extension://jgkjpplhfkpoagkobjfepkkmilbfdgcg/smartclient-app/wrapper.html?mode=dashboard');
await page.waitForSCDone({ timeout: 15000 });
});
test('creates a new session', async ({ page }) => {
await page.clickSC('//Button[ID="btnNewSession"]');
await page.waitForSCDone();
// Dialog should appear — interact with it
await page.typeSC('//DynamicForm/item[name="sessionName"]', 'test-session');
await page.clickSC('//Button[title="OK"]');
await page.waitForTimeout(1000); // bridge async
const name = await page.scGetLocatorText('//ListGrid[ID="sessionsGrid"]/row[index=0]/col[name="name"]');
expect(name).toBe('test-session');
});
});
Discovering Locators at Runtime
In browser console (inside the sandbox iframe):
// Get locator for a component you clicked on
isc.AutoTest.getLocator(document.elementFromPoint(x, y))
// Get locator for a known component by ID
isc.AutoTest.getLocator(isc.AutoTest.getObject('//ListGrid[ID="scriptsGrid"]').getCell(0, 0))
Critical SmartClient module dependencies
ISC_DataBinding.js is required for forms to accept input — even when you don't use a DataSource. SC's form change handler internally calls RPCManager.startQueue() which lives in DataBinding. Without it:
- typing into a field updates the DOM input
- but SC's model never gets the new value (
handleChangethrows a silentTypeError: Cannot read properties of undefined (reading 'startQueue')) - on blur, SC reverts the visible input back to the model's stale value
change/changedhandlers never fire- the form looks editable but every save captures the original defaults
Minimum module set for a working form (raw → brotli@5):
ISC_Core(1.9 MB → 389 KB)ISC_Foundation(479 KB → 91 KB)ISC_Containers(190 KB → 37 KB)ISC_Forms(1.2 MB → 221 KB)ISC_DataBinding(1.9 MB → 393 KB) required even without DataSource- Tahoe
load_skin.js+skin_styles.css(~40 KB brotli)
Total wire size: ~1.4 MB brotli. HTMLFlow also lives in ISC_DataBinding, so loading it gets you that for free.
Standalone SmartClient app testing (Playwright via bridge)
For testing standalone web apps (no extension iframe), use Playwright through the bridge's playwright-shim — assertions and screenshots surface in the dashboard's Test Results portlet.
import { chromium, client } from '../packages/bridge/playwright-shim.mjs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Vendored at packages/bridge/vendor/sc-playwright-commands.cjs (.cjs forces
// CommonJS interpretation; resolves @playwright/test from our node_modules).
const { extendPage } = require('../packages/bridge/vendor/sc-playwright-commands.cjs');
const browser = await chromium.launch({ headless: true });
const page = await (await browser.newContext()).newPage();
extendPage(page);
page.configureSC({ scAutoWait: false, scLogLevel: 'silent' });
@playwright/test must be a dev dep (the SC commands.cjs requires it).
When to use which interaction primitive
| Goal | Use | Notes |
|---|---|---|
| Click a SC button | clickSC('//Button[ID="..."]') |
Real mouse events, fires SC click handler |
| Read a SC component's state | page.evaluate(() => isc.AutoTest.getObject('...').getX()) |
Most flexible |
| Fill a SC text item | page.evaluate(() => calcForm.setValue('x', 'val')) |
typeSC often fails — see below |
| Verify model state after async | waitForFunction(() => location.hash === expected, ...) |
Deterministic signals |
| Trigger an async function and await it | page.evaluate(() => fnReturningPromise()) |
SC click handlers don't await returned promises |
typeSC and form-item locators don't reliably work
The vendored commands.cjs uses isc.AutoTest.waitForElement (stricter than getObject) and doesn't resolve //DynamicForm[ID="X"]/item[name="Y"] locators — typeSC fails for most form items. Workaround: drive the form via calcForm.setValue(name, value) programmatically. SC's setValue does NOT fire change handlers (intentional, prevents loops), so verify the change handler is wired statically as a separate assertion:
const wired = await page.evaluate(() => typeof calcForm.getItem('rate').changed === 'function');
client.assert(wired, 'change handler is wired');
await page.evaluate(() => {
calcForm.setValue('rate', 6);
refreshStatus(); // mirror what the change handler does on real keystroke
});
For tests that need to simulate true user typing (e.g., regression tests for a typing-broken bug), use Playwright's keyboard:
await page.locator('input[name="rate"]').click({ clickCount: 3 });
await page.keyboard.type('6.5', { delay: 30 });
await page.keyboard.press('Tab');
scAutoWait and waitForSCDone cautions
isc.AutoTest.waitForSystemDone can hang indefinitely on pages that bump SC's busy counter — most commonly data-URI images (e.g., a QR code rendered as <img src="data:...">). Symptom: waitForSCDone times out at the configured timeout, your script dies.
- Disable
scAutoWait: falseinconfigureSCand replacewaitForSCDonewith explicit signals:waitForFunction(() => calcForm.getValue('x') === expected)orwaitForTimeout(150). - Vendored bug fix: the SDK's
waitForSCDonereferencestimeoutin its catch block but declares it inside the try (out of scope → ReferenceError swallows the actual timeout error). Hoist it to outer scope when vendoring.
Async click handlers
SC button click handlers don't await async function returns. If your handler calls async function save(), the clickSC returns immediately and your waitForFunction may race the async work.
For testing, pick the path:
- Test the button-to-handler integration once with
clickSC+ a deterministic wait - Test subsequent invocations with
page.evaluate(() => save())so you canawaitthe promise
Layout gotcha — Label valign
SmartClient Label defaults to valign: "center". If you use a Label as a container for stacked HTML rows (e.g., a recents list), set valign: "top" explicitly — otherwise a single row appears mid-container with empty space above.
change vs changed handler
change— fires per-keystroke whenchangeOnKeypress: true(default for most items). Receives(form, item, value, oldValue).changed— fires after the value is committed (blur, programmatic change, etc.).
For an explicit Calculate-button workflow, changed is usually the right choice — change would fire too eagerly while the user is still typing.
State-suffixed class names (no space)
SC stamps state on a widget by replacing the class name with a suffixed variant — no compound selectors, no separate state class. Always-no-space conventions:
| Base class | State variants you'll encounter |
|---|---|
formTitle |
formTitleFocused, formTitleOver, formTitleDisabled |
formCell |
formCellFocused, formCellOver, formCellDisabled |
textItem |
textItemFocused, textItemDisabled, textItemError |
textItemLite |
textItemLiteFocused, textItemLiteDisabled (the Lite family is what Tahoe stamps on the actual <input>, not textItem) |
tab |
tabSelected, tabOver, tabDisabled |
button |
buttonOver, buttonDown, buttonSelected, buttonFocused, buttonDisabled, buttonPrimary |
gridRow |
gridRowOver, gridRowSelected |
listGridCell |
listGridCellOver, listGridCellSelected, listGridCellSelectedOver |
sectionHeader |
sectionHeaderopened, sectionHeaderclosed (lowercase suffixes — outlier) |
Rule for CSS overrides and test selectors: any time you target a base class, also enumerate its state variants. A rule like .textItem { color: ... } won't match the focused <input> (which has textItemLiteFocused). Use [class*="textItem"] for selectors that should catch any state.
SectionStack — recreates section item DOMs on animate
SectionStack rebuilds the inner DOM of each section's items when expanding/collapsing any section (including its siblings). Direct DOM manipulation inside a section's Label contents (e.g., host.innerHTML=''; d3.append('svg')) gets orphaned — your SVG/canvas/widget goes with the old detached element. Verified by tagging the host div with data-tag before an animation: tag was lost after a sibling expanded/collapsed.
Rule for chart/canvas content in a section: build into a detached <div>, then Label.setContents(htmlString). SC then owns the contents and re-applies them across reflows automatically — they can't be wiped. Use viewBox + preserveAspectRatio="none" + CSS width:100%; height:100% on the SVG so it scales to whatever container size SC ends up giving you (you can't read clientWidth on a detached element).
const tmp = document.createElement('div');
const svg = d3.select(tmp).append('svg')
.attr('viewBox', '0 0 480 224').attr('preserveAspectRatio', 'none')
.style('width', '100%').style('height', '100%');
// ...build chart inside svg...
sectionLabel.setContents(
"<div id='chartHost' style='width:100%;height:224px;'>" + tmp.innerHTML + "</div>"
);
SectionStack — callbacks fire inconsistently
SC's documented sectionExpanded / sectionCollapsed callbacks don't fire on programmatic expandSection(name) / collapseSection(name), and may fire inconsistently on user clicks depending on version. Don't depend on them.
Robust pattern: delegated DOM click listener on [class*="sectionHeader"], [eventproxy^="isc_SectionHeader_"], deferred (~350ms) so SC's animation settles before reading section state:
document.addEventListener('click', (e) => {
if (!e.target.closest('[class*="sectionHeader"], [eventproxy^="isc_SectionHeader_"]')) return;
setTimeout(() => {
if (myStack.sectionIsExpanded('section_x')) { /* ... */ }
}, 350);
});
Header height in Tahoe is 37px (verified via inline style probe). Compute total stack height = (37 × N sections) + sum(expanded section content heights) + small pad. SC SectionStack defaults to fixed height; if total expanded content exceeds the declared height: value, content overflows and clips. Either declare a tall-enough height: for the all-expanded state, or setHeight dynamically from the click listener above.