A TypeScript test automation framework that unifies Playwright for web testing and WebdriverIO/Appium for native mobile testing (iOS and Android) under a single codebase. Tests run locally or on BrowserStack real devices and browsers. An AI-powered self-healing engine (OpenAI, Anthropic, Google Gemini, or a local LLM) automatically fixes broken selectors at runtime so that UI changes don't immediately break your entire suite.
- Architecture
- Prerequisites
- Quick Start
- Project Structure
- Writing Tests
- Running Tests
- AI Self-Healing Deep Dive
- CI/CD (GitHub Actions)
- Environment Variables Reference
- Reports and Artifacts
QStack uses a dual-runner architecture: each runner is best-in-class for its domain, and both share common infrastructure.
| Layer | Tool | Why |
|---|---|---|
| Web testing | Playwright Test | Auto-wait, multi-browser, built-in assertions, trace viewer |
| Mobile testing | WebdriverIO + Appium | First-class TypeScript client, rich driver ecosystem, platform-aware selectors |
| Cloud execution | BrowserStack | Real browsers and physical devices, SDK integration for both runners |
| Self-healing | OpenAI / Anthropic / Gemini / Local LLM | LLM-driven selector repair with caching to avoid repeat API calls |
┌──────────────────────────────────┐
│ QStack Framework │
│ │
│ ┌────────┐ ┌────────────────┐ │
│ │ Common │ │ AI Healing │ │
│ │ Utils │ │ Engine + Cache │ │
│ └────────┘ └────────────────┘ │
└──────┬───────────────┬───────────┘
│ │
┌──────────────┴──┐ ┌──────┴──────────────┐
│ Web (Playwright)│ │ Mobile (WebdriverIO) │
│ │ │ │
│ Page Objects │ │ Screen Objects │
│ Healing Fixture│ │ Healing Hooks │
│ *.spec.ts │ │ Gesture Helpers │
└────────┬────────┘ │ *.test.ts │
│ └──────────┬───────────┘
│ │
┌──────────┴─────────────────────────┴──────────┐
│ Execution Targets │
│ │
│ Local Browsers / Emulators BrowserStack │
└────────────────────────────────────────────────┘
- Node.js >= 20 and npm (download)
- Git
- Clone the repo, run
npm install, and copy.env.exampleto.env
Everything above, plus:
npx playwright install --with-depsThis downloads Chromium, Firefox, and WebKit binaries plus OS-level dependencies.
Everything in Global, plus:
- A BrowserStack Automate account
- Set
BROWSERSTACK_USERNAMEandBROWSERSTACK_ACCESS_KEYin.env - The browser/OS matrix is configured in
browserstack.yml(already included)
Everything in Global, plus:
- Appium installed globally:
npm install -g appium
- Platform drivers:
# Android appium driver install uiautomator2 # iOS appium driver install xcuitest
- Android: Android SDK,
ANDROID_HOMEset, an emulator or device connected (adb devicesshows it) - iOS: Xcode with command-line tools, a booted simulator (
xcrun simctl list devices | grep Booted) - Your app (
.apk/.ipa) installed on the emulator/simulator, or theappium:appcapability set inconfig/wdio.local.config.ts
Everything in Global, plus:
- A BrowserStack App Automate account
- Upload your app to BrowserStack:
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" \ -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ -F "file=@/path/to/app.apk"
- Set the returned
bs://hash in.envasBS_ANDROID_APPand/orBS_IOS_APP - Set
BROWSERSTACK_USERNAMEandBROWSERSTACK_ACCESS_KEYin.env
Everything needed for the test type you're running (web or mobile), plus:
- Set
SELF_HEALING_ENABLED=truein.env(or pass it as an env var inline) - Set
AI_PROVIDERto one of:openai,anthropic,gemini, orlocal - Set the corresponding credentials:
- OpenAI:
OPENAI_API_KEY - Anthropic:
ANTHROPIC_API_KEY - Gemini:
GEMINI_API_KEY - Local LLM:
LOCAL_LLM_BASE_URL,LOCAL_LLM_MODEL, and optionallyLOCAL_LLM_API_KEY(see below)
- OpenAI:
# 1. Clone and install
git clone <repo-url> qstack && cd qstack
npm install
# 2. Configure environment
cp .env.example .env
# Edit .env with your keys (only BASE_URL is needed for local web tests)
# 3. Install Playwright browsers
npx playwright install --with-deps
# 4. Run web tests locally
npm run test:webqstack/
package.json # Scripts, dependencies
tsconfig.json # TypeScript config (strict, ES2022)
.env.example # Environment variable template
browserstack.yml # BrowserStack SDK browser/OS matrix
config/
playwright.config.ts # Playwright local: 3 browsers, HTML + JUnit reporters
playwright.browserstack.config.ts # Playwright on BrowserStack via SDK wrapper
wdio.local.config.ts # WDIO local: Appium service auto-start, Mocha framework
wdio.browserstack.config.ts # WDIO on BrowserStack: real devices, Galaxy S24 + iPhone 15 Pro
src/
web/
pages/ # Page Object Model (extend BasePage)
BasePage.ts # Abstract base: navigate, locator, getByRole, getByText, etc.
LoginPage.ts # Example: login form interactions
tests/ # Playwright test specs (*.spec.ts)
login.spec.ts # Example: valid login, invalid login, logout
fixtures/
healing.fixture.ts # Self-healing Playwright fixture (healingPage)
mobile/
screens/ # Screen Object Model (extend BaseScreen)
BaseScreen.ts # Abstract base: platform-aware selectors, tap, type, getText
LoginScreen.ts # Example: login form for Android + iOS
tests/ # WDIO test specs (*.test.ts, Mocha syntax)
login.test.ts # Example: valid login, invalid login
helpers/
gestures.ts # Touch helpers: swipeUp, swipeDown, scrollToElement, tap
healing.hooks.ts # Self-healing WDIO hooks + healingFind() helper
common/
utils/
env.ts # Loads .env, exports typed config object
logger.ts # Structured logging via pino
retry.ts # Generic retry with exponential backoff
types/
index.ts # Shared types: Platform, ExecutionTarget, capabilities
healing/
healer.ts # Core orchestrator: cache check -> AI fallback -> record results
cache.ts # JSON file cache (healing-cache.json) for healed selectors
analyzer.ts # Selector type inference, HTML/XML cleaning, context truncation
types.ts # HealingContext, SelectorSuggestion, HealingResult, etc.
providers/
base.ts # Abstract provider: prompt builder, JSON response parser
openai.ts # OpenAI GPT-4o adapter
anthropic.ts # Anthropic Claude adapter
gemini.ts # Google Gemini adapter
local.ts # Local/self-hosted LLM adapter (OpenAI-compatible API)
.github/workflows/
web-tests.yml # Web CI: push/PR/dispatch/repository_dispatch
mobile-tests.yml # Mobile CI: dispatch/repository_dispatch (BrowserStack only)
reusable-test.yml # Reusable workflow: callable from other repos via workflow_call
reports/ # .gitignored -- local test reports, JUnit XML, healing JSON
Web tests follow the Page Object Model pattern. Every page you test gets a class that extends BasePage.
Create a new file in src/web/pages/. Your class must extend BasePage and define a path property (the URL path relative to BASE_URL).
BasePage API (methods available to all page objects):
| Method | Description |
|---|---|
navigate() |
Navigates to this.path |
waitForPageLoad() |
Waits for domcontentloaded |
getTitle() |
Returns the page title |
screenshot(name) |
Saves a full-page screenshot to reports/ |
locator(selector) |
Returns a Playwright Locator |
getByRole(role, options?) |
Finds by ARIA role |
getByText(text, options?) |
Finds by visible text |
getByTestId(testId) |
Finds by data-testid attribute |
Example -- src/web/pages/LoginPage.ts:
import type { Page } from "@playwright/test";
import { BasePage } from "./BasePage.js";
export class LoginPage extends BasePage {
protected readonly path = "/login";
private readonly usernameInput = "#username";
private readonly passwordInput = "#password";
private readonly loginButton = 'button[type="submit"]';
private readonly flashMessage = "#flash";
private readonly logoutButton = 'a[href="/logout"]';
constructor(page: Page) {
super(page);
}
async login(username: string, password: string): Promise<void> {
await this.locator(this.usernameInput).fill(username);
await this.locator(this.passwordInput).fill(password);
await this.locator(this.loginButton).click();
}
async getFlashMessage(): Promise<string> {
const text = await this.locator(this.flashMessage).innerText();
return text.trim();
}
async isLoggedIn(): Promise<boolean> {
return this.locator(this.logoutButton).isVisible();
}
async logout(): Promise<void> {
await this.locator(this.logoutButton).click();
}
}Key points:
- Store selectors as private readonly properties at the top of the class.
- Each public method represents a user-facing action or assertion query.
- The constructor accepts a Playwright
Pageand passes it toBasePage.
Create a .spec.ts file in src/web/tests/. Import test and expect from @playwright/test, and import your page object.
Example -- src/web/tests/login.spec.ts:
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/LoginPage.js";
test.describe("Login Page", () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.navigate();
});
test("should login with valid credentials", async () => {
await loginPage.login("tomsmith", "SuperSecretPassword!");
const flash = await loginPage.getFlashMessage();
expect(flash).toContain("You logged into a secure area!");
expect(await loginPage.isLoggedIn()).toBe(true);
});
test("should show error for invalid credentials", async () => {
await loginPage.login("invaliduser", "wrongpassword");
const flash = await loginPage.getFlashMessage();
expect(flash).toContain("Your username is invalid!");
});
test("should logout successfully", async () => {
await loginPage.login("tomsmith", "SuperSecretPassword!");
await loginPage.logout();
const flash = await loginPage.getFlashMessage();
expect(flash).toContain("You logged out of the secure area!");
});
});Pattern summary:
test.describegroups related tests.test.beforeEachinstantiates the page object with the Playwrightpagefixture and navigates.- Each
test()calls page object methods and asserts withexpect.
Place specs in src/web/tests/ with a .spec.ts extension. The Playwright config (config/playwright.config.ts) automatically picks up all *.spec.ts files in that directory. No additional registration is needed.
Self-healing web tests use a custom Playwright fixture that intercepts locator actions. If a selector fails, the framework captures the page DOM, asks the AI for alternative selectors, and retries transparently.
Replace your @playwright/test import with the healing fixture:
// Standard test (no healing):
// import { test, expect } from "@playwright/test";
// Self-healing test:
import { test, expect } from "../fixtures/healing.fixture.js";The healing fixture provides a healingPage fixture that wraps the standard Playwright page with a Proxy. Use it exactly like page:
import { test, expect } from "../fixtures/healing.fixture.js";
test("login with self-healing", async ({ healingPage }) => {
await healingPage.goto("/login");
// If "#username" breaks (e.g., renamed to "#user-email"), the AI will:
// 1. Capture the current DOM
// 2. Ask OpenAI/Anthropic for alternative selectors
// 3. Try each suggestion until one works
// 4. Cache the working selector for future runs
await healingPage.locator("#username").fill("tomsmith");
await healingPage.locator("#password").fill("SuperSecretPassword!");
await healingPage.locator('button[type="submit"]').click();
await expect(healingPage.locator("#flash")).toContainText("You logged into a secure area!");
});Page objects that accept a Page in their constructor work with healingPage since the proxy is fully Page-compatible:
import { test, expect } from "../fixtures/healing.fixture.js";
import { LoginPage } from "../pages/LoginPage.js";
test("page object with healing", async ({ healingPage }) => {
const loginPage = new LoginPage(healingPage);
await loginPage.navigate();
await loginPage.login("tomsmith", "SuperSecretPassword!");
expect(await loginPage.isLoggedIn()).toBe(true);
});The healing is transparent -- BasePage.locator() calls this.page.locator(), which is intercepted by the proxy.
The following locator actions are wrapped with healing: click, fill, type, check, uncheck, selectOption, innerText, textContent, isVisible, isEnabled. If any of these fail because the element cannot be found, the healing engine activates.
Mobile tests follow the Screen Object Model pattern. Each screen of your app gets a class that extends BaseScreen. Tests use Mocha syntax (describe/it).
Create a new file in src/mobile/screens/. Your class extends BaseScreen, which provides platform-aware element interaction.
BaseScreen API (methods available to all screen objects):
| Method | Description |
|---|---|
selector(android, ios) |
Returns the correct selector for the current platform |
findElement(android, ios) |
Finds and waits for an element (10s timeout) |
tapElement(android, ios) |
Taps an element |
typeText(android, ios, text) |
Clears and types text into an element |
getElementText(android, ios) |
Returns an element's text content |
isElementDisplayed(android, ios) |
Returns true/false without throwing |
waitForScreenReady(android, ios) |
Waits up to 15s for an element to appear |
screenshot(name) |
Saves a screenshot to reports/ |
isAndroid / isIOS |
Platform detection getters |
Every interaction method takes two selectors -- one for Android, one for iOS. This lets a single test file run on both platforms. When selectors are identical across platforms (common with accessibility IDs), you pass the same value twice.
Example -- src/mobile/screens/LoginScreen.ts:
import { BaseScreen } from "./BaseScreen.js";
export class LoginScreen extends BaseScreen {
private readonly usernameField = {
android: '~username_input',
ios: '~username_input',
};
private readonly passwordField = {
android: '~password_input',
ios: '~password_input',
};
private readonly loginButton = {
android: '~login_button',
ios: '~login_button',
};
private readonly errorMessage = {
android: '~error_message',
ios: '~error_message',
};
private readonly welcomeMessage = {
android: '~welcome_message',
ios: '~welcome_message',
};
async waitForScreen(): Promise<void> {
await this.waitForScreenReady(this.usernameField.android, this.usernameField.ios);
}
async login(username: string, password: string): Promise<void> {
await this.typeText(this.usernameField.android, this.usernameField.ios, username);
await this.typeText(this.passwordField.android, this.passwordField.ios, password);
await this.tapElement(this.loginButton.android, this.loginButton.ios);
}
async getErrorMessage(): Promise<string> {
return this.getElementText(this.errorMessage.android, this.errorMessage.ios);
}
async isWelcomeDisplayed(): Promise<boolean> {
return this.isElementDisplayed(this.welcomeMessage.android, this.welcomeMessage.ios);
}
}Key points:
- Store selectors as objects with
androidandioskeys. - The
~prefix is Appium shorthand for accessibility ID selectors. - For platform-specific XPath or resource-id selectors, provide different values per key.
Create a .test.ts file in src/mobile/tests/. Use Mocha syntax (describe, it, before, beforeEach). WebdriverIO globals (browser, $, expect) are available automatically.
Example -- src/mobile/tests/login.test.ts:
import { LoginScreen } from "../screens/LoginScreen.js";
describe("Login Screen", () => {
let loginScreen: LoginScreen;
before(async () => {
loginScreen = new LoginScreen();
});
beforeEach(async () => {
await loginScreen.waitForScreen();
});
it("should login with valid credentials", async () => {
await loginScreen.login("testuser", "password123");
const isWelcome = await loginScreen.isWelcomeDisplayed();
expect(isWelcome).toBe(true);
});
it("should show error for invalid credentials", async () => {
await loginScreen.login("invalid", "wrong");
const error = await loginScreen.getErrorMessage();
expect(error).toContain("Invalid");
});
});Pattern summary:
before(runs once): instantiate screen objects.beforeEach(runs per test): wait for the screen to be ready.it: interact via screen object methods, assert withexpect.
Import touch gesture utilities from src/mobile/helpers/gestures.ts when you need scrolling or custom taps:
import { swipeUp, swipeDown, scrollToElement, tapCoordinates } from "../helpers/gestures.js";
it("should scroll to find the submit button", async () => {
const element = await scrollToElement("~submit_button", 5); // max 5 swipes
await element.click();
});
it("should swipe through a carousel", async () => {
await swipeUp(); // Default: center of screen, 80% -> 20% Y
await swipeDown({ duration: 500 }); // Slower swipe
await tapCoordinates(200, 400); // Tap exact coordinates
});| Function | Description |
|---|---|
swipeUp(options?) |
Swipe from bottom to top. Configurable start/end coordinates and duration. |
swipeDown(options?) |
Swipe from top to bottom. |
scrollToElement(selector, maxScrolls?) |
Repeatedly swipes up until the element is visible, returns the element. |
tapCoordinates(x, y) |
Taps at exact screen coordinates. |
Place specs in src/mobile/tests/ with a .test.ts extension. Both WDIO configs (wdio.local.config.ts and wdio.browserstack.config.ts) automatically pick up all *.test.ts files in that directory.
Mobile self-healing uses a helper function, healingFind, that wraps element interactions with the AI healing engine.
Import healingFind from the healing hooks module. It replaces direct $() calls with a healing-aware wrapper:
import { healingFind } from "../helpers/healing.hooks.js";
describe("Login Screen (self-healing)", () => {
const testFile = "login.test.ts";
it("should tap login button with healing", async () => {
// If "~login_button" breaks, the AI will analyze the page source
// and suggest alternative selectors
await healingFind("~login_button", "click", "should tap login button", testFile);
});
it("should type username with healing", async () => {
await healingFind("~username_input", "setValue", "should type username", testFile, ["testuser"]);
});
it("should read welcome text with healing", async () => {
const text = await healingFind("~welcome_message", "getText", "should read welcome", testFile);
expect(text).toContain("Welcome");
});
});healingFind API:
| Parameter | Type | Description |
|---|---|---|
selector |
string |
The selector to find (e.g., ~accessibility_id, CSS, XPath) |
action |
"click" | "setValue" | "getText" | "isDisplayed" |
The action to perform on the element |
testName |
string |
Current test name (used for logging and cache keys) |
testFile |
string |
Current test file name |
actionArgs? |
string[] |
Arguments for the action (e.g., text to type for setValue) |
To enable automatic failure capture and healing reports, add the hooks from healing.hooks.ts to your WDIO config:
// In config/wdio.local.config.ts or config/wdio.browserstack.config.ts
import { afterTest, onComplete } from "../src/mobile/helpers/healing.hooks.js";
export const config: WebdriverIO.Config = {
// ... existing config ...
afterTest,
onComplete,
};The afterTest hook captures page source when a test fails due to an element error. The onComplete hook writes the healing report JSON after all tests finish.
| Command | What it does | Prerequisites |
|---|---|---|
npm run test:web |
Web tests, all browsers, locally | Playwright browsers |
npm run test:web:bs |
Web tests on BrowserStack | BS credentials |
npm run test:web:heal |
Web tests with AI self-healing | Playwright browsers + AI key |
npm run test:mobile:android |
Android tests on local emulator | Appium + Android SDK + emulator |
npm run test:mobile:ios |
iOS tests on local simulator | Appium + Xcode + simulator |
npm run test:mobile:bs |
Mobile tests on BrowserStack | BS credentials + uploaded app |
npm run test:mobile:heal |
Mobile tests with AI self-healing | Appium setup + AI key |
npm run test:heal |
Web + mobile with healing | All of the above |
npm run typecheck |
TypeScript compilation check | None |
npm run lint |
ESLint | None |
Run all browsers (Chromium, Firefox, WebKit):
npm run test:webRun a single browser:
npx playwright test --config=config/playwright.config.ts --project=chromium
npx playwright test --config=config/playwright.config.ts --project=firefox
npx playwright test --config=config/playwright.config.ts --project=webkitRun a single test file:
npx playwright test --config=config/playwright.config.ts src/web/tests/login.spec.tsRun tests matching a name pattern:
npx playwright test --config=config/playwright.config.ts --grep "should login"Run in headed mode (see the browser):
npx playwright test --config=config/playwright.config.ts --headedRun in debug mode (step through with Playwright Inspector):
npx playwright test --config=config/playwright.config.ts --debugOverride the base URL:
BASE_URL=https://staging.example.com npm run test:webnpm run test:web:bsThis uses browserstack-node-sdk to run Playwright tests across the browser/OS matrix defined in browserstack.yml. The SDK handles connecting to BrowserStack, launching browsers, and collecting results.
To customize the matrix, edit browserstack.yml:
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Sonoma
browserName: safari
browserVersion: latestAndroid (requires running emulator):
npm run test:mobile:androidiOS (requires booted simulator):
npm run test:mobile:iosBoth commands use config/wdio.local.config.ts, which auto-starts an Appium server via @wdio/appium-service. You need to configure device capabilities in the config file before running. Add capabilities to the capabilities array:
capabilities: [
{
platformName: "Android",
"appium:deviceName": "Pixel_7_API_34",
"appium:platformVersion": "14",
"appium:automationName": "UiAutomator2",
"appium:app": "/path/to/your/app.apk",
},
],npm run test:mobile:bsThis runs on real devices via @wdio/browserstack-service. The default config targets Samsung Galaxy S24 (Android 14) and iPhone 15 Pro (iOS 17). Device capabilities are configured in config/wdio.browserstack.config.ts.
Web tests with healing:
npm run test:web:heal
# or inline:
SELF_HEALING_ENABLED=true npm run test:webMobile tests with healing:
npm run test:mobile:heal
# or inline:
SELF_HEALING_ENABLED=true npm run test:mobile:androidBoth web and mobile with healing:
npm run test:healType-check all TypeScript without running tests:
npm run typecheckLint all source and config files:
npm run lintThe self-healing engine activates when a locator action fails (element not found). It follows this flow:
Test action fails (selector not found)
│
▼
┌─────────────────────┐
│ Check healing cache │
│ (healing-cache.json) │
└────────┬────────────┘
│
┌─────┴──────┐
│ Cache hit? │
└─────┬──────┘
Yes │ No
│ │
▼ ▼
┌──────────┐ ┌──────────────────────┐
│ Try │ │ Capture page context │
│ cached │ │ (DOM / page source) │
│ selector │ └──────────┬───────────┘
└────┬─────┘ │
│ ▼
│ ┌───────────────────┐
│ │ Send to AI provider│
│ │ │
│ └────────┬──────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ Receive 3-5 ranked │
│ │ selector suggestions │
│ └────────┬─────────────┘
│ │
▼ ▼
┌──────────────────────────┐
│ Try each suggestion │
│ until one works │
└─────────┬────────────────┘
│
┌─────┴──────┐
│ Healed? │
└─────┬──────┘
Yes │ No
│ │
▼ ▼
┌──────────┐ ┌────────────────┐
│ Cache + │ │ Fail test with │
│ Log + │ │ detailed error │
│ Continue │ └────────────────┘
└──────────┘
The prompt sent to the AI includes:
- The failed selector and its type (CSS, XPath, accessibility ID, etc.)
- The action that was attempted (click, fill, getText, etc.)
- The test name and file
- The platform (web, android, ios)
- The error message
- A cleaned and truncated page context (~6,000 characters of DOM HTML or native XML page source)
The AI responds with a JSON array of 3-5 alternative selectors, each with a confidence score and reasoning.
Successful healings are persisted to healing-cache.json at the project root. On subsequent runs, the cache is checked first to avoid redundant API calls. Cache entries are keyed by {testFile, originalSelector} and include a hit counter.
| Provider | Model | Config |
|---|---|---|
| OpenAI | GPT-4o | AI_PROVIDER=openai + OPENAI_API_KEY |
| Anthropic | Claude Sonnet | AI_PROVIDER=anthropic + ANTHROPIC_API_KEY |
| Google Gemini | Gemini 2.5 Flash | AI_PROVIDER=gemini + GEMINI_API_KEY |
| Local LLM | Any (configurable) | AI_PROVIDER=local + LOCAL_LLM_BASE_URL + LOCAL_LLM_MODEL |
The local provider works with any server that exposes an OpenAI-compatible /v1/chat/completions endpoint. Common options:
Ollama (default):
# Install and start Ollama, then pull a model
ollama pull llama3
# QStack defaults point to Ollama already:
# LOCAL_LLM_BASE_URL=http://localhost:11434
# LOCAL_LLM_MODEL=llama3LM Studio:
# Start LM Studio's local server, then set:
# LOCAL_LLM_BASE_URL=http://localhost:1234
# LOCAL_LLM_MODEL=<model-name-from-lm-studio>vLLM / llama.cpp server / LocalAI:
# Point to your server's address and model name:
# LOCAL_LLM_BASE_URL=http://localhost:8000
# LOCAL_LLM_MODEL=<your-model>
# LOCAL_LLM_API_KEY=<if required>The local provider sends no data to external services -- all inference runs on your machine.
| Workflow | File | Triggers | Description |
|---|---|---|---|
| Web Tests | .github/workflows/web-tests.yml |
push, pull_request, workflow_dispatch, repository_dispatch |
Runs web tests locally (with Playwright browsers) or on BrowserStack |
| Mobile Tests | .github/workflows/mobile-tests.yml |
workflow_dispatch, repository_dispatch |
Runs mobile tests on BrowserStack (local mobile in CI is impractical) |
| Reusable | .github/workflows/reusable-test.yml |
workflow_call |
Callable from other repos; supports both web and mobile |
Configure these in Settings > Secrets and variables > Actions in your GitHub repo:
| Secret | Required for |
|---|---|
BROWSERSTACK_USERNAME |
BrowserStack runs |
BROWSERSTACK_ACCESS_KEY |
BrowserStack runs |
OPENAI_API_KEY |
Self-healing with OpenAI |
ANTHROPIC_API_KEY |
Self-healing with Anthropic |
GEMINI_API_KEY |
Self-healing with Gemini |
AI_PROVIDER |
Self-healing (value: openai, anthropic, gemini, or local) |
BS_ANDROID_APP |
BrowserStack mobile (Android bs:// hash) |
BS_IOS_APP |
BrowserStack mobile (iOS bs:// hash) |
Both web-tests.yml and mobile-tests.yml support workflow_dispatch with input parameters. Go to Actions > [workflow name] > Run workflow and select:
- Web: environment (local/browserstack), browser (chromium/firefox/webkit), self-healing toggle
- Mobile: platform (android/ios/both), self-healing toggle
Send an HTTP request to trigger tests from any system (CI pipeline, script, another repo):
Trigger web tests:
curl -X POST https://api.github.com/repos/OWNER/qstack/dispatches \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-d '{
"event_type": "run-web-tests",
"client_payload": {
"environment": "browserstack",
"self_healing": "true",
"base_url": "https://staging.myapp.com"
}
}'Trigger mobile tests:
curl -X POST https://api.github.com/repos/OWNER/qstack/dispatches \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
-d '{
"event_type": "run-mobile-tests",
"client_payload": {
"self_healing": "false"
}
}'client_payload options:
| Key | Values | Default | Used by |
|---|---|---|---|
environment |
local, browserstack |
local |
web-tests |
self_healing |
true, false |
false |
both |
base_url |
Any URL | https://the-internet.herokuapp.com |
web-tests |
In another repository's workflow file, call QStack's reusable workflow:
name: Run QStack Tests
on:
push:
branches: [main]
jobs:
web-tests:
uses: OWNER/qstack/.github/workflows/reusable-test.yml@main
with:
test_type: web
environment: browserstack
self_healing: "true"
base_url: "https://staging.myapp.com"
browser: chromium
secrets:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
mobile-tests:
uses: OWNER/qstack/.github/workflows/reusable-test.yml@main
with:
test_type: mobile
secrets:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
BS_ANDROID_APP: ${{ secrets.BS_ANDROID_APP }}
BS_IOS_APP: ${{ secrets.BS_IOS_APP }}Reusable workflow inputs:
| Input | Type | Required | Default | Description |
|---|---|---|---|---|
test_type |
string |
Yes | -- | web or mobile |
environment |
string |
No | local |
local or browserstack |
self_healing |
string |
No | false |
true or false |
browser |
string |
No | chromium |
chromium, firefox, or webkit (web only) |
base_url |
string |
No | https://the-internet.herokuapp.com |
Base URL for web tests |
| Variable | Required | Default | Used By |
|---|---|---|---|
BROWSERSTACK_USERNAME |
For BrowserStack runs | -- | Web BS, Mobile BS, CI |
BROWSERSTACK_ACCESS_KEY |
For BrowserStack runs | -- | Web BS, Mobile BS, CI |
BS_ANDROID_APP |
For BS mobile (Android) | -- | Mobile BS |
BS_IOS_APP |
For BS mobile (iOS) | -- | Mobile BS |
AI_PROVIDER |
For self-healing | openai |
Healing engine |
OPENAI_API_KEY |
If AI_PROVIDER=openai |
-- | Healing engine |
ANTHROPIC_API_KEY |
If AI_PROVIDER=anthropic |
-- | Healing engine |
GEMINI_API_KEY |
If AI_PROVIDER=gemini |
-- | Healing engine |
LOCAL_LLM_BASE_URL |
If AI_PROVIDER=local |
http://localhost:11434 |
Healing engine |
LOCAL_LLM_MODEL |
If AI_PROVIDER=local |
llama3 |
Healing engine |
LOCAL_LLM_API_KEY |
No | not-needed |
Healing engine (if server requires auth) |
SELF_HEALING_ENABLED |
No | false |
Healing engine |
BASE_URL |
No | https://the-internet.herokuapp.com |
Web tests |
APPIUM_HOST |
No | 127.0.0.1 |
Local mobile tests |
APPIUM_PORT |
No | 4723 |
Local mobile tests |
LOG_LEVEL |
No | info |
All (pino logger) |
After web tests run, an interactive HTML report is generated:
# Open the report in your browser
npx playwright show-report playwright-reportLocation: playwright-report/index.html
For CI integration (GitHub Actions, Jenkins, etc.), JUnit XML files are generated:
| Report | Location |
|---|---|
| Web tests (local) | reports/web-results.xml |
| Web tests (BrowserStack) | reports/web-bs-results.xml |
| Mobile tests (local) | reports/mobile-local-results.xml |
| Mobile tests (BrowserStack) | reports/mobile-bs-results.xml |
When self-healing is enabled, a JSON report is written after each run:
Location: reports/healing-report.json
Structure:
{
"runTimestamp": "2026-04-15T10:30:00.000Z",
"totalHealed": 2,
"totalFailed": 1,
"entries": [
{
"originalSelector": "#old-button",
"healedSelector": "[data-action='submit']",
"selectorType": "css",
"confidence": 0.92,
"reasoning": "Button with submit action attribute matches the original intent",
"timestamp": "2026-04-15T10:30:05.000Z",
"testFile": "src/web/tests/login.spec.ts",
"testName": "should login with valid credentials",
"platform": "web"
}
]
}BrowserStack test results, videos, logs, and screenshots are available on the BrowserStack dashboard:
- Web: automate.browserstack.com
- Mobile: app-automate.browserstack.com
Session links are printed in the test output when running with BrowserStack services.
In GitHub Actions, the following artifacts are uploaded after every run:
| Artifact | Contents |
|---|---|
web-test-report |
playwright-report/ + reports/ |
mobile-test-report |
reports/ (JUnit XML) |
healing-report-web |
reports/healing-report.json (if healing was enabled) |
healing-report-mobile |
reports/healing-report.json (if healing was enabled) |
Artifacts are retained for 14 days and downloadable from the Actions run page.
MIT