Skip to content

Configuration

Configuration lives in spana.config.ts at the project root. Pass the config object to defineConfig for type safety.

import { defineConfig } from "spana-test";
export default defineConfig({
// ...
});

Use --config ./path/to/spana.config.ts to specify a different location.

Run spana validate-config to validate the file without starting drivers or discovering flows.

import { defineConfig } from "spana-test";
export default defineConfig({
apps: {
web: { url: "http://localhost:3000" },
android: { packageName: "com.example.app", appPath: "./builds/app.apk" },
ios: {
bundleId: "com.example.app",
appPath: "./builds/App.app",
signing: { teamId: "ABCDE12345" },
},
},
platforms: ["web", "android", "ios"],
parallelPlatforms: true,
flowDir: "./flows",
reporters: ["console", "json", "html"],
defaults: {
waitTimeout: 5000,
pollInterval: 200,
settleTimeout: 250,
initialPollInterval: 50,
waitForIdleTimeout: 150,
typingDelay: 20,
retries: 2,
retryDelay: 250,
workers: 2,
},
artifacts: {
outputDir: "./spana-output",
captureOnFailure: true,
captureOnSuccess: false,
captureSteps: false,
screenshot: true,
uiHierarchy: true,
consoleLogs: true,
jsErrors: true,
har: true,
},
execution: {
web: {
browser: "chromium",
headless: true,
verboseLogging: false,
storybook: { url: "http://localhost:6006" },
},
appium: {
serverUrl: "https://hub.browserstack.com/wd/hub",
reportToProvider: true,
},
},
visualRegression: {
threshold: 0.2,
maxDiffPixelRatio: 0.01,
baselinesDir: "./visual-baselines",
},
hooks: {
beforeAll: async ({ app }) => {
/* global setup */
},
beforeEach: async ({ app }) => {
/* reset state */
},
afterEach: async ({ app, result }) => {
/* teardown */
},
afterAll: async ({ app, summary }) => {
/* cleanup */
},
},
});

Defines the app targets for each platform.

apps: {
web?: { url: string; appPath?: string };
android?: { packageName: string; appPath?: string };
ios?: { bundleId: string; appPath?: string; signing?: IOSSigningConfig };
}
FieldPlatformDescription
web.urlWebBase URL Playwright navigates to on launch
android.packageNameAndroidAndroid application ID (e.g. com.example.app)
ios.bundleIdiOSiOS bundle identifier (e.g. com.example.app)
appPathAndroid/iOSPath to .app, .ipa, or .apk for auto-install
signing.teamIdiOSApple Development Team ID (required for physical devices)
signing.signingIdentityiOSCode signing identity (default: "Apple Development")
platforms?: Array<"web" | "android" | "ios">

Which platforms to run tests on by default. Can be overridden per-flow with FlowConfig.platforms and at the CLI with --platform.

Default: ["web"]

parallelPlatforms?: boolean

Run platform groups concurrently instead of serially.

Default: false

This is most useful when your web, Android, and iOS runs use independent resources. CLI parallel controls such as --parallel, --workers, and --devices can still force concurrent execution for the selected run.

flowDir?: string

Directory to discover flow files from. Accepts a glob or directory path.

Default: "./flows"

Relative paths are resolved from the directory that contains the config file, not from the current shell directory.

reporters?: string[]

One or more reporter names. Available reporters:

NameOutput
consoleHuman-readable terminal output (default)
jsonStructured JSON events to stdout
junitJUnit XML — compatible with CI artifact ingestion
htmlSelf-contained HTML report with embedded screenshots
allureAllure-compatible result files

Default: ["console"]

Execution mode and remote Appium settings.

execution?: {
mode?: "local" | "appium";
web?: {
browser?: "chromium" | "firefox" | "webkit";
headless?: boolean;
storageState?: string;
verboseLogging?: boolean;
storybook?: {
url?: string;
iframePath?: string;
};
};
appium?: {
serverUrl?: string;
capabilities?: Record<string, unknown>;
capabilitiesFile?: string;
reportToProvider?: boolean;
cloudProvider?: string;
browserstack?: {
app?: { id?: string; path?: string; name?: string; customId?: string };
local?: { enabled?: boolean; binary?: string; identifier?: string; args?: string[] };
options?: Record<string, unknown>;
};
saucelabs?: {
app?: { id?: string; path?: string; name?: string };
connect?: { enabled?: boolean; binary?: string; tunnelName?: string; args?: string[] };
options?: Record<string, unknown>;
};
};
}

Use execution.web to configure the local Playwright runtime for web flows.

