mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(e2e): make the harness actually exercise the UI past login (#1859)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user