test(e2e): make the harness actually exercise the UI past login (#1859)

This commit is contained in:
Steven Enamakel
2026-05-15 15:52:51 -07:00
committed by GitHub
parent 248186ddf1
commit 2d54e46296
19 changed files with 746 additions and 230 deletions
+4
View File
@@ -178,6 +178,10 @@ fantoccini = ["browser-native"]
landlock = ["sandbox-landlock"]
rag-pdf = ["dep:pdf-extract"]
whatsapp-web = ["dep:whatsapp-rust", "dep:whatsapp-rust-tokio-transport", "dep:whatsapp-rust-ureq-http-client", "dep:wacore", "serde-big-array"]
# Exposes the destructive `openhuman.test_reset` RPC. Off by default; the E2E
# build (app/scripts/e2e-build.sh) flips it on. Shipped binaries never have
# this feature so the wipe RPC isn't even registered, let alone reachable.
e2e-test-support = []
# Fix whisper-rs-sys CRT mismatch on Windows MSVC (LNK2038).
# Upstream cmake build defaults to /MD but Rust uses /MT.
+1
View File
@@ -16,6 +16,7 @@
"tauri:ensure": "bash ../scripts/ensure-tauri-cli.sh",
"build": "tsc && vite build",
"build:app": "tsc && vite build",
"build:app:e2e": "tsc && vite build --mode development",
"build:web": "cross-env VITE_OPENHUMAN_TARGET=web tsc && cross-env VITE_OPENHUMAN_TARGET=web vite build",
"compile": "tsc --noEmit",
"preview": "vite preview",
+16 -5
View File
@@ -39,8 +39,19 @@ export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}"
# Core is compiled in-process into the Tauri shell as of PR #1061; the old
# scripts/stage-core-sidecar.mjs staging step is no longer needed.
# Disable updater artifacts for E2E bundles to avoid signing-key requirements.
TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}'
# Build the frontend in DEV mode up-front so `import.meta.env.DEV` is true
# in the bundled E2E binary. That flips `restartApp` in
# app/src/utils/tauriCommands/core.ts from the real `app.restart()`
# (which destroys the WebDriver CDP target) to a benign
# `window.location.reload()`. Without this, the identity-flip path that
# fires on every E2E login kills the chromium-driver session.
#
# We then override `beforeBuildCommand` to a no-op so Tauri does not
# clobber our dev-mode dist with a fresh prod-mode build.
echo "Building frontend (dev mode) for E2E..."
pnpm run build:app:e2e
TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false},"build":{"beforeBuildCommand":""}}'
# Tauri CLI maps env CI to --ci and only accepts true|false; some runners set CI=1.
case "${CI:-}" in 1) export CI=true ;; 0) export CI=false ;; esac
@@ -56,18 +67,18 @@ case "$OS" in
Linux)
# Linux: build debug binary only.
echo "Building for Linux (debug binary, no bundle)..."
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle -- --bin OpenHuman
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle --features e2e-test-support -- --bin OpenHuman
;;
Darwin)
# macOS: build .app bundle (wdio.conf points at
# src-tauri/target/debug/bundle/macos/OpenHuman.app).
echo "Building for macOS (.app bundle)..."
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug -- --bin OpenHuman
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug --features e2e-test-support -- --bin OpenHuman
;;
MINGW*|MSYS*|CYGWIN*|Windows_NT)
# Windows: bare .exe at src-tauri/target/debug/OpenHuman.exe.
echo "Building for Windows (.exe, no bundle)..."
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle -- --bin OpenHuman
cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle --features e2e-test-support -- --bin OpenHuman
;;
*)
echo "ERROR: unsupported OS for e2e build: $OS" >&2
+4
View File
@@ -177,6 +177,10 @@ default = []
# every `pnpm dev:app` will silently load the production bundle. DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]
sandbox-bubblewrap = []
# Forwarded to the core crate to expose `openhuman.test_reset`. Off by
# default; the E2E build flips it on via `cargo tauri build --features
# e2e-test-support`. See app/scripts/e2e-build.sh.
e2e-test-support = ["openhuman_core/e2e-test-support"]
[patch.crates-io]
# `cargo tauri build` resolves dependencies from this manifest, so the
+1
View File
@@ -154,6 +154,7 @@ vi.mock('../utils/config', () => ({
CORE_RPC_URL: 'http://127.0.0.1:7788/rpc',
CORE_RPC_TIMEOUT_MS: 30_000,
IS_DEV: true,
IS_DEV_LIKE: true,
IS_PROD: false,
DEV_FORCE_ONBOARDING: false,
SKILLS_GITHUB_REPO: 'test/skills',
+11
View File
@@ -62,6 +62,17 @@ export const CORE_RPC_TIMEOUT_MS = parseCoreRpcTimeoutMs();
export const IS_DEV = import.meta.env.DEV;
export const IS_PROD = import.meta.env.PROD;
/**
* True when the build behaves like a dev build for runtime purposes — either
* a real `vite dev` (DEV=true) or a `vite build --mode development` (the E2E
* harness — DEV=false but MODE='development'). `IS_DEV` alone is insufficient
* for the E2E case because `vite build` always sets PROD=true / DEV=false
* regardless of `--mode`. Consumers gating behavior that should NOT happen in
* shipped binaries (e.g. the `restartApp` reload-instead-of-restart path)
* should read this flag rather than touch `import.meta.env` directly.
*/
export const IS_DEV_LIKE = IS_DEV || import.meta.env.MODE === 'development';
/** Dev only: skip `.skip_onboarding` workspace check and ignore onboarded state so `/onboarding` always shows. Set `VITE_DEV_FORCE_ONBOARDING=true` in `.env.local`. */
export const DEV_FORCE_ONBOARDING =
import.meta.env.DEV && import.meta.env.VITE_DEV_FORCE_ONBOARDING === 'true';
+6 -8
View File
@@ -72,14 +72,12 @@ describe('tauriCommands/core', () => {
debug.mockRestore();
});
test('invokes restart_app in production mode (IS_DEV=false)', async () => {
// setup.ts globally mocks ../config with IS_DEV: true. Override with
// doMock + resetModules so a fresh import of ./core sees IS_DEV=false
// and runs the production branch (#1068 dev workaround should be inert).
vi.doMock('../config', () => ({
IS_DEV: false,
// Re-export anything else core.ts might end up using; today just IS_DEV.
}));
test('invokes restart_app in production mode (IS_DEV_LIKE=false)', async () => {
// setup.ts globally mocks ../config with IS_DEV_LIKE: true (because
// IS_DEV: true → derived IS_DEV_LIKE). Override with doMock +
// resetModules so a fresh import of ./core sees IS_DEV_LIKE=false and
// runs the production branch (#1068 dev workaround should be inert).
vi.doMock('../config', () => ({ IS_DEV: false, IS_DEV_LIKE: false }));
vi.resetModules();
const prodCore = await import('./core');
+7 -2
View File
@@ -4,7 +4,7 @@
import { invoke } from '@tauri-apps/api/core';
import { callCoreRpc } from '../../services/coreRpcClient';
import { IS_DEV } from '../config';
import { IS_DEV_LIKE } from '../config';
import { CommandResponse, isTauri } from './common';
export interface CoreUpdateStatus {
@@ -79,7 +79,12 @@ export async function restartApp(): Promise<void> {
console.debug('[app] restartApp: skipped — not running in Tauri');
return;
}
if (IS_DEV) {
// `IS_DEV_LIKE` is true for both `vite dev` (DEV=true) and the E2E build
// (`vite build --mode development` → DEV=false but MODE='development').
// Without the E2E case we'd hit the OS-level restart path in the packaged
// E2E binary and kill the WebDriver CDP target every time identity flips
// on login. See `app/src/utils/config.ts` for the canonical definition.
if (IS_DEV_LIKE) {
console.debug('[app] restartApp: dev mode → window.location.reload()');
window.location.reload();
return;
+48 -1
View File
@@ -349,7 +349,54 @@ export function buildBypassJwt(userId: string = 'e2e-user'): string {
return `${header}.${payload}.e2e`;
}
export function triggerAuthDeepLinkBypass(userId: string = 'e2e-user'): Promise<void> {
export async function triggerAuthDeepLinkBypass(userId: string = 'e2e-user'): Promise<void> {
// BootCheckGate sits in front of the route on fresh storage. The deep-link
// auth handler calls `waitForAuthReadiness`, which can't make progress
// while the gate is up — the call eventually fails with "Sign-in failed.
// Please try again." and the spec is wedged on the login screen. Dismiss
// the gate before triggering the deep link so the auth path can complete.
await dismissBootCheckGateIfVisibleInline().catch(err => {
deepLinkDebug('pre-deep-link BootCheckGate dismiss failed (continuing):', err);
});
const token = buildBypassJwt(userId);
return triggerDeepLink(`openhuman://auth?token=${encodeURIComponent(token)}&key=auth`);
}
/**
* Inlined BootCheckGate dismisser — shared-flows has a richer exported
* version, but importing it here would create a circular dependency
* (shared-flows imports `triggerAuthDeepLink` from this file).
*/
async function dismissBootCheckGateIfVisibleInline(timeoutMs = 8_000): Promise<boolean> {
if (typeof browser === 'undefined' || typeof browser.execute !== 'function') return false;
const deadline = Date.now() + timeoutMs;
let everSeen = false;
while (Date.now() < deadline) {
const status: string = await browser
.execute(() => {
const heading = Array.from(document.querySelectorAll('h2')).find(
h => (h.textContent ?? '').trim() === 'Choose core mode'
);
if (!heading) return 'gone';
const modal = (heading.closest('.fixed') ?? heading.parentElement) as Element | null;
if (!modal) return 'gone';
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>('button'));
const primary =
buttons.find(b => (b.textContent ?? '').trim() === 'Continue') ??
buttons.find(b => /bg-ocean-500/.test(b.className)) ??
buttons[buttons.length - 1];
if (!primary) return 'no-button';
['mousedown', 'mouseup', 'click'].forEach(type => {
primary.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0 })
);
});
return 'clicked';
})
.catch(() => 'error');
if (status === 'gone') return everSeen;
everSeen = true;
await browser.pause(800);
}
return everSeen;
}
+135
View File
@@ -0,0 +1,135 @@
/**
* E2E reset — bring the running app back to a fresh-install baseline.
*
* We keep ONE Appium session across the whole spec run (see wdio.conf.ts).
* Instead of restarting the sidecar between specs we call the in-place
* `openhuman.test_reset` RPC: it wipes the auth marker, clears the
* `chat_onboarding_completed` flag, removes all cron jobs and saves the
* config back. The renderer is then reloaded so Redux/localStorage are
* also flushed, and the spec re-authenticates via the deep-link bypass
* and walks onboarding through the real UI.
*
* Use this as the FIRST thing every spec does:
*
* before(async () => {
* await resetApp('e2e-<spec-name>');
* });
*
* Picking a unique `userId` per spec is what gives each spec its own
* mock-backend identity so request logs aren't cross-contaminated.
*/
import { waitForApp, waitForAppReady } from './app-helpers';
import { callOpenhumanRpc } from './core-rpc';
import { triggerAuthDeepLinkBypass } from './deep-link-helpers';
import { waitForWebView, waitForWindowVisible } from './element-helpers';
import { supportsExecuteScript } from './platform';
import { dismissBootCheckGateIfVisible, walkOnboarding } from './shared-flows';
interface ResetAppOptions {
/** Skip the auth + onboarding bootstrap. Use for specs that test the welcome/login screens themselves. */
skipAuth?: boolean;
/** Override the onboarding-walker log prefix. */
logPrefix?: string;
}
function stepLog(message: string): void {
console.log(`[resetApp][${new Date().toISOString()}] ${message}`);
}
/**
* Wipe sidecar + renderer state and (by default) re-auth + onboard.
*
* Order matters:
* 1. RPC wipe — must come BEFORE the reload, otherwise the renderer will
* re-hydrate Redux from persisted storage and immediately call out to
* a sidecar that still thinks the user is logged in.
* 2. Clear web storage + reload — the renderer's persisted Redux slices
* live in localStorage; if we don't drop them the welcome screen is
* skipped and we land back on /home with stale state.
* 3. Wait for the app to remount.
* 4. Re-auth via deep-link bypass + walk onboarding through the UI.
*
* Returns the userId that was authenticated (mirrors what the spec passed
* in) so callers can use it for mock-backend request-log assertions.
*/
export async function resetApp(userId: string, options: ResetAppOptions = {}): Promise<string> {
const logPrefix = options.logPrefix ?? '[resetApp]';
stepLog(`Calling openhuman.test_reset for ${userId}`);
// The sidecar only spawns after the first successful user login, so the
// very first spec of a run hits an unreachable RPC — that's not an error,
// a freshly-launched workspace is already in the same "pristine" state
// the wipe would have produced. Race the RPC call against a short budget
// and treat the result as a flag: did we actually wipe anything?
const reset = await Promise.race([
callOpenhumanRpc('openhuman.test_reset', {}),
new Promise<{ ok: false; error: string }>(resolve =>
setTimeout(
() =>
resolve({
ok: false,
error: 'test_reset RPC probe timed out (sidecar likely not started)',
}),
8_000
)
),
]);
let didWipe = false;
if (reset.ok) {
stepLog(`Sidecar wipe ok: ${JSON.stringify(reset.result)}`);
didWipe = true;
} else {
const errText = String(reset.error ?? '');
const unreachable =
errText.includes('not reachable') ||
errText.includes('probe timed out') ||
errText.includes('ECONNREFUSED');
if (!unreachable) {
throw new Error(`openhuman.test_reset failed: ${errText || JSON.stringify(reset)}`);
}
stepLog(`Sidecar not reachable (${errText}) — treating as fresh launch, skipping wipe`);
}
// Only reload the renderer when we actually wiped state — otherwise we
// throw away the in-app "Choose core mode" acceptance the app shell has
// already cleared, and end up wedged behind that modal on first launch.
if (didWipe && supportsExecuteScript()) {
stepLog('Clearing renderer storage + reloading webview');
await browser.execute(() => {
try {
window.localStorage.clear();
window.sessionStorage.clear();
} catch (err) {
console.warn('[resetApp] storage.clear failed', err);
}
window.location.replace('#/');
window.location.reload();
});
} else if (didWipe) {
stepLog('execute() unsupported — skipping renderer reload (state may be stale)');
} else {
stepLog('Skipping renderer reload — nothing was wiped');
}
await waitForApp();
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
await dismissBootCheckGateIfVisible();
if (options.skipAuth) {
stepLog('skipAuth=true — stopping before auth bypass');
return userId;
}
stepLog(`Triggering auth deep-link bypass for ${userId}`);
await triggerAuthDeepLinkBypass(userId);
await waitForAppReady(15_000);
// BootCheckGate may re-mount after the deep-link routes to /home; dismiss
// the modal again if it slid back into view.
await dismissBootCheckGateIfVisible(8_000);
await walkOnboarding(logPrefix);
stepLog('Reset + onboarding complete');
return userId;
}
+122 -36
View File
@@ -328,52 +328,138 @@ export async function waitForOnboardingOverlayHidden(timeout = 10_000): Promise<
}
/**
* Walk through onboarding: Welcome → Local AI → Screen & Accessibility → Tools → Skills.
* Each step uses the shared primary button label "Continue" (see OnboardingNextButton).
* Completing the last step dismisses the overlay.
* BootCheckGate shows a "Choose core mode" modal on fresh storage. It sits
* *in front of* the routed page, so onboarding never mounts behind it. We
* click the primary "Continue" CTA via a synthetic MouseEvent and retry
* until the modal is gone (a single click can race against the gate's
* re-render). Exported so specs that bypass `walkOnboarding` can still
* call this directly.
*/
export async function walkOnboarding(logPrefix = '[E2E]') {
let visible = false;
for (let attempt = 0; attempt < 8; attempt++) {
if (await onboardingOverlayLikelyVisible()) {
visible = true;
break;
}
await browser.pause(400);
export async function dismissBootCheckGateIfVisible(timeoutMs = 12_000): Promise<boolean> {
if (!supportsExecuteScript()) return false;
const deadline = Date.now() + timeoutMs;
let everSeen = false;
while (Date.now() < deadline) {
const status = await browser.execute(() => {
const heading = Array.from(document.querySelectorAll('h2')).find(
h => (h.textContent ?? '').trim() === 'Choose core mode'
);
if (!heading) return 'gone';
const modal = heading.closest('.fixed') ?? heading.parentElement;
if (!modal) return 'gone';
const buttons = Array.from(modal.querySelectorAll<HTMLButtonElement>('button'));
const primary =
buttons.find(b => (b.textContent ?? '').trim() === 'Continue') ??
buttons.find(b => /bg-ocean-500/.test(b.className)) ??
buttons[buttons.length - 1];
if (!primary) return 'visible-no-button';
['mousedown', 'mouseup', 'click'].forEach(type => {
primary.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0 })
);
});
return 'clicked';
});
if (status === 'gone') return everSeen;
everSeen = true;
await browser.pause(800);
}
return everSeen;
}
if (!visible) {
console.log(`${logPrefix} Onboarding overlay not visible — skipping`);
await browser.pause(1_000);
/**
* Walk through onboarding by advancing the `data-testid="onboarding-next-button"`
* until it unmounts. The button is rendered on every step (see
* app/src/pages/onboarding/components/OnboardingNextButton.tsx), so we don't
* need to track step *titles* — title-based detection silently skipped any
* step that wasn't in `ONBOARDING_OVERLAY_TEXTS` (e.g. "Connect your Gmail")
* and left specs wedged behind onboarding while reporting success.
*
* We dispatch a real synthetic MouseEvent so React's onClick fires reliably,
* and bail out if the button gets stuck in a permanently-disabled state.
*
* Dismisses BootCheckGate ("Choose core mode") first if it's blocking the
* route — onboarding sits behind it, so without this the walker just times
* out waiting for the next-button to mount.
*/
export async function walkOnboarding(logPrefix = '[E2E]', maxSteps = 12): Promise<void> {
if (!supportsExecuteScript()) {
// Mac2/no-script fallback: legacy "Continue" matcher, kept so the
// unsupported-driver branch isn't a hard failure for old harnesses.
const clicked = await clickFirstMatch(['Continue'], 3_000);
if (clicked) console.log(`${logPrefix} Onboarding: clicked Continue (legacy fallback)`);
return;
}
// Up to 6 "Continue" clicks — covers 5 steps plus one retry if the list is still loading.
for (let step = 0; step < 6; step++) {
if (!(await onboardingOverlayLikelyVisible())) {
console.log(`${logPrefix} Onboarding dismissed after step ${step}`);
// Onboarding mounts beneath BootCheckGate. If the user is fresh-installed
// the gate is up and onboarding will never render until we confirm it.
const dismissed = await dismissBootCheckGateIfVisible();
if (dismissed) {
console.log(`${logPrefix} Dismissed BootCheckGate before onboarding`);
await browser.pause(1_500);
}
// Wait up to 15s for the onboarding shell to actually mount. If the user is
// already onboarded (e.g. resuming an existing session) the button never
// appears and we return without firing any clicks.
const appeared = await browser
.waitUntil(
async () =>
Boolean(
await browser.execute(
() => document.querySelector('[data-testid="onboarding-next-button"]') !== null
)
),
{ timeout: 15_000, interval: 500, timeoutMsg: 'onboarding-next-button never appeared' }
)
.catch(() => false);
if (!appeared) {
console.log(`${logPrefix} Onboarding next-button never appeared — assuming already onboarded`);
return;
}
for (let step = 0; step < maxSteps; step += 1) {
const status = await browser.execute(() => {
const btn = document.querySelector<HTMLButtonElement>(
'[data-testid="onboarding-next-button"]'
);
const onOnboardingHash = window.location.hash.startsWith('#/onboarding');
if (!btn) return onOnboardingHash ? 'gone-but-onboarding-hash' : 'gone';
if (btn.disabled) return 'disabled';
['mousedown', 'mouseup', 'click'].forEach(type => {
btn.dispatchEvent(
new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0 })
);
});
return 'clicked';
});
if (status === 'gone') {
console.log(`${logPrefix} Onboarding dismissed after ${step} step(s)`);
return;
}
const clicked = await clickFirstMatch(['Continue'], 12_000);
if (clicked) {
console.log(`${logPrefix} Onboarding step ${step}: clicked Continue`);
await browser.pause(step >= 4 ? 4_000 : 2_000);
} else {
const installSkillsLabel = ONBOARDING_OVERLAY_TEXTS[ONBOARDING_OVERLAY_TEXTS.length - 1]!;
if (await textExists(installSkillsLabel)) {
await browser.pause(2_500);
const retry = await clickFirstMatch(['Continue'], 10_000);
if (retry) {
console.log(
`${logPrefix} Onboarding step ${step}: retry Continue on ${installSkillsLabel}`
);
await browser.pause(4_000);
}
}
break;
if (status === 'gone-but-onboarding-hash') {
// The button momentarily unmounts between steps (animation / lazy render).
// Don't claim victory yet — wait for the next step to render.
console.log(
`${logPrefix} Onboarding next-button absent but hash still on /onboarding — waiting`
);
await browser.pause(1_500);
continue;
}
if (status === 'disabled') {
// Some steps gate the button on async work (skill catalog fetch, local
// AI download check). Give it a beat, then retry; if it stays disabled
// for too long we bail rather than spinning forever.
console.log(`${logPrefix} Onboarding step ${step}: next-button disabled — waiting`);
await browser.pause(2_000);
continue;
}
console.log(`${logPrefix} Onboarding step ${step}: clicked Continue`);
await browser.pause(step >= 4 ? 3_000 : 1_500);
}
console.log(`${logPrefix} Onboarding hit max steps (${maxSteps}) — moving on`);
}
/**
+156 -176
View File
@@ -1,47 +1,40 @@
// @ts-nocheck
/**
* End-to-end: cron jobs across the full desktop stack.
* Reference E2E spec — Settings → Cron Jobs through real UI clicks.
*
* Covers the cross-process flow that unit tests cannot prove:
* UI (Settings → Cron Jobs panel) → coreRpcClient → Tauri core_rpc_relay → openhuman sidecar
* This file is the template every other E2E spec should follow:
*
* What this validates:
* 1. Completing onboarding triggers the sidecar's `seed_proactive_agents`
* side effect — the `morning_briefing` cron job must appear in `cron_list`
* without any explicit UI action (proves the post-onboarding hook wired to
* the cron seed ran in the real sidecar process, not just in isolation).
* 2. `cron_update` round-trips a patch through the sidecar and the persisted
* state is reflected on a fresh `cron_list`.
* 3. `cron_runs` on a never-run job returns an empty history (RPC shape).
* 4. `cron_remove` on an unknown id surfaces a structured error back to
* the WebView (tests the error path end-to-end; the webview client
* returns `{ ok: false, error }` rather than throwing).
* 5. The Settings → Cron Jobs panel renders after auth and shows the
* seeded morning_briefing job (UI ↔ core RPC sync).
* 1. ONE Appium session for the whole run (see wdio.conf.ts). We never
* restart the app between specs.
* 2. Each spec starts with `await resetApp(<unique userId>)` which calls
* the in-place `openhuman.test_reset` RPC, reloads the renderer, and
* walks the real onboarding UI. After that the app is in the same
* state a brand-new install would be in.
* 3. The rest of the spec drives the product through real UI: clicks on
* buttons, assertions on rendered text, navigation via the same
* affordances a user would tap. Direct RPC calls are reserved for
* *oracle* checks (verifying that a click actually persisted), not
* for setting up or driving state.
*
* Method naming note: controllers register as `namespace=cron, function=list`
* but the RPC method name is composed via `openhuman.{namespace}_{function}` —
* so the wire method is `openhuman.cron_list`, matching what the UI's
* `openhumanCronList` helper in app/src/utils/tauriCommands/cron.ts sends.
* What this validates end-to-end (UI → coreRpcClient → Tauri relay → sidecar):
* - `morning_briefing` is auto-seeded after onboarding completes.
* - The Cron Jobs settings panel renders the seeded job with its
* Pause / Run Now / View Runs / Remove affordances.
* - Clicking "Pause" flips the row to "Resume" AND the change persists
* across "Refresh Cron Jobs" — i.e. it went through the sidecar.
* - Clicking "Remove" makes the row disappear and the list shows the
* empty state. A final oracle `cron_list` RPC confirms the sidecar
* agrees, but the *test* drove everything via the buttons.
*/
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
import { waitForApp } from '../helpers/app-helpers';
import { callOpenhumanRpc } from '../helpers/core-rpc';
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
import {
dumpAccessibilityTree,
textExists,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import { supportsExecuteScript } from '../helpers/platform';
import {
completeOnboardingIfVisible,
navigateToSettings,
navigateViaHash,
} from '../helpers/shared-flows';
import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server';
import { clickButton, textExists, waitForText } from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import { navigateToSettings, navigateViaHash } from '../helpers/shared-flows';
import { startMockServer, stopMockServer } from '../mock-server';
const MORNING_BRIEFING_NAME = 'morning_briefing';
const USER_ID = 'e2e-cron-jobs';
const MORNING_BRIEFING = 'morning_briefing';
function stepLog(message: string, context?: unknown): void {
const stamp = new Date().toISOString();
@@ -52,168 +45,155 @@ function stepLog(message: string, context?: unknown): void {
console.log(`[CronJobsE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2));
}
interface CronJobMinimal {
id: string;
name?: string | null;
enabled: boolean;
}
/**
* RpcOutcome.into_cli_compatible_json wraps payloads as `{result: T, logs: [...]}`
* whenever logs are non-empty — every cron op emits at least one log line, so
* every cron RPC returns the wrapped shape. Mirror the `inner()` helper in
* tests/json_rpc_e2e.rs and fall through to the raw value if logs were absent.
*/
function innerPayload<T>(outer: unknown): T | undefined {
if (outer && typeof outer === 'object' && 'result' in (outer as object)) {
return (outer as { result?: T }).result;
}
return outer as T | undefined;
}
async function waitForSeededJob(
name: string,
timeoutMs = 15_000
): Promise<CronJobMinimal | undefined> {
/** Wait for an element matching one of several texts to be visible. */
async function waitForAnyText(candidates: string[], timeoutMs = 10_000): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
const list = await callOpenhumanRpc('openhuman.cron_list', {});
if (list.ok) {
const jobs = innerPayload<CronJobMinimal[]>(list.result) ?? [];
const match = Array.isArray(jobs) ? jobs.find(j => (j?.name ?? null) === name) : undefined;
if (match) return match;
} else {
lastError = list;
for (const text of candidates) {
if (await textExists(text)) return text;
}
await browser.pause(750);
await browser.pause(500);
}
if (lastError) {
stepLog('waitForSeededJob: last cron_list error', lastError);
}
return undefined;
return null;
}
describe('Cron jobs (UI + core RPC)', () => {
/** Click the action button (Pause | Resume | Remove | …) inside the morning_briefing row. */
async function clickActionForJob(jobName: string, action: string): Promise<boolean> {
return Boolean(
await browser.execute(
(name: string, label: string) => {
const rows = Array.from(document.querySelectorAll('div'))
.filter(div => /text-sm font-semibold text-stone-900/.test(div.className))
.filter(div => (div.textContent ?? '').trim() === name);
if (rows.length === 0) return false;
// Walk up to the panel row container (sibling-of-sibling structure in CoreJobList).
const container = rows[0]?.closest('div.p-4');
if (!container) return false;
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('button'));
const btn = buttons.find(b => (b.textContent ?? '').trim() === label);
if (!btn) return false;
btn.click();
return true;
},
jobName,
action
)
);
}
/** Poll for the in-row action button label to settle (e.g. "Pause" → "Resume"). */
async function waitForRowActionLabel(
jobName: string,
expected: string,
timeoutMs = 10_000
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = await browser.execute((name: string) => {
const rows = Array.from(document.querySelectorAll('div'))
.filter(div => /text-sm font-semibold text-stone-900/.test(div.className))
.filter(div => (div.textContent ?? '').trim() === name);
const container = rows[0]?.closest('div.p-4');
if (!container) return null;
const labels = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).map(b =>
(b.textContent ?? '').trim()
);
// We care about the toggle button (first one in the row).
return labels[0] ?? null;
}, jobName);
if (current === expected) return true;
await browser.pause(400);
}
return false;
}
/** Open the Cron Jobs settings panel via the same Settings entry-point a user clicks. */
async function openCronJobsPanel(): Promise<void> {
await navigateToSettings();
await browser.pause(800);
// The Cron Jobs panel is nested under Developer Options. Hash-nav is still
// a click-equivalent under the hood (the router handles the route change
// identically) — what matters for "real UI" is that the rendered panel is
// the one the user lands on, not how we got there.
await navigateViaHash('/settings/cron-jobs');
await waitForText('Cron Jobs', 10_000);
await waitForText('Scheduled Jobs', 5_000);
}
describe('Cron jobs settings panel (real UI flow)', () => {
before(async () => {
await startMockServer();
await waitForApp();
clearRequestLog();
await resetApp(USER_ID);
});
after(async () => {
await stopMockServer();
});
it('authenticates and completes onboarding (seeds morning_briefing)', async () => {
await triggerAuthDeepLinkBypass('e2e-cron-jobs');
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
await completeOnboardingIfVisible('[CronJobsE2E]');
const atHome =
(await textExists('Message OpenHuman')) ||
(await textExists('Good morning')) ||
(await textExists('Upgrade to Premium'));
expect(atHome).toBe(true);
it('completing onboarding lands the user on the home screen', async () => {
const home = await waitForAnyText(
['Message OpenHuman', 'Good morning', 'Good afternoon', 'Good evening', 'Upgrade to Premium'],
15_000
);
expect(home).toBeTruthy();
});
it('core.ping responds over the UI JSON-RPC bridge', async () => {
const ping = await callOpenhumanRpc('core.ping', {});
if (!ping.ok) stepLog('core.ping failed', ping);
expect(ping.ok).toBe(true);
});
it('cron_list surfaces the morning_briefing job seeded after onboarding', async () => {
// seed_proactive_agents runs in a detached spawn_blocking task — poll.
const seeded = await waitForSeededJob(MORNING_BRIEFING_NAME, 20_000);
if (!seeded) {
const snapshot = await callOpenhumanRpc('openhuman.cron_list', {});
stepLog('morning_briefing not found; latest cron_list snapshot', snapshot);
it('the seeded morning_briefing job appears in the Cron Jobs panel', async () => {
await openCronJobsPanel();
// The seed runs in a detached spawn_blocking task — poll for the row.
const present = await waitForAnyText([MORNING_BRIEFING], 20_000);
if (!present) {
stepLog('morning_briefing row never rendered — clicking Refresh and retrying');
await clickButton('Refresh Cron Jobs');
await browser.pause(1_500);
}
expect(seeded).toBeTruthy();
expect(typeof seeded?.id).toBe('string');
expect(seeded?.enabled === true || seeded?.enabled === false).toBe(true);
expect(await textExists(MORNING_BRIEFING)).toBe(true);
expect(await textExists('Enabled')).toBe(true);
});
it('cron_update round-trips an enabled=false patch through the sidecar', async () => {
const seeded = await waitForSeededJob(MORNING_BRIEFING_NAME, 10_000);
expect(seeded).toBeTruthy();
const originalEnabled = seeded!.enabled;
const target = !originalEnabled;
it('clicking Pause flips the row to Resume and persists across Refresh', async () => {
const startLabel = await waitForRowActionLabel(MORNING_BRIEFING, 'Pause', 5_000);
expect(startLabel).toBe(true);
const update = await callOpenhumanRpc('openhuman.cron_update', {
job_id: seeded!.id,
patch: { enabled: target },
const clicked = await clickActionForJob(MORNING_BRIEFING, 'Pause');
expect(clicked).toBe(true);
const flipped = await waitForRowActionLabel(MORNING_BRIEFING, 'Resume', 10_000);
expect(flipped).toBe(true);
expect(await textExists('Paused')).toBe(true);
// Real UI persistence proof: refresh re-reads from the sidecar.
await clickButton('Refresh Cron Jobs');
await browser.pause(1_500);
const stillResumed = await waitForRowActionLabel(MORNING_BRIEFING, 'Resume', 8_000);
expect(stillResumed).toBe(true);
// Restore so the next test starts from the enabled state.
const restored = await clickActionForJob(MORNING_BRIEFING, 'Resume');
expect(restored).toBe(true);
const back = await waitForRowActionLabel(MORNING_BRIEFING, 'Pause', 10_000);
expect(back).toBe(true);
});
it('clicking Remove deletes the job from both the UI and the sidecar', async () => {
const clicked = await clickActionForJob(MORNING_BRIEFING, 'Remove');
expect(clicked).toBe(true);
// UI assertion first — the row should disappear and the empty state appear.
const gone = await browser.waitUntil(async () => !(await textExists(MORNING_BRIEFING)), {
timeout: 10_000,
interval: 500,
timeoutMsg: 'morning_briefing row never disappeared',
});
if (!update.ok) stepLog('cron_update failed', update);
expect(update.ok).toBe(true);
const updated = innerPayload<CronJobMinimal>(update.result);
expect(updated?.id).toBe(seeded!.id);
expect(updated?.enabled).toBe(target);
expect(gone).toBe(true);
expect(await textExists('No core cron jobs found.')).toBe(true);
// Verify persistence across a fresh list call.
const reread = await callOpenhumanRpc('openhuman.cron_list', {});
expect(reread.ok).toBe(true);
const rereadJobs = innerPayload<CronJobMinimal[]>(reread.result) ?? [];
const after = rereadJobs.find(j => j.id === seeded!.id);
expect(after?.enabled).toBe(target);
// Restore the original state so subsequent specs/runs aren't poisoned.
const restore = await callOpenhumanRpc('openhuman.cron_update', {
job_id: seeded!.id,
patch: { enabled: originalEnabled },
});
expect(restore.ok).toBe(true);
});
it('cron_runs on a never-run job returns an empty array', async () => {
const seeded = await waitForSeededJob(MORNING_BRIEFING_NAME, 5_000);
expect(seeded).toBeTruthy();
const runs = await callOpenhumanRpc('openhuman.cron_runs', { job_id: seeded!.id, limit: 5 });
if (!runs.ok) stepLog('cron_runs failed', runs);
expect(runs.ok).toBe(true);
const history = innerPayload<unknown[]>(runs.result) ?? [];
expect(Array.isArray(history)).toBe(true);
// Fresh workspace — morning_briefing has not fired.
expect(history.length).toBe(0);
});
it('cron_remove on an unknown id surfaces an error via the RPC envelope', async () => {
const missing = await callOpenhumanRpc('openhuman.cron_remove', {
job_id: 'does-not-exist-e2e',
});
// The webview RPC envelope returns { ok:false, error } on JSON-RPC errors;
// the node fallback shape is the same (see core-rpc-webview / core-rpc-node).
expect(missing.ok).toBe(false);
const errText = String(missing.error ?? '');
expect(errText.length > 0).toBe(true);
});
it('Settings → Cron Jobs panel renders with the seeded job', async () => {
await navigateToSettings();
await browser.pause(1_000);
await navigateViaHash('/settings/cron-jobs');
await browser.pause(3_000);
if (supportsExecuteScript()) {
const hash = await browser.execute(() => window.location.hash);
expect(String(hash)).toContain('/settings/cron-jobs');
}
// The panel title or a morning_briefing marker should be visible.
const panelVisible =
(await textExists('Cron Jobs')) ||
(await textExists('Scheduled Jobs')) ||
(await textExists('Refresh Cron Jobs')) ||
(await textExists(MORNING_BRIEFING_NAME));
if (!panelVisible) {
stepLog('Cron Jobs panel markers missing');
await dumpAccessibilityTree();
stepLog('Request log (mock API):', getRequestLog());
}
expect(panelVisible).toBe(true);
// Single oracle RPC: confirm the sidecar agrees with the UI.
const list = await callOpenhumanRpc('openhuman.cron_list', {});
expect(list.ok).toBe(true);
const inner = (list.result as { result?: unknown } | undefined)?.result ?? list.result;
const jobs = Array.isArray(inner) ? inner : [];
expect(jobs.find((j: { name?: string }) => j?.name === MORNING_BRIEFING)).toBeUndefined();
});
});
+11
View File
@@ -182,6 +182,12 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::billing::all_billing_registered_controllers());
// Team and role management
controllers.extend(crate::openhuman::team::all_team_registered_controllers());
// E2E test support — `openhuman.test_reset` wipes sidecar state in-place.
// Gated behind the `e2e-test-support` cargo feature so shipped binaries
// never even register the destructive wipe RPC. Flipped on by the E2E
// build script (app/scripts/e2e-build.sh).
#[cfg(feature = "e2e-test-support")]
controllers.extend(crate::openhuman::test_support::all_test_support_registered_controllers());
// Local wallet metadata and onboarding status
controllers.extend(crate::openhuman::wallet::all_wallet_registered_controllers());
// Local assistive surfaces over third-party provider apps
@@ -279,6 +285,8 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::referral::all_referral_controller_schemas());
schemas.extend(crate::openhuman::billing::all_billing_controller_schemas());
schemas.extend(crate::openhuman::team::all_team_controller_schemas());
#[cfg(feature = "e2e-test-support")]
schemas.extend(crate::openhuman::test_support::all_test_support_controller_schemas());
schemas.extend(crate::openhuman::wallet::all_wallet_controller_schemas());
schemas.extend(crate::openhuman::provider_surfaces::all_provider_surfaces_controller_schemas());
schemas.extend(crate::openhuman::text_input::all_text_input_controller_schemas());
@@ -360,6 +368,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"referral" => Some("Referral codes, stats, and apply flows via the hosted backend API."),
"billing" => Some("Subscription plan, payment links, and credit top-up via the backend."),
"team" => Some("Team member management, invites, and role changes via the backend."),
"test" => Some(
"E2E test support — wipe sidecar state in-place between specs.",
),
"wallet" => Some("Local wallet onboarding status and derived multi-chain account metadata."),
"provider_surfaces" => Some(
"Local-first assistive surfaces for provider events, respond queues, and drafts.",
+2 -2
View File
@@ -20,8 +20,8 @@ pub use schemas::{
};
#[allow(unused_imports)]
pub use store::{
add_agent_job, add_agent_job_with_definition, add_job, add_shell_job, due_jobs, get_job,
list_jobs, list_runs, record_last_run, record_run, remove_job, reschedule_after_run,
add_agent_job, add_agent_job_with_definition, add_job, add_shell_job, clear_all_jobs, due_jobs,
get_job, list_jobs, list_runs, record_last_run, record_run, remove_job, reschedule_after_run,
update_job,
};
pub use types::{
+14
View File
@@ -183,6 +183,20 @@ pub fn remove_job(config: &Config, id: &str) -> Result<()> {
Ok(())
}
/// Deletes every cron job in the workspace. Returns the number of rows removed.
///
/// Intended for the `openhuman.test_reset` RPC used by E2E specs to wipe state
/// between tests without restarting the sidecar. The cron scheduler picks up
/// the empty table on its next tick — no in-memory cache to invalidate.
pub fn clear_all_jobs(config: &Config) -> Result<usize> {
let removed = with_connection(config, |conn| {
conn.execute("DELETE FROM cron_jobs", params![])
.context("Failed to clear cron jobs")
})?;
log::info!("[cron] cleared all cron jobs (removed {removed} rows)");
Ok(removed)
}
pub fn due_jobs(config: &Config, now: DateTime<Utc>) -> Result<Vec<CronJob>> {
let lim = i64::try_from(config.scheduler.max_tasks.max(1))
.context("Scheduler max_tasks overflows i64")?;
+2
View File
@@ -61,6 +61,8 @@ pub mod skills;
pub mod socket;
pub mod subconscious;
pub mod team;
#[cfg(feature = "e2e-test-support")]
pub mod test_support;
pub mod text_input;
pub mod threads;
pub mod tokenjuice;
+15
View File
@@ -0,0 +1,15 @@
//! Test-support domain — wipe-and-reset hooks for E2E specs.
//!
//! `openhuman.test_reset` is the one RPC E2E specs call between tests so the
//! running sidecar starts each spec from a pristine state without restarting
//! the process. As new domains add persistent state, extend `rpc::reset` to
//! wipe them too — every new domain that survives a `test_reset` is a leak
//! that will make specs interfere with each other.
pub mod rpc;
mod schemas;
pub use schemas::{
all_controller_schemas as all_test_support_controller_schemas,
all_registered_controllers as all_test_support_registered_controllers,
};
+103
View File
@@ -0,0 +1,103 @@
//! Implementation of `openhuman.test_reset` — wipes persistent state in-place.
//!
//! The reset deliberately mirrors what the user sees on a fresh install:
//! - no authenticated user (active_user.toml removed, api_key cleared)
//! - onboarding not yet completed (chat_onboarding_completed=false)
//! - no cron jobs (so the post-onboarding seed re-creates `morning_briefing`)
//!
//! It is intentionally in-process: the sidecar keeps running. Specs reload
//! the webview after this call so the renderer also starts from a blank slate.
use serde::Serialize;
use serde_json::json;
use crate::openhuman::config::Config;
use crate::openhuman::config::{clear_active_user, default_root_openhuman_dir};
use crate::openhuman::cron;
use crate::rpc::RpcOutcome;
/// Wipe summary returned to the caller for debug visibility.
#[derive(Debug, Serialize)]
pub struct ResetSummary {
pub cron_jobs_removed: usize,
pub onboarding_was_completed: bool,
pub api_key_was_set: bool,
pub active_user_cleared: bool,
}
/// Reset persistent state to the "fresh install" baseline.
///
/// Errors at any individual wipe step short-circuit and surface back to the
/// caller — partial resets are worse than a clear failure, because they let
/// downstream tests pass on contaminated state.
pub async fn reset() -> Result<RpcOutcome<ResetSummary>, String> {
log::debug!("[test_reset] entry");
let mut config = Config::load_or_init()
.await
.map_err(|e| format!("test_reset: failed to load config: {e}"))?;
log::trace!(
"[test_reset] config loaded — onboarding_completed={}, api_key_set={}",
config.chat_onboarding_completed,
config.api_key.is_some()
);
let onboarding_was_completed = config.chat_onboarding_completed;
let api_key_was_set = config.api_key.is_some();
log::debug!("[test_reset] step=wipe_cron start");
let cron_jobs_removed = cron::clear_all_jobs(&config)
.map_err(|e| format!("test_reset: cron wipe failed: {e:#}"))?;
log::debug!("[test_reset] step=wipe_cron ok removed={cron_jobs_removed}");
log::debug!("[test_reset] step=clear_config_fields start");
config.chat_onboarding_completed = false;
config.api_key = None;
config
.save()
.await
.map_err(|e| format!("test_reset: failed to save config: {e:#}"))?;
log::debug!("[test_reset] step=clear_config_fields ok");
log::debug!("[test_reset] step=clear_active_user start");
let root = default_root_openhuman_dir()
.map_err(|e| format!("test_reset: failed to resolve default root dir: {e:#}"))?;
clear_active_user(&root)
.map_err(|e| format!("test_reset: failed to clear active user: {e:#}"))?;
log::debug!(
"[test_reset] step=clear_active_user ok root={}",
root.display()
);
let summary = ResetSummary {
cron_jobs_removed,
onboarding_was_completed,
api_key_was_set,
active_user_cleared: true,
};
log::info!(
"[test_reset] wiped sidecar state: {}",
serde_json::to_string(&summary).unwrap_or_default()
);
Ok(RpcOutcome::new(
summary,
vec![
format!("removed {cron_jobs_removed} cron jobs"),
format!("chat_onboarding_completed: {onboarding_was_completed} → false"),
format!("api_key cleared (was set: {api_key_was_set})"),
"active_user.toml removed".to_string(),
],
))
}
/// Convenience helper for handlers that prefer a raw JSON envelope.
#[allow(dead_code)]
pub async fn reset_json() -> Result<serde_json::Value, String> {
let outcome = reset().await?;
Ok(json!({
"removed_cron_jobs": outcome.value.cron_jobs_removed,
"previously_onboarded": outcome.value.onboarding_was_completed,
"previously_authenticated": outcome.value.api_key_was_set,
}))
}
+88
View File
@@ -0,0 +1,88 @@
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::test_support::rpc;
use crate::rpc::RpcOutcome;
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schemas("reset")]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: schemas("reset"),
handler: handle_reset,
}]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"reset" => ControllerSchema {
namespace: "test",
function: "reset",
description:
"Wipe persistent sidecar state in-place: clears auth, onboarding, and cron jobs. \
E2E specs call this between tests so each starts from a fresh-install baseline.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "summary",
ty: TypeSchema::Object {
fields: vec![
FieldSchema {
name: "cron_jobs_removed",
ty: TypeSchema::U64,
comment: "Number of cron jobs deleted from the workspace database.",
required: true,
},
FieldSchema {
name: "onboarding_was_completed",
ty: TypeSchema::Bool,
comment: "Whether chat_onboarding_completed was true before the reset.",
required: true,
},
FieldSchema {
name: "api_key_was_set",
ty: TypeSchema::Bool,
comment: "Whether an api_key was present before the reset.",
required: true,
},
FieldSchema {
name: "active_user_cleared",
ty: TypeSchema::Bool,
comment: "Whether active_user.toml was successfully removed.",
required: true,
},
],
},
comment: "Summary of what was wiped.",
required: true,
}],
},
_other => ControllerSchema {
namespace: "test",
function: "unknown",
description: "Unknown test-support controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_reset(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move { to_json(rpc::reset().await?) })
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}