FieldDescription
browserBrowser engine for local web runs: chromium, firefox, or webkit
headlessRun Playwright without opening a visible browser window
storageStatePreload Playwright cookies and storage state from a JSON file
verboseLoggingPrint verbose Playwright browser/page diagnostics to stdout
storybook.urlDedicated Storybook origin for app.openStory() / Session.openStory()
storybook.iframePathOverride Storybook’s preview iframe path, default /iframe.html

storageState is resolved relative to spana.config.ts. Storybook uses URLs instead of filesystem paths, so it stays portable by pointing at the right origin for each environment.

Use mode: "appium" when running against BrowserStack, Sauce Labs, or another Appium-compatible grid.

FieldDescription
serverUrlRemote Appium server URL
capabilities / capabilitiesFileRaw desired capabilities, with file paths resolved relative to the config file
reportToProviderMark the remote session passed/failed when the provider supports it
cloudProviderPath to a custom cloud provider module that default-exports a CloudProvider
browserstackBrowserStack upload, local tunnel, and capability helper settings
saucelabsSauce Labs upload, Sauce Connect, and capability helper settings

Raw capabilities from execution.appium.capabilities, capabilitiesFile, and --caps-json remain the strongest override surface. Provider helper sections fill in missing provider-specific fields and can manage BrowserStack Local / Sauce Connect lifecycle when enabled. For a custom provider implementation, see Cloud Providers.

For spana test, CLI flags win over config values.

CLI flagConfig field
--platformplatforms
--reporterreporters
--retriesdefaults.retries
--workersdefaults.workers
--driverexecution.mode
--appium-urlexecution.appium.serverUrl
--validate-configValidates config and exits
--device / --devicesCLI-only device selection
--parallelEnables concurrent execution
--watch / --changed / --last-failedCLI-only iteration controls
--update-baselinesCLI-only visual regression control
--shard / --bailCLI-only execution controls
--debug-on-failure / --verbose / --quiet / --jsonCLI-only output and debugging controls

Sharding happens after tag/name/platform filtering so each shard gets a deterministic slice of the already-selected flows.

Timing and retry defaults applied to all auto-wait operations. Individual operations can override these with WaitOptions.

defaults?: {
waitTimeout?: number; // ms
pollInterval?: number; // ms
settleTimeout?: number; // ms
retries?: number;
waitForIdleTimeout?: number;
typingDelay?: number;
initialPollInterval?: number;
hierarchyCacheTtl?: number;
retryDelay?: number;
workers?: number;
}
OptionDefaultDescription
waitTimeout5000Maximum ms to wait for an assertion or element lookup
pollInterval200Maximum ms between assertion polls
settleTimeout0Stable time required before an assertion passes
retries0Retry count for failed flow executions
waitForIdleTimeout0Pause after mutating actions such as tap or scroll
typingDelay0Delay between typed characters
initialPollInterval50First poll interval before adaptive backoff ramps up
hierarchyCacheTtl100Hierarchy cache freshness window in ms; set 0 to disable
retryDelay0Delay between failed flow retry attempts
workersDefault max workers per platform when --parallel is enabled

Controls screenshot and hierarchy capture on test completion.

artifacts?: {
outputDir?: string;
captureOnFailure?: boolean;
captureOnSuccess?: boolean;
captureSteps?: boolean;
screenshot?: boolean;
uiHierarchy?: boolean;
consoleLogs?: boolean;
jsErrors?: boolean;
har?: boolean;
}
OptionDefaultDescription
outputDir"./spana-output"Directory to write captured artifacts
captureOnFailuretrueCapture artifacts for failed flows
captureOnSuccessfalseCapture final artifacts for passed flows
captureStepsfalseCapture screenshots after each recorded step
screenshottrueInclude screenshots in captures
uiHierarchytrueInclude UI hierarchy dumps
consoleLogstrueInclude captured browser console logs on web
jsErrorstrueInclude uncaught browser JavaScript errors on web
hartrueInclude HAR network traces on web

Lifecycle hooks that run around flow execution. Each hook receives a HookContext.

hooks?: {
beforeAll?: (ctx: HookContext) => Promise<void>;
beforeEach?: (ctx: HookContext) => Promise<void>;
afterEach?: (ctx: HookContext) => Promise<void>;
afterAll?: (ctx: HookContext) => Promise<void>;
}
HookWhen it runs
beforeAllOnce before all flows on a platform
beforeEachBefore each individual flow
afterEachAfter each individual flow (always runs, even on failure)
afterAllOnce after all flows on a platform

HookContext provides app, expect, platform, result (in afterEach), and summary (in afterAll).

HookOn failure
beforeAllAll flows on that platform are skipped and marked as failed
beforeEachThat flow is skipped and marked as failed
afterEachWarning logged, test result is not affected
afterAllWarning logged, test results are not affected
export default defineConfig({
hooks: {
beforeEach: async ({ app }) => {
await app.launch({ clearState: true });
},
afterEach: async ({ app, result }) => {
if (result?.status === "failed") {
console.log(`Flow failed: ${result.name}`);
}
},
},
});

