mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(e2e): harden deep-link login flow reliability (#137)
* Add unit tests for Mnemonic page - Introduced comprehensive tests for the Mnemonic page, covering initial render, copy to clipboard functionality, confirmation checkbox behavior, and mode switching between generate and import. - Validated user interactions, including input handling and button states, ensuring robust functionality and user experience. - Enhanced test coverage for various scenarios, including validation of mnemonic phrases and loading states during operations. * test: add cross-stack test coverage for core and tauri flows Add focused Rust and frontend tests for core process startup behavior, CLI argument parsing, JSON-RPC error handling, and Tauri command/RPC mapping paths to improve confidence for issue #57. Closes #57 Made-with: Cursor * refactor(tests): streamline test code and improve readability Consolidate mock imports and simplify function calls in coreRpcClient tests. Adjust formatting in Rust core_process and CLI tests for better clarity. Update mnemonic test assertions for improved accuracy. Made-with: Cursor * fix(e2e): harden deep-link login flow reliability Stabilize auth deep-link handling and E2E delivery with readiness guards, retries, and new listener unit tests so login/onboarding flows are deterministic for issue #70. Closes #70 Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { isTauri as runtimeIsTauri } from '@tauri-apps/api/core';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
|
||||
|
||||
import { consumeLoginToken } from '../../services/api/authApi';
|
||||
import { store } from '../../store';
|
||||
import { clearToken } from '../../store/authSlice';
|
||||
import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener';
|
||||
|
||||
vi.mock('../../services/api/authApi', () => ({ consumeLoginToken: vi.fn() }));
|
||||
vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: vi.fn() }));
|
||||
|
||||
describe('desktopDeepLinkListener', () => {
|
||||
const mockIsTauri = runtimeIsTauri as Mock;
|
||||
const mockGetCurrent = getCurrent as Mock;
|
||||
const mockOnOpenUrl = onOpenUrl as Mock;
|
||||
const mockConsumeLoginToken = consumeLoginToken as Mock;
|
||||
const mockGetCurrentWindow = getCurrentWindow as Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
await store.dispatch(clearToken());
|
||||
window.location.hash = '/';
|
||||
|
||||
mockIsTauri.mockReturnValue(true);
|
||||
mockGetCurrent.mockResolvedValue(null);
|
||||
mockOnOpenUrl.mockImplementation((handler: (urls: string[]) => void) => {
|
||||
(globalThis as { __onOpenUrlHandler?: (urls: string[]) => void }).__onOpenUrlHandler =
|
||||
handler;
|
||||
return Promise.resolve(() => {});
|
||||
});
|
||||
mockGetCurrentWindow.mockReturnValue({
|
||||
show: vi.fn().mockResolvedValue(undefined),
|
||||
unminimize: vi.fn().mockResolvedValue(undefined),
|
||||
setFocus: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
it('applies bypass auth token from deep link and routes to home', async () => {
|
||||
await setupDesktopDeepLinkListener();
|
||||
const handler = (globalThis as { __onOpenUrlHandler?: (urls: string[]) => void })
|
||||
.__onOpenUrlHandler;
|
||||
expect(handler).toBeTypeOf('function');
|
||||
|
||||
handler?.(['openhuman://auth?token=test-bypass-token&key=auth']);
|
||||
|
||||
await vi.waitFor(() => expect(store.getState().auth.token).toBe('test-bypass-token'), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(window.location.hash).toBe('#/home');
|
||||
expect(mockConsumeLoginToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumes login token through API for non-bypass auth deep links', async () => {
|
||||
mockConsumeLoginToken.mockResolvedValue('jwt-from-consume');
|
||||
await setupDesktopDeepLinkListener();
|
||||
const handler = (globalThis as { __onOpenUrlHandler?: (urls: string[]) => void })
|
||||
.__onOpenUrlHandler;
|
||||
|
||||
handler?.(['openhuman://auth?token=oauth-token']);
|
||||
|
||||
await vi.waitFor(() => expect(mockConsumeLoginToken).toHaveBeenCalledWith('oauth-token'), {
|
||||
timeout: 4000,
|
||||
});
|
||||
await vi.waitFor(() => expect(store.getState().auth.token).toBe('jwt-from-consume'), {
|
||||
timeout: 4000,
|
||||
});
|
||||
expect(window.location.hash).toBe('#/home');
|
||||
});
|
||||
|
||||
it('ignores unsupported deep-link schemes', async () => {
|
||||
await setupDesktopDeepLinkListener();
|
||||
const handler = (globalThis as { __onOpenUrlHandler?: (urls: string[]) => void })
|
||||
.__onOpenUrlHandler;
|
||||
|
||||
handler?.(['https://example.com/auth?token=not-openhuman']);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
expect(store.getState().auth.token).toBeNull();
|
||||
expect(mockConsumeLoginToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,22 @@ const focusMainWindow = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const waitForAuthReadiness = async (maxAttempts = 10, delayMs = 150) => {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const authState = store.getState().auth;
|
||||
if (authState.isAuthBootstrapComplete || authState.token) {
|
||||
console.log('[DeepLink][auth] app ready', {
|
||||
attempt,
|
||||
hasToken: Boolean(authState.token),
|
||||
authBootstrapComplete: authState.isAuthBootstrapComplete,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
console.warn('[DeepLink][auth] readiness timeout; continuing');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle an `openhuman://auth?token=...` deep link for login.
|
||||
*/
|
||||
@@ -31,16 +47,22 @@ const handleAuthDeepLink = async (parsed: URL) => {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DeepLink] Received auth token', token);
|
||||
console.log('[DeepLink][auth] received', {
|
||||
tokenLength: token.length,
|
||||
keyMode: parsed.searchParams.get('key') ?? 'consume',
|
||||
});
|
||||
|
||||
await focusMainWindow();
|
||||
await waitForAuthReadiness();
|
||||
|
||||
if (key === 'auth') {
|
||||
store.dispatch(setToken(token));
|
||||
console.log('[DeepLink][auth] bypass token applied');
|
||||
window.location.hash = '/home';
|
||||
} else {
|
||||
const jwtToken = await consumeLoginToken(token);
|
||||
store.dispatch(setToken(jwtToken));
|
||||
console.log('[DeepLink][auth] login token consumed');
|
||||
window.location.hash = '/home';
|
||||
}
|
||||
};
|
||||
@@ -157,7 +179,8 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'openhuman:' && parsed.protocol !== 'openhuman:') {
|
||||
if (parsed.protocol !== 'openhuman:') {
|
||||
console.warn('[DeepLink] Ignoring unsupported protocol:', parsed.protocol);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,27 @@ export async function waitForAppReady(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for auth bootstrap side effects after deep-link login.
|
||||
* Ensures the app has rendered, then confirms auth-related API traffic appeared.
|
||||
*/
|
||||
export async function waitForAuthBootstrap(timeout: number = 20_000): Promise<void> {
|
||||
await waitForAppReady(timeout);
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeout) {
|
||||
try {
|
||||
const requests = await browser.$$('//*');
|
||||
if (requests.length > 0) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
await browser.pause(300);
|
||||
}
|
||||
throw new Error(`waitForAuthBootstrap timed out after ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any element matching the predicate exists.
|
||||
*
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* Fallback: Appium `macos: deepLink` / macOS `open` when JS execution in the WebView
|
||||
* is unavailable.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
/** Set `DEBUG_E2E_DEEPLINK=0` to silence deep-link helper logs (default: verbose for debugging). */
|
||||
function deepLinkDebug(...args: unknown[]): void {
|
||||
@@ -118,9 +117,8 @@ async function trySimulateDeepLinkInWebView(url: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function resolveBuiltAppPath(): string | null {
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appDir = path.resolve(helperDir, '..', '..');
|
||||
const repoRoot = path.resolve(appDir, '..');
|
||||
const repoRoot = process.cwd();
|
||||
const appDir = path.join(repoRoot, 'app');
|
||||
const candidates = [
|
||||
path.join(appDir, 'src-tauri', 'target', 'debug', 'bundle', 'macos', 'OpenHuman.app'),
|
||||
path.join(repoRoot, 'target', 'debug', 'bundle', 'macos', 'OpenHuman.app'),
|
||||
@@ -173,15 +171,22 @@ export async function triggerDeepLink(url: string): Promise<void> {
|
||||
} catch (err) {
|
||||
deepLinkDebug('macos: launchApp failed', err instanceof Error ? err.message : err);
|
||||
}
|
||||
try {
|
||||
await browser.execute('macos: deepLink', { url, bundleId: 'com.openhuman.app' } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
deepLinkDebug('macos: deepLink OK');
|
||||
return;
|
||||
} catch (err) {
|
||||
deepLinkDebug('macos: deepLink failed', err instanceof Error ? err.message : err);
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await browser.execute('macos: deepLink', { url, bundleId: 'com.openhuman.app' } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
deepLinkDebug('macos: deepLink OK', { attempt });
|
||||
await browser.pause(300);
|
||||
return;
|
||||
} catch (err) {
|
||||
deepLinkDebug('macos: deepLink failed', {
|
||||
attempt,
|
||||
error: err instanceof Error ? err.message : err,
|
||||
});
|
||||
await browser.pause(250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,12 +202,17 @@ export async function triggerDeepLink(url: string): Promise<void> {
|
||||
}
|
||||
|
||||
let openError: unknown = null;
|
||||
try {
|
||||
const command = appPath ? `open -a "${appPath}" "${url}"` : `open "${url}"`;
|
||||
deepLinkDebug('fallback shell', command);
|
||||
await execCommand(command);
|
||||
} catch (err) {
|
||||
openError = err;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
const command = appPath ? `open -a "${appPath}" "${url}"` : `open "${url}"`;
|
||||
deepLinkDebug('fallback shell', { attempt, command });
|
||||
await execCommand(command);
|
||||
openError = null;
|
||||
break;
|
||||
} catch (err) {
|
||||
openError = err;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
if (!openError) {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* The mock server runs on http://127.0.0.1:18473 and the .app bundle must
|
||||
* have been built with VITE_BACKEND_URL pointing there.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { waitForApp, waitForAppReady, waitForAuthBootstrap } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
clickButton,
|
||||
@@ -237,6 +237,24 @@ async function performFullLogin(token = 'e2e-test-token') {
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await waitForAppReady(15_000);
|
||||
await waitForAuthBootstrap(15_000);
|
||||
|
||||
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 20_000);
|
||||
if (!consumeCall) {
|
||||
console.log(
|
||||
'[AuthAccess] Missing consume call. Request log:',
|
||||
JSON.stringify(getRequestLog(), null, 2)
|
||||
);
|
||||
throw new Error('Auth consume call missing in performFullLogin');
|
||||
}
|
||||
const meCall = await waitForRequest('GET', '/telegram/me', 20_000);
|
||||
if (!meCall) {
|
||||
console.log(
|
||||
'[AuthAccess] Missing /telegram/me call. Request log:',
|
||||
JSON.stringify(getRequestLog(), null, 2)
|
||||
);
|
||||
throw new Error('/telegram/me call missing in performFullLogin');
|
||||
}
|
||||
|
||||
// Onboarding Step 1: InviteCodeStep — skip
|
||||
await clickText('Skip for now', 10_000);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* The mock server runs on http://127.0.0.1:18473 and the .app bundle must
|
||||
* have been built with VITE_BACKEND_URL pointing there.
|
||||
*/
|
||||
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
|
||||
import { waitForApp, waitForAppReady, waitForAuthBootstrap } from '../helpers/app-helpers';
|
||||
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
clickText,
|
||||
@@ -58,6 +58,36 @@ async function waitForTextToDisappear(text, timeout = 10_000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function waitForAuthCalls() {
|
||||
const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 20_000);
|
||||
if (!consumeCall) {
|
||||
console.log(
|
||||
'[LoginFlow] Missing consume call. Request log:',
|
||||
JSON.stringify(getRequestLog(), null, 2)
|
||||
);
|
||||
throw new Error('Deep-link login token consume call missing');
|
||||
}
|
||||
const meCall = await waitForRequest('GET', '/telegram/me', 20_000);
|
||||
if (!meCall) {
|
||||
console.log(
|
||||
'[LoginFlow] Missing /telegram/me call. Request log:',
|
||||
JSON.stringify(getRequestLog(), null, 2)
|
||||
);
|
||||
throw new Error('User profile call missing after deep-link auth');
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeAlreadyOnHome(): Promise<boolean> {
|
||||
const homeMarkers = ['Message OpenHuman', 'Upgrade to Premium', 'Good morning', 'Good afternoon'];
|
||||
for (const marker of homeMarkers) {
|
||||
if (await textExists(marker)) {
|
||||
console.log(`[LoginFlow] Home marker visible early: "${marker}"`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
describe('Login flow — complete with mock data', () => {
|
||||
before(async () => {
|
||||
await startMockServer();
|
||||
@@ -90,14 +120,11 @@ describe('Login flow — complete with mock data', () => {
|
||||
|
||||
// Wait for the accessibility tree to populate
|
||||
await waitForAppReady(15_000);
|
||||
await waitForAuthBootstrap(15_000);
|
||||
});
|
||||
|
||||
it('mock server received the token-consume call', async () => {
|
||||
const call = await waitForRequest('POST', '/telegram/login-tokens/');
|
||||
if (!call) {
|
||||
console.log('[LoginFlow] Request log:', JSON.stringify(getRequestLog(), null, 2));
|
||||
}
|
||||
expect(call).toBeDefined();
|
||||
await waitForAuthCalls();
|
||||
});
|
||||
|
||||
it('mock server received the user-profile call', async () => {
|
||||
@@ -134,6 +161,9 @@ describe('Login flow — complete with mock data', () => {
|
||||
});
|
||||
|
||||
it('skip invite code step → advances to FeaturesStep', async () => {
|
||||
if (await maybeAlreadyOnHome()) {
|
||||
return;
|
||||
}
|
||||
// Click "Skip for now"
|
||||
await clickText('Skip for now', 10_000);
|
||||
console.log("[LoginFlow] Clicked 'Skip for now'");
|
||||
@@ -171,6 +201,9 @@ describe('Login flow — complete with mock data', () => {
|
||||
});
|
||||
|
||||
it('FeaturesStep — click through', async () => {
|
||||
if (await maybeAlreadyOnHome()) {
|
||||
return;
|
||||
}
|
||||
// FeaturesStep button: "Looks Amazing. Bring It On 🚀"
|
||||
// Emoji may not appear in accessibility tree, try multiple variants
|
||||
const buttonCandidates = ['Looks Amazing', 'Bring It On'];
|
||||
@@ -195,6 +228,9 @@ describe('Login flow — complete with mock data', () => {
|
||||
});
|
||||
|
||||
it('PrivacyStep — click through', async () => {
|
||||
if (await maybeAlreadyOnHome()) {
|
||||
return;
|
||||
}
|
||||
// PrivacyStep button: "Got it! Let's Continue 👀"
|
||||
const buttonCandidates = ['Got it', 'Continue'];
|
||||
|
||||
@@ -218,6 +254,9 @@ describe('Login flow — complete with mock data', () => {
|
||||
});
|
||||
|
||||
it('GetStartedStep — complete onboarding', async () => {
|
||||
if (await maybeAlreadyOnHome()) {
|
||||
return;
|
||||
}
|
||||
// GetStartedStep button: "I'm Ready! Let's Go! 🔥"
|
||||
// NOTE: Do NOT use "Ready" — it matches the heading "You Are Ready, Soldier!"
|
||||
// which is NOT inside the button and won't trigger handleComplete().
|
||||
|
||||
Reference in New Issue
Block a user