diff --git a/Cargo.toml b/Cargo.toml index 463506040..6a62aaada 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/app/package.json b/app/package.json index caa92cf92..bcb415827 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/scripts/e2e-build.sh b/app/scripts/e2e-build.sh index 292407547..d7e6ff008 100755 --- a/app/scripts/e2e-build.sh +++ b/app/scripts/e2e-build.sh @@ -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 diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 337412b9f..03c76c468 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -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 diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index 758fcc159..9265e0ee4 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -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', diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts index 571c7d129..074e07056 100644 --- a/app/src/utils/config.ts +++ b/app/src/utils/config.ts @@ -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'; diff --git a/app/src/utils/tauriCommands/core.test.ts b/app/src/utils/tauriCommands/core.test.ts index a2feb2174..89b4e1dad 100644 --- a/app/src/utils/tauriCommands/core.test.ts +++ b/app/src/utils/tauriCommands/core.test.ts @@ -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'); diff --git a/app/src/utils/tauriCommands/core.ts b/app/src/utils/tauriCommands/core.ts index 9ad005281..a4b196d6b 100644 --- a/app/src/utils/tauriCommands/core.ts +++ b/app/src/utils/tauriCommands/core.ts @@ -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 { 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; diff --git a/app/test/e2e/helpers/deep-link-helpers.ts b/app/test/e2e/helpers/deep-link-helpers.ts index 8b90b0027..541117478 100644 --- a/app/test/e2e/helpers/deep-link-helpers.ts +++ b/app/test/e2e/helpers/deep-link-helpers.ts @@ -349,7 +349,54 @@ export function buildBypassJwt(userId: string = 'e2e-user'): string { return `${header}.${payload}.e2e`; } -export function triggerAuthDeepLinkBypass(userId: string = 'e2e-user'): Promise { +export async function triggerAuthDeepLinkBypass(userId: string = 'e2e-user'): Promise { + // 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 { + 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('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; +} diff --git a/app/test/e2e/helpers/reset-app.ts b/app/test/e2e/helpers/reset-app.ts new file mode 100644 index 000000000..7439922e0 --- /dev/null +++ b/app/test/e2e/helpers/reset-app.ts @@ -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-'); + * }); + * + * 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 { + 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; +} diff --git a/app/test/e2e/helpers/shared-flows.ts b/app/test/e2e/helpers/shared-flows.ts index 82e2237ae..202113111 100644 --- a/app/test/e2e/helpers/shared-flows.ts +++ b/app/test/e2e/helpers/shared-flows.ts @@ -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 { + 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('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 { + 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( + '[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`); } /** diff --git a/app/test/e2e/specs/cron-jobs-flow.spec.ts b/app/test/e2e/specs/cron-jobs-flow.spec.ts index 95b6173a7..0215a620d 100644 --- a/app/test/e2e/specs/cron-jobs-flow.spec.ts +++ b/app/test/e2e/specs/cron-jobs-flow.spec.ts @@ -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()` 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(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 { +/** Wait for an element matching one of several texts to be visible. */ +async function waitForAnyText(candidates: string[], timeoutMs = 10_000): Promise { 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(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 { + 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('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 { + 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('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 { + 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(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(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(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(); }); }); diff --git a/src/core/all.rs b/src/core/all.rs index d9d48251f..80829039a 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -182,6 +182,12 @@ fn build_registered_controllers() -> Vec { 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 { 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.", diff --git a/src/openhuman/cron/mod.rs b/src/openhuman/cron/mod.rs index c5bb91c46..d1fd69b1e 100644 --- a/src/openhuman/cron/mod.rs +++ b/src/openhuman/cron/mod.rs @@ -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::{ diff --git a/src/openhuman/cron/store.rs b/src/openhuman/cron/store.rs index c66d54db1..36a2e9114 100644 --- a/src/openhuman/cron/store.rs +++ b/src/openhuman/cron/store.rs @@ -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 { + 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) -> Result> { let lim = i64::try_from(config.scheduler.max_tasks.max(1)) .context("Scheduler max_tasks overflows i64")?; diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index ae544b6e1..8d7b132eb 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -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; diff --git a/src/openhuman/test_support/mod.rs b/src/openhuman/test_support/mod.rs new file mode 100644 index 000000000..47d4969d1 --- /dev/null +++ b/src/openhuman/test_support/mod.rs @@ -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, +}; diff --git a/src/openhuman/test_support/rpc.rs b/src/openhuman/test_support/rpc.rs new file mode 100644 index 000000000..04a4fa352 --- /dev/null +++ b/src/openhuman/test_support/rpc.rs @@ -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, 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 { + 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, + })) +} diff --git a/src/openhuman/test_support/schemas.rs b/src/openhuman/test_support/schemas.rs new file mode 100644 index 000000000..8cb1c955b --- /dev/null +++ b/src/openhuman/test_support/schemas.rs @@ -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 { + vec![schemas("reset")] +} + +pub fn all_registered_controllers() -> Vec { + 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) -> ControllerFuture { + Box::pin(async move { to_json(rpc::reset().await?) }) +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +}