Files
openhuman/app/test/wdio.conf.ts
T

186 lines
7.6 KiB
TypeScript

import type { Options } from '@wdio/types';
import path from 'path';
import { fileURLToPath } from 'url';
import { captureFailureArtifacts } from './e2e/helpers/artifacts';
/**
* Unified WDIO config — single Appium Chromium-driver session that attaches
* to the running CEF app over its remote-debugging port (CDP).
*
* One automation backend on every platform:
*
* macOS / Linux / Windows → Appium Chromium driver → CEF :19222
*
* The runner script (`scripts/e2e-run-session.sh`) is responsible for:
* 1. Launching the built CEF app binary.
* 2. Waiting until `http://127.0.0.1:19222/json/version` responds (CDP up).
* 3. Starting Appium with the `chromium` driver installed.
* 4. Invoking `wdio` against this config.
*
* WDIO creates ONE session per worker. With `maxInstances: 1` and no
* cross-spec teardown, all specs run sequentially in the same session,
* against the same app process — no restart cost between spec files.
* Tests are intentionally order-dependent: state from spec N flows into
* spec N+1. Each spec is responsible for any reset it requires.
*/
const configDir = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(configDir, '..');
const tsconfigE2ePath = path.join(projectRoot, 'test', 'tsconfig.e2e.json');
const testSpecsPath = path.join(projectRoot, 'test', 'e2e', 'specs', '**', '*.spec.ts');
const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || '4723', 10);
const CEF_CDP_HOST = process.env.CEF_CDP_HOST || '127.0.0.1';
const CEF_CDP_PORT = parseInt(process.env.CEF_CDP_PORT || '19222', 10);
// Admin base for the shared mock backend. The runner exports BACKEND_URL to
// the mock; fall back to the E2E_MOCK_PORT default the runner scripts use.
const MOCK_ADMIN_BASE =
process.env.BACKEND_URL || `http://127.0.0.1:${process.env.E2E_MOCK_PORT || 18473}`;
// The mock backend carries module-level mutable state (conversations, cron
// jobs, webhook triggers, request log, socket sessions). Specs run in ONE
// ordered session and historically only reset it when a spec *remembered* to
// call `/__admin/reset` in its own hook — so a spec that failed before its
// reset poisoned the next spec file. Reset once at the start of every spec
// file, unconditionally, so no spec can leak mock state into the next one.
// Guarded by file path so nested `describe` blocks inside a file don't wipe
// state mid-file (specs still build up state across their own `it`s).
let lastResetSpecFile: string | null = null;
async function resetMockBackendOncePerSpecFile(specFile: string | undefined): Promise<void> {
if (!specFile || specFile === lastResetSpecFile) return;
lastResetSpecFile = specFile;
try {
const res = await fetch(`${MOCK_ADMIN_BASE}/__admin/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!res.ok) {
console.warn(`[wdio] mock /__admin/reset returned ${res.status} for ${specFile}`);
}
} catch (err) {
// Best-effort: some lanes run specs that don't stand up the mock backend.
// Never fail the run on an unreachable mock — just record it.
console.warn(`[wdio] mock /__admin/reset skipped for ${specFile}: ${(err as Error).message}`);
}
}
// appium-chromium-driver advertises support for platformName ∈ {windows, mac, linux}
// (not "chromium" — that's only the automationName). Pick the actual OS so the
// capability negotiation succeeds.
function platformNameForHost(): 'mac' | 'linux' | 'windows' {
if (process.platform === 'darwin') return 'mac';
if (process.platform === 'win32') return 'windows';
return 'linux';
}
export const config: Options.Testrunner & Record<string, unknown> = {
runner: 'local',
hostname: '127.0.0.1',
port: APPIUM_PORT,
path: '/',
specs: [testSpecsPath],
rootDir: projectRoot,
// Single session — Tauri+CEF is one app instance.
maxInstances: 1,
capabilities: [
{
platformName: platformNameForHost(),
'appium:automationName': 'Chromium',
// The runner downloads a chromedriver whose major matches CEF's
// bundled Chromium and exports its path here. If unset, Appium falls
// back to its bundled chromedriver — which usually drifts ahead of
// CEF and produces a "ChromeDriver only supports Chrome version N"
// session-creation error.
//
// Appium chromium driver names this capability `executable` (see
// appium-chromium-driver/build/lib/desired-caps.js), not the more
// common Chrome-driver name `chromedriverExecutable`.
...(process.env.E2E_CHROMEDRIVER_PATH
? { 'appium:executable': process.env.E2E_CHROMEDRIVER_PATH }
: {}),
'goog:chromeOptions': {
// Attach to the already-running CEF process. chromedriver will not
// try to launch its own Chrome — it picks the first page target
// exposed at this address (which is the main OpenHuman webview).
debuggerAddress: `${CEF_CDP_HOST}:${CEF_CDP_PORT}`,
},
},
],
logLevel: 'warn',
// `bail` is the number of failing specs to tolerate before WDIO stops the
// run. `--bail` on e2e-run-all-flows.sh sets E2E_BAIL_ON_FAILURE=1 so we
// flip this to 1 (= stop after the first failed spec).
bail: process.env.E2E_BAIL_ON_FAILURE === '1' ? 1 : 0,
waitforTimeout: 10_000,
connectionRetryTimeout: 120_000,
connectionRetryCount: 3,
framework: 'mocha',
reporters: ['spec'],
mochaOpts: {
ui: 'bdd',
// Cap individual `it` budget at 30s so broken specs fail fast instead
// of burning the prior 2-minute ceiling on every hung `waitForX`.
// Genuinely-slow flows (billing polling) should use scoped
// `this.timeout(60_000)` inside the specific `it` that needs it.
timeout: 30_000,
},
autoCompileOpts: { tsNodeOpts: { project: tsconfigE2ePath } },
/**
* After the chromedriver session attaches, switch the active window to
* the main OpenHuman app webview.
*
* CEF exposes multiple CDP page targets:
* - `about:blank` — the CEF prewarm hot-loaded child-webview slot
* (see CEF_PREWARM_LABEL in src-tauri/src/lib.rs).
* - `OpenHuman` @ `http://tauri.localhost/#/` — the main React app.
*
* `debuggerAddress` makes chromedriver attach to the *first* page target,
* which is `about:blank`. Without this switch, every spec ends up looking
* at an empty document. We pick the first window whose URL contains
* `tauri.localhost`, falling back to the first non-`about:blank`.
*/
before: async function () {
const handles = await browser.getWindowHandles();
let target: string | null = null;
for (const handle of handles) {
await browser.switchToWindow(handle);
const url = await browser.getUrl();
if (url.includes('tauri.localhost')) {
target = handle;
break;
}
}
if (!target) {
for (const handle of handles) {
await browser.switchToWindow(handle);
const url = await browser.getUrl();
if (!url.startsWith('about:')) {
target = handle;
break;
}
}
}
if (target) {
await browser.switchToWindow(target);
}
},
beforeSuite: async function (suite: { file?: string }) {
// Fires once per Mocha suite. The per-file guard makes the reset run only
// for the first suite encountered in each spec file.
await resetMockBackendOncePerSpecFile(suite?.file);
},
afterTest: async function (
test: { title: string; parent?: string },
_context: unknown,
result: { passed: boolean; error?: Error }
) {
if (result.passed) return;
const name = [test.parent, test.title].filter(Boolean).join(' ').trim() || test.title;
await captureFailureArtifacts(name);
},
};