@civitas-cerebrum/singularity-engine
v0.1.4
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✨ 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');Boolean Visibility Check
isPresent() returns true/false without throwing — perfect for conditional flows:
const hasError = await steps.isPresent('errorMessage', 'LoginPage');
if (hasError) {
const errorText = await steps.getText('errorMessage', 'LoginPage');
console.log('Login failed:', errorText);
}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) |
| 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
