Files
openhuman/app/test/playwright/helpers/core-rpc.ts
T

287 lines
9.9 KiB
TypeScript

import { expect, type Page } from '@playwright/test';
const CORE_RPC_URL = process.env.PW_CORE_RPC_URL || 'http://127.0.0.1:17788/rpc';
const CORE_RPC_TOKEN = process.env.PW_CORE_RPC_TOKEN || 'openhuman-playwright-token';
const AUTH_CALLBACK_HOME_TIMEOUT_MS = 30_000;
let nextRpcId = 1;
interface JsonRpcSuccess<T> {
result: T;
}
interface JsonRpcFailure {
error: { message?: string; code?: number; data?: unknown };
}
function buildBypassJwt(userId: string): string {
const payload = Buffer.from(
JSON.stringify({ sub: userId, userId, exp: Math.floor(Date.now() / 1000) + 3600 })
).toString('base64url');
return `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.${payload}.sig`;
}
export async function callCoreRpc<T>(
method: string,
params: Record<string, unknown> = {}
): Promise<T> {
const response = await fetch(CORE_RPC_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${CORE_RPC_TOKEN}` },
body: JSON.stringify({ jsonrpc: '2.0', id: nextRpcId++, method, params }),
});
if (!response.ok) {
throw new Error(`RPC ${method} failed with HTTP ${response.status}`);
}
const payload = (await response.json()) as JsonRpcSuccess<T> & JsonRpcFailure;
if (payload.error) {
throw new Error(`RPC ${method} failed: ${payload.error.message || 'unknown error'}`);
}
return payload.result;
}
export async function resetCoreForWebUser(userId: string): Promise<void> {
await callCoreRpc('openhuman.auth_clear_session', {});
await callCoreRpc('openhuman.config_set_onboarding_completed', { value: true });
await callCoreRpc('openhuman.auth_store_session', { token: buildBypassJwt(userId) });
}
export async function seedBrowserCoreMode(page: Page): Promise<void> {
await page.addInitScript(
({ rpcUrl, token }) => {
window.localStorage.setItem('openhuman_core_mode', 'cloud');
window.localStorage.setItem('openhuman_core_rpc_url', rpcUrl);
window.localStorage.setItem('openhuman_core_rpc_token', token);
window.localStorage.setItem('openhuman:walkthrough_completed', 'true');
window.localStorage.removeItem('openhuman:walkthrough_pending');
},
{ rpcUrl: CORE_RPC_URL, token: CORE_RPC_TOKEN }
);
}
async function applyBrowserCoreModeInPage(page: Page): Promise<void> {
await page.evaluate(
({ rpcUrl, token }) => {
window.localStorage.setItem('openhuman_core_mode', 'cloud');
window.localStorage.setItem('openhuman_core_rpc_url', rpcUrl);
window.localStorage.setItem('openhuman_core_rpc_token', token);
window.localStorage.setItem('openhuman:walkthrough_completed', 'true');
window.localStorage.removeItem('openhuman:walkthrough_pending');
},
{ rpcUrl: CORE_RPC_URL, token: CORE_RPC_TOKEN }
);
}
async function completeAuthCallback(page: Page, token: string): Promise<void> {
await page.goto(`/#/callback/auth?token=${encodeURIComponent(token)}&key=auth`);
try {
// The app-side auth callback waits up to 15s for CoreStateProvider to
// commit currentUser before navigating to the post-auth landing surface;
// CI occasionally needs more than Playwright's default 10s assertion
// window here. Since the root two-panel shell folded Home into the unified
// chat surface, the post-auth landing is /chat (the former /home route now
// redirects there). We must wait for the *settled* #/chat rather than the
// transient #/home redirect frame — otherwise callers that navigate
// immediately after sign-in can have their target clobbered by the late
// /home → /chat redirect.
await expect
.poll(async () => page.evaluate(() => window.location.hash), {
timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS,
})
.toMatch(/^#\/chat/);
return;
} catch {
const runtimePickerVisible = await page
.getByText(/Select a Runtime|Connect to Your Runtime/)
.count()
.then(count => count > 0)
.catch(() => false);
if (!runtimePickerVisible) {
throw new Error(
'auth callback did not reach the post-auth landing surface (/home → /chat) and no runtime picker fallback was available'
);
}
}
await applyBrowserCoreModeInPage(page);
await page.goto(`/#/callback/auth?token=${encodeURIComponent(token)}&key=auth`);
await expect
.poll(async () => page.evaluate(() => window.location.hash), {
timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS,
})
.toMatch(/^#\/chat/);
}
export async function resetCoreForWebGuest(): Promise<void> {
await callCoreRpc('openhuman.auth_clear_session', {});
await callCoreRpc('openhuman.config_set_onboarding_completed', { value: true });
}
export async function bootRuntimeReadyGuestPage(page: Page): Promise<void> {
await resetCoreForWebGuest();
await seedBrowserCoreMode(page);
await page.goto('/#/');
await page.waitForSelector('#root');
}
export async function signInViaCallbackToken(page: Page, token: string): Promise<void> {
await completeAuthCallback(page, token);
await waitForAuthenticatedSnapshot(page);
await waitForAppReady(page);
}
export async function signInViaBypassUser(page: Page, userId: string): Promise<void> {
await completeAuthCallback(page, buildBypassJwt(userId));
await waitForAuthenticatedSnapshot(page);
await waitForAppReady(page);
}
export async function bootAuthenticatedPage(
page: Page,
userId: string,
hash: string = '/home'
): Promise<void> {
await resetCoreForWebUser(userId);
await seedBrowserCoreMode(page);
await page.goto('/#/home');
await waitForAuthenticatedSnapshot(page);
await page.goto(`/#${hash}`);
await waitForAppReady(page);
}
export async function waitForAppReady(page: Page): Promise<void> {
await page.waitForSelector('#root');
await expect
.poll(async () => {
const text = await page
.locator('#root')
.innerText()
.catch(() => '');
return text.trim().length;
})
.toBeGreaterThan(20);
await expect
.poll(async () =>
page.evaluate(() => {
const candidates = Array.from(document.querySelectorAll('h2, button, p, div, span'));
return candidates.some(node => {
const text = node.textContent?.trim() ?? '';
if (!/Select a Runtime|Connect to Your Runtime/.test(text)) return false;
const el = node as HTMLElement;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
})
)
.toBe(false);
}
export async function dismissWalkthroughIfPresent(page: Page): Promise<void> {
const skipButton = page.getByRole('button', { name: /Skip|Skip tour/i });
const portal = page.locator('#react-joyride-portal');
const deadline = Date.now() + 5_000;
const markCompleted = async () => {
await page.evaluate(() => {
try {
localStorage.setItem('openhuman:walkthrough_completed', 'true');
localStorage.removeItem('openhuman:walkthrough_pending');
} catch {}
});
};
while (Date.now() < deadline) {
if ((await portal.count()) === 0) return;
if (
(await skipButton.count()) > 0 &&
(await skipButton
.first()
.isVisible()
.catch(() => false))
) {
await markCompleted();
await skipButton
.first()
.click({ force: true, timeout: 1_000 })
.catch(() => {});
try {
await expect
.poll(
async () => {
const visible = await skipButton
.first()
.isVisible()
.catch(() => false);
return !visible;
},
{ timeout: 5_000 }
)
.toBe(true);
return;
} catch {
// Some routes keep the Joyride portal mounted even after the tour is
// dismissed. Keep looping so we can re-check visibility and fall back
// to the persisted completion flag below.
}
}
await page.waitForTimeout(100);
}
await markCompleted();
// Last-resort: a lingering #react-joyride-portal node will keep
// intercepting clicks on the page even after we've persisted the
// completion flag, AND React may re-mount one later (e.g. after a
// hash-route navigation that runs the walkthrough effect again).
// Strip every portal node now AND install a MutationObserver that
// keeps stripping any future mount for the rest of the page
// lifetime. The observer install is idempotent — re-runs of this
// helper on the same page no-op.
await page.evaluate(() => {
document.querySelectorAll('#react-joyride-portal').forEach(node => node.remove());
const win = window as unknown as { __openhumanJoyrideScrubInstalled?: boolean };
if (win.__openhumanJoyrideScrubInstalled) return;
win.__openhumanJoyrideScrubInstalled = true;
const scrub = (root: ParentNode) => {
root.querySelectorAll('#react-joyride-portal').forEach(node => node.remove());
};
const obs = new MutationObserver(mutations => {
for (const m of mutations) {
m.addedNodes.forEach(node => {
if (!(node instanceof Element)) return;
if (node.id === 'react-joyride-portal') {
node.remove();
} else {
scrub(node);
}
});
}
});
obs.observe(document.body, { childList: true, subtree: true });
});
}
async function waitForAuthenticatedSnapshot(page: Page): Promise<void> {
await expect
.poll(
async () =>
page.evaluate(() => {
const winAny = window as unknown as {
__OPENHUMAN_CORE_STATE__?: () => {
snapshot?: {
sessionToken?: string | null;
currentUser?: { _id?: string | null } | null;
};
};
};
const snapshot = winAny.__OPENHUMAN_CORE_STATE__?.()?.snapshot;
return {
hasToken: Boolean(snapshot?.sessionToken),
hasUser: Boolean(snapshot?.currentUser?._id),
};
}),
{ timeout: 20_000 }
)
.toEqual({ hasToken: true, hasUser: true });
}