The flow() function accepts an optional config object as its second argument, letting you override global settings for a single flow.

import { flow } from "spana-test";
flow(
"checkout",
{
timeout: 30000,
tags: ["smoke"],
artifacts: { captureSteps: true },
launchOptions: { deepLink: "myapp://checkout" },
},
async ({ app }) => {
// ...
},
);
interface FlowConfig {
tags?: string[];
platforms?: Array<"web" | "android" | "ios">;
timeout?: number;
autoLaunch?: boolean;
launchOptions?: LaunchOptions;
artifacts?: ArtifactConfig;
defaults?: FlowDefaults;
when?: WhenCondition;
}
OptionDefaultDescription
tags[]Tags for CLI filtering with --tag
platformsRestrict a flow to specific platforms
timeoutOverall flow timeout in ms
autoLaunchtrueAutomatically launch the app before the flow starts
launchOptionsPer-flow launch defaults merged on top of project launchOptions
artifactsPer-flow artifact overrides
defaultsPer-flow wait, typing, cache, and idle timing overrides
whenRuntime conditions such as platform or environment-variable gates

The per-flow artifacts object is merged with the global artifacts config, so you only need to specify the fields you want to override. For example, enabling captureSteps on a single flow:

flow(
"visual regression",
{ artifacts: { captureSteps: true, captureOnSuccess: true } },
async ({ app }) => {
// Every step is captured, and the final state is saved even on success
},
);

Default launch options applied to each flow launch. FlowConfig.launchOptions overrides these defaults for one flow, and explicit app.launch(opts) calls are merged on top of both.

launchOptions?: LaunchOptions
export default defineConfig({
launchOptions: {
clearState: true, // fresh state every run
},
});
interface LaunchOptions {
clearState?: boolean;
clearKeychain?: boolean;
deepLink?: string;
launchArguments?: Record<string, unknown>;
deviceState?: DeviceStateConfig;
}
interface DeviceStateConfig {
language?: string;
locale?: string;
timeZone?: string;
}
OptionDefaultDescription
clearStatefalseClear app data/storage before launch
clearKeychainfalseClear the iOS keychain before launch (simulator only)
deepLinkOpen the app via a deep link URL
launchArgumentsKey-value pairs passed as launch arguments to the app
deviceStateLaunch-time language / locale / time-zone overrides where the runtime can honor them
OptionWebAndroidiOS
clearStateClears cookies, localStorageadb shell pm clearResets app permissions
clearKeychainNo-opNo-op (warns)xcrun simctl keychain reset
launchArgumentsNo-opPassed as --es extras via am startAppium iOS: process args via mobile: launchApp; local WDA: warns + ignores
deviceStateWarns + ignoresAppium: session-start caps; local Android: warns + ignoresAppium: session caps + launch-time process config; local WDA: warns + ignores
deepLinkNavigates to URLadb shell am start -d <url>xcrun simctl openurl / WDA

Android launchArguments are string extras. For example:

await app.launch({
launchArguments: {
featureFlag: "on",
buildVariant: "staging",
},
});

deviceState is for launch-time localization and time-zone setup. It does not mutate the device clock mid-run. In Appium mode, suite-level launchOptions.deviceState is also mapped into session capabilities so defaults apply even when Appium keeps autoLaunch off. Common locale forms such as fr_CA / fr-CA are normalized for Appium, but Android still needs both language and region information available.

Use FlowConfig.launchOptions when one flow needs a different launch profile than the project defaults:

flow(
"checkout in French",
{
autoLaunch: true,
launchOptions: {
clearState: true,
deepLink: "myapp://checkout",
deviceState: {
language: "fr",
locale: "fr_CA",
timeZone: "America/Toronto",
},
},
},
async ({ app }) => {
// ...
},
);

When autoLaunch is false, you can still call app.launch() manually. The project and per-flow defaults are merged first, then the manual call wins:

flow("onboarding", { autoLaunch: false }, async ({ app }) => {
await app.launch({ deepLink: "myapp://welcome" });
// ...
});

Project-level defaults for screenshot assertions.

visualRegression?: {
threshold?: number;
maxDiffPixelRatio?: number;
baselinesDir?: string;
}
OptionDefaultDescription
threshold0.2Pixel comparison sensitivity
maxDiffPixelRatio0.01Maximum differing pixels before the assertion fails
baselinesDir__baselines__ next to each flow fileCentralize baseline screenshots in one directory

These values become defaults for expect(selector).toMatchScreenshot(name, options?). Per-assertion options still win, and baseline rewrites are still controlled by spana test --update-baselines.