mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(e2e): cover runtime picker → login → onboarding → logout (#1982)
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* E2E test: Runtime picker → provider login → onboarding/home → logout.
|
||||
*
|
||||
* Exercises the *first-launch login funnel* end-to-end against the shared
|
||||
* mock backend, running on the unified Appium chromium-driver session (CEF
|
||||
* over CDP) — the same harness CI uses for Linux in `e2e/docker-compose.yml`.
|
||||
*
|
||||
* Phase 1 — Runtime picker (BootCheckGate ModePicker):
|
||||
* 1. Reset to Welcome (no auth), then click "Select a Runtime" so
|
||||
* Welcome dispatches `resetCoreMode()` and the BootCheckGate
|
||||
* re-renders the picker.
|
||||
* 2. Verify both runtime options ("Run Locally", "Run on the Cloud")
|
||||
* plus the picker heading are present.
|
||||
* 3. Cloud branch:
|
||||
* - URL/token inputs appear when cloud is selected.
|
||||
* - Empty URL on Continue → inline URL error.
|
||||
* - Empty token → inline token error.
|
||||
* - Unreachable host on "Test Connection" → unreachable status pill
|
||||
* (no auth backend at 127.0.0.1:1 / picks a closed port).
|
||||
* 4. Switch back to Local, click Continue. BootCheckGate runs `runBootCheck`
|
||||
* against the embedded local core (which is already up — the e2e build
|
||||
* seeds `VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE=local`) and we land back
|
||||
* on Welcome with the OAuth provider row visible.
|
||||
*
|
||||
* Phase 2 — Provider login (deep-link bypass simulates the OAuth round-trip):
|
||||
* 5. Welcome shows OAuth provider buttons. We don't click them (that opens
|
||||
* the system browser), instead we simulate the post-OAuth deep link
|
||||
* callback — exactly the same code path the real providers exercise
|
||||
* when the backend redirects back to `openhuman://auth?token=...&key=auth`.
|
||||
* 6. Walk onboarding (if shown) until we reach Home.
|
||||
* 7. Verify mock backend recorded the auth/me profile fetch.
|
||||
*
|
||||
* Phase 3 — Logout:
|
||||
* 8. Logout from Settings.
|
||||
* 9. Confirm we're back on Welcome (logged-out state visible).
|
||||
*
|
||||
* The mock server (scripts/mock-api-*) handles auth + profile + onboarding.
|
||||
* Deep links go through `window.__simulateDeepLink` so the spec is safe on
|
||||
* the headless Linux container — no system browser, no real OAuth round-trip,
|
||||
* and no PID-bound URL handler is touched.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady, waitForAuthBootstrap } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
dumpAccessibilityTree,
|
||||
hasAppChrome,
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { supportsExecuteScript } from '../helpers/platform';
|
||||
import { resetApp } from '../helpers/reset-app';
|
||||
import {
|
||||
logoutViaSettings,
|
||||
waitForHomePage,
|
||||
waitForLoggedOutState,
|
||||
waitForRequest,
|
||||
walkOnboarding,
|
||||
} from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
setMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[RuntimePicker]';
|
||||
|
||||
/**
|
||||
* Click the smallest clickable element whose textContent contains `text`.
|
||||
*
|
||||
* Picker option tiles have a title + description nested in a single button so
|
||||
* the button's textContent is `<title><description>` — strict equality misses.
|
||||
* We score by descendant count to prefer the most-specific match (e.g. the
|
||||
* Continue button text "Continue" matches several ancestors; we want the
|
||||
* <button> itself, not <body>).
|
||||
*/
|
||||
async function clickByTextDom(text: string): Promise<boolean> {
|
||||
if (!supportsExecuteScript()) return false;
|
||||
return browser.execute(t => {
|
||||
const all = Array.from(document.querySelectorAll<HTMLElement>('button, [role="button"], a'));
|
||||
const matches = all.filter(el => (el.textContent ?? '').includes(t));
|
||||
if (matches.length === 0) return false;
|
||||
matches.sort((a, b) => a.querySelectorAll('*').length - b.querySelectorAll('*').length);
|
||||
const clickable = matches[0];
|
||||
['mousedown', 'mouseup', 'click'].forEach(type => {
|
||||
clickable.dispatchEvent(
|
||||
new MouseEvent(type, { bubbles: true, cancelable: true, view: window, button: 0 })
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}, text);
|
||||
}
|
||||
|
||||
/** Set value of a controlled input by selector + dispatch React change. */
|
||||
async function fillInput(selector: string, value: string): Promise<boolean> {
|
||||
if (!supportsExecuteScript()) return false;
|
||||
return browser.execute(
|
||||
({ sel, val }) => {
|
||||
const input = document.querySelector(sel) as HTMLInputElement | null;
|
||||
if (!input) return false;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
'value'
|
||||
)?.set;
|
||||
setter?.call(input, val);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return true;
|
||||
},
|
||||
{ sel: selector, val: value }
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the BootCheckGate ModePicker by clicking Welcome's "Select a Runtime". */
|
||||
async function openRuntimePicker(): Promise<boolean> {
|
||||
// The button text comes from `welcome.selectRuntime` = "Select a Runtime".
|
||||
// We can't disambiguate from the picker heading by text alone, so we trigger
|
||||
// through Welcome's button and then assert *picker-only* markers (the option
|
||||
// tiles) to confirm we landed on the picker phase.
|
||||
const clicked = await clickByTextDom('Select a Runtime');
|
||||
if (!clicked) return false;
|
||||
await browser.pause(1_000);
|
||||
return Boolean(
|
||||
(await waitForText('Run Locally (Recommended)', 8_000).catch(() => false)) ||
|
||||
(await textExists('Run on the Cloud (Complex)'))
|
||||
);
|
||||
}
|
||||
|
||||
describe('Runtime picker → login → onboarding → home → logout', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
await waitForApp();
|
||||
resetMockBehavior();
|
||||
setMockBehavior('composioConnections', '[]');
|
||||
// skipAuth so we land on Welcome (logged out) — the spec drives login itself.
|
||||
await resetApp('e2e-runtime-picker-login', { skipAuth: true });
|
||||
clearRequestLog();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
resetMockBehavior();
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 1: Runtime picker
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('app is running and shows Welcome with OAuth providers', async () => {
|
||||
expect(await hasAppChrome()).toBe(true);
|
||||
await waitForWindowVisible(20_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
|
||||
// Welcome.tsx: "Welcome to OpenHuman" + at least one provider button.
|
||||
expect(await waitForText('Welcome to OpenHuman', 15_000)).toBeTruthy();
|
||||
expect(await textExists('Select a Runtime')).toBe(true);
|
||||
});
|
||||
|
||||
it('clicking "Select a Runtime" opens the runtime picker with both options', async () => {
|
||||
const opened = await openRuntimePicker();
|
||||
if (!opened) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(`${LOG} Picker did not open. Tree:\n`, tree.slice(0, 4000));
|
||||
}
|
||||
expect(opened).toBe(true);
|
||||
|
||||
expect(await textExists('Run Locally (Recommended)')).toBe(true);
|
||||
expect(await textExists('Run on the Cloud (Complex)')).toBe(true);
|
||||
});
|
||||
|
||||
it('cloud option reveals URL + token inputs and validates them', async () => {
|
||||
// Click the cloud tile.
|
||||
const clickedCloud = await clickByTextDom('Run on the Cloud (Complex)');
|
||||
expect(clickedCloud).toBe(true);
|
||||
await browser.pause(500);
|
||||
|
||||
// URL field shows up.
|
||||
expect(await textExists('Runtime URL')).toBe(true);
|
||||
expect(await textExists('Auth Token')).toBe(true);
|
||||
|
||||
// Continue with empty URL → URL error inline.
|
||||
const continueClicked = await clickByTextDom('Continue');
|
||||
expect(continueClicked).toBe(true);
|
||||
await browser.pause(500);
|
||||
expect(await textExists('Please enter a runtime URL.')).toBe(true);
|
||||
|
||||
// Fill URL but leave token empty → token error.
|
||||
const urlOk = await fillInput('input[type="url"]', 'http://127.0.0.1:1/rpc');
|
||||
expect(urlOk).toBe(true);
|
||||
await clickByTextDom('Continue');
|
||||
await browser.pause(500);
|
||||
expect(await textExists("We'll need an auth token to connect.")).toBe(true);
|
||||
});
|
||||
|
||||
it('"Test Connection" against an unreachable host shows the unreachable pill', async () => {
|
||||
// Token already required; supply something + a deliberately closed port.
|
||||
const tokenOk = await fillInput('input[type="password"]', 'bad-token-e2e');
|
||||
expect(tokenOk).toBe(true);
|
||||
|
||||
const clicked = await clickByTextDom('Test Connection');
|
||||
expect(clicked).toBe(true);
|
||||
|
||||
// Either "auth failed" (if something happens to respond) or unreachable.
|
||||
// Both prove the test path actually fired. Poll up to 20s — chromium-driver
|
||||
// can sit on the connect timeout for a while before failing.
|
||||
const deadline = Date.now() + 20_000;
|
||||
let saw = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (
|
||||
(await textExists("Couldn't reach it:")) ||
|
||||
(await textExists("That token didn't work. Double-check it and try again."))
|
||||
) {
|
||||
saw = true;
|
||||
break;
|
||||
}
|
||||
await browser.pause(500);
|
||||
}
|
||||
if (!saw) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(`${LOG} No test-connection result. Tree:\n`, tree.slice(0, 4000));
|
||||
}
|
||||
expect(saw).toBe(true);
|
||||
});
|
||||
|
||||
it('switching back to Local and clicking Continue closes the picker', async () => {
|
||||
expect(await clickByTextDom('Run Locally (Recommended)')).toBe(true);
|
||||
await browser.pause(500);
|
||||
expect(await clickByTextDom('Continue')).toBe(true);
|
||||
|
||||
// BootCheckGate flips to 'checking' then 'match' against the in-process
|
||||
// local core. Eventually we either land on Welcome (still logged out) or
|
||||
// — if onboarding state leaked — on the onboarding overlay. Either is a
|
||||
// valid post-picker state; we only care that the picker is gone.
|
||||
const deadline = Date.now() + 25_000;
|
||||
let pickerGone = false;
|
||||
while (Date.now() < deadline) {
|
||||
const stillThere =
|
||||
(await textExists('Run Locally (Recommended)')) ||
|
||||
(await textExists('Run on the Cloud (Complex)'));
|
||||
if (!stillThere) {
|
||||
pickerGone = true;
|
||||
break;
|
||||
}
|
||||
await browser.pause(500);
|
||||
}
|
||||
if (!pickerGone) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(`${LOG} Picker did not dismiss. Tree:\n`, tree.slice(0, 4000));
|
||||
}
|
||||
expect(pickerGone).toBe(true);
|
||||
|
||||
// We should be back on Welcome (logged-out marker).
|
||||
const back = await waitForLoggedOutState(15_000);
|
||||
expect(back).not.toBeNull();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 2: Provider login (bypass deep link simulates the OAuth callback)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('OAuth provider buttons render on Welcome', async () => {
|
||||
// Real OAuth opens a system browser — out of scope for headless CI. We
|
||||
// just assert the buttons mount; the deep-link callback below covers the
|
||||
// post-OAuth path.
|
||||
const providerButtonPresent = await browser.execute(() => {
|
||||
const buttons = Array.from(document.querySelectorAll('button'));
|
||||
return buttons.some(b => {
|
||||
const label = b.getAttribute('aria-label') || b.textContent || '';
|
||||
return /Google|GitHub|Twitter|Discord/i.test(label);
|
||||
});
|
||||
});
|
||||
expect(providerButtonPresent).toBe(true);
|
||||
});
|
||||
|
||||
it('deep-link auth callback signs the user in and reaches Home', async () => {
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass('e2e-runtime-picker-user');
|
||||
await waitForWindowVisible(20_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await waitForAuthBootstrap(20_000);
|
||||
|
||||
// The bypass path does not call the token-consume endpoint (it sets the
|
||||
// JWT directly) — that's by design. The /auth/me lookup MUST still fire.
|
||||
const meCall = await waitForRequest(getRequestLog, 'GET', '/auth/me', 20_000);
|
||||
if (!meCall) {
|
||||
console.log(`${LOG} /auth/me not seen. Log:`, JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(meCall).toBeTruthy();
|
||||
|
||||
// Walk through onboarding if it's shown (new user path); a returning user
|
||||
// would skip directly to Home. walkOnboarding is a no-op when there's no
|
||||
// onboarding-next-button mounted.
|
||||
await walkOnboarding(LOG);
|
||||
|
||||
// Confirm we're authenticated + post-onboarding. waitForHomePage's
|
||||
// hardcoded greeting strings (Good morning / Test / etc.) can miss
|
||||
// valid Home renders, so fall back to a route + welcome-gone check.
|
||||
const home = await waitForHomePage(15_000);
|
||||
if (home) {
|
||||
console.log(`${LOG} Home reached: "${home}"`);
|
||||
} else {
|
||||
const deadline = Date.now() + 15_000;
|
||||
let onHome = false;
|
||||
while (Date.now() < deadline) {
|
||||
const hash = (await browser.execute(() => window.location.hash)) as string;
|
||||
const stillOnWelcome = await textExists('Welcome to OpenHuman');
|
||||
if (!stillOnWelcome && (hash.startsWith('#/home') || hash.startsWith('#/chat'))) {
|
||||
onHome = true;
|
||||
break;
|
||||
}
|
||||
await browser.pause(500);
|
||||
}
|
||||
if (!onHome) {
|
||||
const tree = await dumpAccessibilityTree();
|
||||
console.log(`${LOG} Home not reached. Tree:\n`, tree.slice(0, 4000));
|
||||
}
|
||||
expect(onHome).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Phase 3: Logout returns to Welcome
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('logout from Settings returns the user to Welcome', async () => {
|
||||
await logoutViaSettings(LOG);
|
||||
|
||||
// logoutViaSettings already asserts the logged-out marker; double-check
|
||||
// the Welcome OAuth row reappeared so we know the route reset cleanly.
|
||||
expect(await waitForText('Welcome to OpenHuman', 15_000)).toBeTruthy();
|
||||
expect(await textExists('Select a Runtime')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user