@civitas-cerebrum/singularity-engine
v0.1.6
Published
Platform-agnostic test automation engine. Semantic Steps API for web, mobile, and API testing.
Maintainers
Readme
Singularity Engine
A platform-agnostic test automation engine. Write once, run on web, mobile, and desktop.
@civitas-cerebrum/singularity-engine pairs with @civitas-cerebrum/element-repository to achieve a fully decoupled test automation architecture. By separating Element Acquisition from Element Interaction behind an abstract factory pattern, the same test steps work across Playwright (web), Appium (Android, iOS), and desktop apps (Windows, Mac) — without changing test code.
✨ The Unified Steps API
The Steps class provides a single, semantic API that works identically across platforms. Under the hood, an IInteractionFactory routes each call to the correct platform implementation.
// This test runs on web, Android, and iOS — no code changes needed
await steps.navigateTo('/');
await steps.click('addToCartButton', 'HomePage');
await steps.verifyText('total', 'CartPage', '$29.99');
// API testing built-in
const orders = await steps.apiGet<Order[]>('/api/orders');
await steps.verifyApiStatus(orders, 200);🧠 AI-Friendly Test Development
The API is highly semantic and completely decoupled from the DOM and platform internals, making it an ideal framework for AI coding assistants. AI models generate robust test flows using plain-English strings ('CheckoutPage', 'submitButton') without hallucinating complex selectors.
📦 Installation
npm install @civitas-cerebrum/singularity-enginePeer dependency: @playwright/test is required for web testing. Install it alongside:
npm install @civitas-cerebrum/singularity-engine @playwright/test🚀 Quick Start
1. Create the test fixture
// tests/fixtures/base.ts
import { test as base, expect } from '@playwright/test';
import { baseFixture } from '@civitas-cerebrum/singularity-engine';
export const test = baseFixture(base, 'tests/data/page-repository.json');
export { expect };2. Define your selectors
// tests/data/page-repository.json
{
"pages": [
{
"name": "HomePage",
"elements": [
{ "elementName": "searchInput", "selector": { "css": "[data-testid='search']" } },
{ "elementName": "addToCartButton", "selector": { "css": "[data-testid='add-to-cart']" } },
{ "elementName": "bookCard", "selector": { "css": "[data-testid^='book-card-']" } }
]
},
{
"name": "CartPage",
"elements": [
{ "elementName": "total", "selector": { "css": "[data-testid='cart-total']" } },
{ "elementName": "checkoutButton", "selector": { "css": "[data-testid='checkout-btn']" } },
{ "elementName": "emptyMessage", "selector": { "css": "[data-testid='cart-empty']" } }
]
}
]
}3. Write tests
// tests/checkout.spec.ts
import { test, expect } from '../fixtures/base';
test('complete purchase flow', async ({ steps }) => {
// Navigate and interact
await steps.navigateTo('/');
await steps.fill('searchInput', 'HomePage', 'Dune');
await steps.pressKey('Enter');
await steps.waitForNetworkIdle();
// Verify results
const count = await steps.getCount('bookCard', 'HomePage');
expect(count).toBeGreaterThan(0);
// Click first result and add to cart
await steps.clickNth('bookCard', 'HomePage', 0);
await steps.click('addToCartButton', 'BookDetailPage');
// Verify cart
await steps.navigateTo('/cart');
await steps.verifyPresence('total', 'CartPage');
await steps.verifyText('total', 'CartPage');
});4. Run
npx playwright test📱 Mobile Setup (Android + iOS)
Same steps.* API, different fixture. Mobile drives native apps via Appium + WebDriverIO; the only thing that changes between web and mobile is which fixture function builds the test object.
Prerequisites
- Appium 3+:
npm i -g appiumthenappium driver install uiautomator2(Android) and/orappium driver install xcuitest(iOS). - Android: Android SDK, a running emulator or device,
ANDROID_HOMEset on PATH. - iOS: Xcode with at least one simulator you've booted once, plus
xcrun simctlon PATH. - Appium server running before tests start:
appium server --base-path / # default port 4723 - Your app binary —
.apkfor Android,.appor.app.zipfor iOS simulator.
Fixture
// tests/fixtures/mobile.ts
import { test as base, expect } from '@playwright/test';
import { mobileFixture } from '@civitas-cerebrum/singularity-engine';
export const test = mobileFixture(base, 'tests/data/mobile-page-repository.json', {
capabilities: {
platformName: 'Android',
'appium:automationName': 'UiAutomator2',
'appium:deviceName': 'Android Emulator',
'appium:app': '/absolute/path/to/app.apk',
'appium:autoGrantPermissions': true,
'appium:newCommandTimeout': 120,
},
// Optional:
hostname: '127.0.0.1', // Appium server — defaults to 127.0.0.1
port: 4723, // Appium port — defaults to 4723
timeout: 30_000, // Element-action timeout (default 30s)
onSessionStarted: async (driver) => {
// Optional hook — e.g. force-launch the app on Android:
// await driver.terminateApp('com.example.app');
// await driver.activateApp('com.example.app');
},
});
export { expect };iOS caps look essentially the same — swap platformName, automationName, and add appium:platformVersion + pointer to the .app.zip:
capabilities: {
platformName: 'iOS',
'appium:automationName': 'XCUITest',
'appium:deviceName': 'iPhone 16 Pro',
'appium:platformVersion': '18.3',
'appium:app': '/absolute/path/to/TestApp.app.zip',
'appium:newCommandTimeout': 120,
}page-repository.json for mobile
Entries use the portable accessibility: selector (maps to accessibilityIdentifier on iOS and content-desc on Android). Tag each entry's owning page with the target platform so element-repository picks the right variant:
{
"pages": [
{
"name": "LoginPage",
"platform": "android",
"elements": [
{ "elementName": "submitButton", "selector": { "accessibility": "btn-submit" } }
]
},
{
"name": "LoginPage",
"platform": "ios",
"elements": [
{ "elementName": "submitButton", "selector": { "accessibility": "btn-submit" } }
]
}
]
}Writing tests (same steps.* API)
import { test, expect } from './fixtures/mobile';
test('login flow', async ({ steps }) => {
await steps.fill('emailInput', 'LoginPage', '[email protected]');
await steps.fill('passwordInput', 'LoginPage', 'hunter2');
await steps.click('submitButton', 'LoginPage');
await steps.on('welcomeBanner', 'HomePage').text.toContain('Welcome');
});Web-only methods (hover, rightClick, uploadFile, selectDropdown, urlContains, setViewport, reload, switchToNewTab, …) are on the same contract on mobile — they log a tester:warn line and either no-op (void returns) or throw PlatformNotSupportedError (value returns). Your test code is portable; what doesn't make sense on a given backend fails loudly rather than silently producing fake data.
Running
# Android
MOBILE_PLATFORM=android npx playwright test
# iOS
MOBILE_PLATFORM=ios npx playwright testEngine's own mobile suite (under tests/mobile/) reads capabilities from tests/mobile/capabilities.{android,ios}.json and lets these env vars override specific keys without editing the JSON:
| Env var | Overrides |
|---|---|
| APPIUM_HOST / APPIUM_PORT | Appium server endpoint |
| APPIUM_ANDROID_APP_PATH / APPIUM_ANDROID_DEVICE | Android app + device name |
| APPIUM_IOS_APP_PATH / APPIUM_IOS_DEVICE / APPIUM_IOS_PLATFORM_VERSION | iOS app + device + iOS version |
| MOBILE_ELEMENT_TIMEOUT | Per-call element timeout |
| MOBILE_PAGE_REPOSITORY | Alternate page-repository.json path |
See tests/mobile/README.md for the engine-specific setup playbook (device bundle ids, iOS Xcode version notes, Appium noReset trade-offs).
✨ Features
Semantic Steps API
77 methods organised by purpose. No raw Playwright calls needed — every interaction goes through the Steps API:
// All interactions use (elementName, pageName) from the repository
await steps.click('submitButton', 'LoginPage');
await steps.fill('emailInput', 'LoginPage', '[email protected]');
await steps.verifyText('errorMessage', 'LoginPage', 'Invalid credentials');🌐 API Interaction
Make typed HTTP requests directly from tests. Powered by @civitas-cerebrum/wasapi:
// Enable in fixture
export const test = baseFixture(base, 'tests/data/page-repository.json', {
apiBaseUrl: 'http://localhost:8080',
});
// In tests
const response = await steps.apiGet<{ status: string }>('/api/health');
await steps.verifyApiStatus(response, 200);
expect(response.body!.status).toBe('healthy');
const order = await steps.apiPost<Order>('/api/orders', {
items: [{ bookId: 'book-001', quantity: 1 }]
});
expect(order.body!.status).toBe('COMPLETED');
// Check response headers
const headers = await steps.apiHead('/api/health');
expect(headers['content-type']).toContain('application/json');Multiple API Providers
Connect to multiple backends simultaneously:
export const test = baseFixture(base, 'tests/data/page-repository.json', {
apiBaseUrl: 'http://localhost:8080', // default
apiProviders: {
payments: 'https://api.stripe.test/v1', // named
auth: 'https://auth.example.com', // named
},
});
// Default client
await steps.apiGet('/api/books');
// Named providers
await steps.apiPost('payments', '/charges', { amount: 1000 });
await steps.apiGet('auth', '/oauth/userinfo');Visibility — Probe + Gate (isVisible)
isVisible() is a dual-behavior entry point: await it for a boolean probe (never throws), or chain an action on it for a visibility-gated call (silently skips when hidden).
// Probe — returns boolean
const ok = await steps.isVisible('promoBanner', 'HomePage', { timeout: 500 });
// Gate — click only if visible (no throw)
await steps.isVisible('cookieBanner', 'HomePage').click();
// Gate with text filter
await steps.isVisible('promo', 'HomePage', { containsText: '50% off' }).click();
// Matcher tree — silently skipped when hidden
await steps.isVisible('banner', 'HomePage').text.toBe('Hello');Every probe and gate decision is logged under the tester:visible debug channel so silently-skipped actions stay traceable.
isPresent(elementName, pageName) remains available as a boolean-only presence check using the default element timeout.
const hasError = await steps.isPresent('errorMessage', 'LoginPage');
if (hasError) {
const errorText = await steps.getText('errorMessage', 'LoginPage');
console.log('Login failed:', errorText);
}
ifVisible()on the fluent chain is deprecated — useisVisible({ timeout })instead. It covers both the old modifier and the old boolean probe in one API.
Chain-Style Expect Matchers
A fluent assertion surface for when you want to verify multiple things about a single element. Available at both steps.expect(el, page) (top-level) and as field getters on steps.on(el, page) (fluent). Each matcher queues an assertion; awaiting flushes the queue and short-circuits on the first failure.
// Chain many verifications in one expression
await steps.on('submitButton', 'CheckoutPage')
.text.toBe('Place Order')
.visible.toBeTrue()
.enabled.toBeTrue()
.attributes.get('data-variant').toBe('primary')
.not.attributes.toHaveKey('disabled');
// Top-level matcher tree
await steps.expect('price', 'ProductPage').text.toMatch(/^\$\d+\.\d{2}$/);
await steps.expect('items', 'ListPage').count.toBeGreaterThan(3);
// Predicate escape hatch — for assertions the matcher tree doesn't cover
await steps.expect('price', 'ProductPage')
.satisfy(el => parseFloat(el.text.slice(1)) > 10)
.throws('price must be above $10');
// Per-call timeout override
await steps.on('slowWidget', 'HomePage').timeout(5000).text.toBe('Ready');Field matchers: text, value, count, visible, enabled, attributes, css(prop). Each carries .not for negation.
Navigation with Query Parameters
await steps.navigateTo('/', { query: { genre: 'Fiction', page: '2' } });
// Navigates to /?genre=Fiction&page=2Email Testing
Send, receive, and verify emails via SMTP/IMAP:
export const test = baseFixture(base, 'tests/data/page-repository.json', {
emailCredentials: {
smtp: { host: 'smtp.example.com', port: 587, user: '...', pass: '...' },
imap: { host: 'imap.example.com', port: 993, user: '...', pass: '...' },
},
});
// In tests
await steps.sendEmail({ to: '[email protected]', subject: 'OTP', text: '123456' });
const email = await steps.receiveEmail({
filters: [{ type: EmailFilterType.SUBJECT, value: 'OTP' }]
});
expect(email.text).toContain('123456');Platform-Agnostic Testing
The same test code runs on web, Android, iOS, Windows, and Mac:
// This test works on all platforms — no code changes needed
await steps.navigateTo('/');
await steps.click('submitButton', 'HomePage');
await steps.verifyText('welcomeMessage', 'HomePage', 'Hello!');The abstract factory pattern routes each call to the correct platform implementation. PlatformElement wraps both Playwright's Locator and WebdriverIO's Element — neither leaks into test code.
Viewport Testing
await steps.setViewport(375, 812); // mobile
await steps.navigateTo('/');
await steps.verifyPresence('bookGrid', 'HomePage');
await steps.setViewport(1280, 720); // desktopScreenshots
const pageScreenshot = await steps.screenshot();
const elementScreenshot = await steps.screenshot('bookGrid', 'HomePage');Listed Element Interactions
Work with tables, lists, and repeating elements:
// Click a specific row by text
await steps.clickListedElement('orderCard', 'OrdersPage', { text: 'ORD-123' });
// Verify data within a list item
await steps.verifyListedElement('orderCard', 'OrdersPage', {
text: 'ORD-123',
child: { elementName: 'statusBadge', pageName: 'OrdersPage' },
expectedText: 'COMPLETED'
});
// Extract data from a list item
const price = await steps.getListedElementData('cartItem', 'CartPage', {
text: 'Dune',
child: { elementName: 'itemPrice', pageName: 'CartPage' }
});🧩 Included Fixtures
baseFixture provides these fixtures to every test:
| Fixture | Type | Description |
|---------|------|-------------|
| steps | Steps | The full Steps API |
| repo | ElementRepository | Direct repository access for advanced queries |
| interactions | ElementInteractions | Raw interaction API for custom locators |
| contextStore | ContextStore | Shared in-memory key-value store between steps |
| page | Page | Playwright page with auto-screenshot on failure |
🛠️ API Reference
🧭 Navigation
| Method | Description |
|--------|-------------|
| navigateTo(url, options?) | Navigate to URL. Options: { query: { key: 'value' } } |
| refresh() | Reload current page |
| backOrForward(direction) | Browser history: 'back' or 'forward' |
| setViewport(width, height) | Resize viewport |
| switchToNewTab(action) | Execute action that opens new tab, return new page |
| closeTab(targetPage?) | Close a tab |
| getTabCount() | Number of open tabs |
🖱️ Interaction
| Method | Description |
|--------|-------------|
| click(element, page) | Click an element |
| clickNth(element, page, index) | Click by zero-based index |
| clickIfPresent(element, page) | Click if visible, returns boolean |
| clickRandom(element, page) | Click a random match |
| doubleClick(element, page) | Double-click |
| rightClick(element, page) | Right-click (web only) |
| hover(element, page) | Hover (web only) |
| fill(element, page, text) | Clear and fill input |
| clearInput(element, page) | Clear input field |
| typeSequentially(element, page, text, delay?) | Type character by character |
| pressKey(key) | Press keyboard key ('Enter', 'Escape', 'Tab', etc.) |
| check(element, page) / uncheck(element, page) | Toggle checkbox |
| selectDropdown(element, page, options?) | Select from <select> |
| selectMultiple(element, page, values) | Multi-select |
| uploadFile(element, page, filePath) | Upload file (web only) |
| dragAndDrop(element, page, options) | Drag to target or by offset |
| setSliderValue(element, page, value) | Set range input value |
| scrollIntoView(element, page) | Scroll element into viewport |
| scrollUntilFound(element, page, timeout?) | Scroll until element appears |
| clickWithoutScrolling(element, page) | Click without auto-scrolling |
📊 Data Extraction
| Method | Description |
|--------|-------------|
| getText(element, page) | Get trimmed text content |
| getAttribute(element, page, attr) | Get HTML attribute value |
| getCount(element, page) | Count matching elements |
| getInputValue(element, page) | Get input's value property |
| getAll(element, page, options?) | Get text/attributes from all matches |
| getCssProperty(element, page, property) | Get computed CSS (web only) |
| screenshot() / screenshot(element, page) | Page or element screenshot |
✅ Verification
| Method | Description |
|--------|-------------|
| verifyPresence(element, page) | Assert visible (throws on failure) |
| isPresent(element, page) | Boolean visibility check (never throws) |
| isVisible(element, page, opts?) | VisibleChain — await for probe (boolean); chain .click()/.fill()/matcher tree for visibility-gated actions. Options: { timeout, containsText } |
| expect(element, page) | Chain-style matcher tree — .text, .value, .count, .visible, .enabled, .attributes, .css(prop), .satisfy(pred), .not |
| verifyAbsence(element, page) | Assert hidden/detached |
| verifyText(element, page, expected?) | Assert text matches; no args = not empty |
| verifyTextContains(element, page, substring) | Assert text contains |
| verifyCount(element, page, options) | Assert count: { exactly }, { greaterThan }, { lessThan } |
| verifyState(element, page, state) | Assert state: 'enabled', 'disabled', 'checked', 'visible', etc. |
| verifyAttribute(element, page, attr, expected) | Assert attribute value |
| verifyInputValue(element, page, expected) | Assert input value |
| verifyUrlContains(text) | Assert URL contains text |
| verifyImages(element, page) | Verify images rendered (web only) |
| verifyOrder(element, page, expectedTexts) | Assert text order in list |
| verifyListOrder(element, page, direction) | Assert sorted: 'asc' or 'desc' |
| verifyCssProperty(element, page, prop, expected) | Assert CSS value (web only) |
| verifyTabCount(expected) | Assert tab count |
🌐 API Interaction
| Method | Description |
|--------|-------------|
| apiGet<T>(path, options?) | GET request. Options: { query, headers } |
| apiPost<T>(path, body?, options?) | POST request. Options: { pathParams, query, headers } |
| apiPut<T>(path, body?, options?) | PUT request |
| apiDelete<T>(path, options?) | DELETE request |
| apiPatch<T>(path, body?, options?) | PATCH request |
| apiHead(path) | HEAD request — returns headers only |
| verifyApiStatus(response, status) | Assert HTTP status code |
| verifyApiHeader(response, name, value?) | Assert header presence/value |
All API methods support named providers: apiGet('providerName', path, options?).
📋 Listed Elements
| Method | Description |
|--------|-------------|
| clickListedElement(element, page, options) | Click within a list by text/attribute |
| verifyListedElement(element, page, options) | Verify within a list |
| getListedElementData(element, page, options) | Extract data from a list item |
| dragAndDropListedElement(element, page, text, options) | Drag a list item |
⏳ Wait & Composite
| Method | Description |
|--------|-------------|
| waitForState(element, page, state?) | Wait for element state (default: 'visible') |
| waitForNetworkIdle() | Wait for network quiet |
| waitForResponse(urlPattern, action) | Wait for network response during action |
| waitAndClick(element, page) | Wait for visible, then click |
| fillForm(page, fields) | Fill multiple form fields in one call |
| retryUntil(action, verify, maxRetries?, delay?) | Retry action until verification passes |
| Method | Description |
|--------|-------------|
| sendEmail(options) | Send via SMTP |
| receiveEmail(options) | Poll inbox for matching email |
| receiveAllEmails(options) | Get all matching emails |
| cleanEmails(options?) | Delete matching or all emails |
| markEmail(action, options?) | Mark as read, flagged, archived |
🗂️ Page Repository
All selectors live in a single JSON file — the source of truth for element locations:
{
"pages": [
{
"name": "LoginPage",
"elements": [
{
"elementName": "emailInput",
"selector": { "css": "input[data-testid='email']" }
},
{
"elementName": "submitButton",
"selector": {
"css": "button[type='submit']",
"text": "Sign In"
}
}
]
}
]
}Selector strategies: css, xpath, id, text, accessibility id, uiautomator, predicate, class chain
Naming conventions:
- Page names: PascalCase (
CheckoutPage,ProductDetailsPage) - Element names: camelCase (
submitButton,cartTotal)
Multi-Platform Selectors
The same JSON file supports different selectors per platform:
{
"pages": [
{
"name": "HomePage",
"elements": [
{ "elementName": "submit", "selector": { "css": "button#submit" } }
]
},
{
"name": "HomePage",
"platform": "android",
"elements": [
{ "elementName": "submit", "selector": { "accessibility id": "submit-btn" } }
]
}
]
}📱 Supported Platforms
| Platform | Driver | Status | |----------|--------|--------| | Web (Chromium, Firefox, WebKit) | Playwright | Stable | | Android | Appium + WebdriverIO | Stable | | iOS | Appium + WebdriverIO | Stable | | Windows | Appium + WinAppDriver | Experimental | | Mac | Appium + mac2 driver | Experimental |
🧱 Advanced Usage
Direct Repository Access
test('advanced query', async ({ repo, page }) => {
const element = await repo.get('submitButton', 'HomePage');
const selector = repo.getSelector('submitButton', 'HomePage');
const allElements = await repo.getAll('productCard', 'HomePage');
});Context Store
Share data between test steps:
test('multi-step flow', async ({ steps, contextStore }) => {
// Save data in one step
const orderId = await steps.getText('orderId', 'OrderPage');
contextStore.set('orderId', orderId);
// Use it later
const savedId = contextStore.get<string>('orderId');
await steps.navigateTo(`/orders/${savedId}`);
});Form Filling
Fill multiple fields in one call:
await steps.fillForm('SignupPage', {
usernameInput: 'john_doe',
emailInput: '[email protected]',
passwordInput: 'SecurePass123!',
countrySelect: { type: DropdownSelectType.VALUE, value: 'us' }
});Retry Until
Retry an action until a verification passes:
await steps.retryUntil(
async () => { await steps.click('refreshButton', 'DashboardPage'); },
async () => { await steps.verifyText('status', 'DashboardPage', 'Ready'); },
3, // max retries
1000 // delay between retries (ms)
);📦 Re-exported Types
These types are available directly from the package:
import {
// Framework
Steps, baseFixture, BaseFixtureOptions,
ElementInteractions, ContextStore,
// Element Repository
ElementType, WebElement, PlatformElement,
// Enums
DropdownSelectType, EmailFilterType, EmailMarkAction,
// API (wasapi)
WasapiClient, ApiCall, ApiResponse, ResponsePair,
GET, POST, PUT, DELETE, PATCH,
FailedCallException, WasapiException, HttpMethod,
// Email
EmailClient,
} from '@civitas-cerebrum/singularity-engine';📄 License
MIT
Powered by Civitas Cerebrum
