mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(playwright): add functional UI flow coverage (#3257)
This commit is contained in:
@@ -14,6 +14,7 @@ export VITE_OPENHUMAN_TARGET="web"
|
||||
export VITE_OPENHUMAN_E2E_DEFAULT_CORE_MODE="cloud"
|
||||
export VITE_OPENHUMAN_E2E_RESTART_APP_AS_RELOAD="true"
|
||||
export VITE_OPENHUMAN_CORE_RPC_URL="http://127.0.0.1:${OPENHUMAN_CORE_PORT:-17788}/rpc"
|
||||
export VITE_CHAT_ATTACHMENTS="true"
|
||||
|
||||
if [ -f "$REPO_ROOT/.env" ]; then
|
||||
# shellcheck source=/dev/null
|
||||
|
||||
@@ -361,6 +361,7 @@ const AgentEditorPage = () => {
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('settings.agents.editor.selectTools')}
|
||||
onClick={() => setToolsOpen(true)}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-dashed border-stone-300 px-2.5 py-1 text-xs font-medium text-stone-600 hover:border-ocean-400 hover:text-ocean-600 dark:border-neutral-700 dark:text-neutral-300 dark:hover:border-ocean-500 dark:hover:text-ocean-300">
|
||||
<LuPlus className="h-3 w-3" />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
const MOCK_BASE = `http://127.0.0.1:${process.env.E2E_MOCK_PORT || '18473'}`;
|
||||
|
||||
async function setMockBehavior(behavior: Record<string, unknown>): Promise<void> {
|
||||
await fetch(`${MOCK_BASE}/__admin/behavior`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ behavior }),
|
||||
});
|
||||
}
|
||||
|
||||
async function resetMock(): Promise<void> {
|
||||
await fetch(`${MOCK_BASE}/__admin/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keepBehavior: false, keepRequests: false }),
|
||||
});
|
||||
}
|
||||
|
||||
async function selectedThreadId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => {
|
||||
const store = (
|
||||
window as unknown as {
|
||||
__OPENHUMAN_STORE__?: {
|
||||
getState?: () => { thread?: { selectedThreadId?: string | null } };
|
||||
};
|
||||
}
|
||||
).__OPENHUMAN_STORE__;
|
||||
return store?.getState?.().thread?.selectedThreadId ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
async function openChat(page: Page, userId: string): Promise<void> {
|
||||
await bootAuthenticatedPage(page, userId, '/chat');
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
await expect(page.getByTestId('send-message-button')).toBeVisible();
|
||||
}
|
||||
|
||||
async function newThread(page: Page): Promise<string> {
|
||||
await page.getByRole('button', { name: /^New$/ }).click();
|
||||
await expect.poll(() => selectedThreadId(page), { timeout: 10_000 }).not.toBeNull();
|
||||
return (await selectedThreadId(page))!;
|
||||
}
|
||||
|
||||
test.describe('Chat management functional coverage', () => {
|
||||
test('attachment preview, remove, and attachment send path remain interactive', async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetMock();
|
||||
await setMockBehavior({
|
||||
llmForcedResponses: JSON.stringify([{ content: 'Attachment received by the assistant.' }]),
|
||||
llmStreamChunkDelayMs: '5',
|
||||
});
|
||||
await openChat(page, 'pw-chat-attachments');
|
||||
await newThread(page);
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toHaveCount(1);
|
||||
await fileInput.setInputFiles({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
|
||||
await expect(page.getByText('pixel.png')).toBeVisible();
|
||||
await page.getByRole('button', { name: /Remove pixel\.png/ }).click();
|
||||
await expect(page.getByText('pixel.png')).toHaveCount(0);
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'pixel.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
),
|
||||
});
|
||||
await page.getByPlaceholder('How can I help you today?').fill('Describe this image');
|
||||
await page.getByTestId('send-message-button').click();
|
||||
await expect(page.getByText("This model can't process images.")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByPlaceholder('How can I help you today?')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('thread rename and delete remain usable from the conversation UI', async ({ page }) => {
|
||||
await resetMock();
|
||||
await openChat(page, 'pw-chat-rename-delete');
|
||||
const threadId = await newThread(page);
|
||||
const title = `Playwright thread ${Date.now()}`;
|
||||
|
||||
await page.getByRole('button', { name: 'Edit thread title' }).click({ force: true });
|
||||
await page.getByRole('textbox', { name: 'Edit thread title' }).fill(title);
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('heading', { name: title })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Show sidebar' }).click();
|
||||
await page
|
||||
.getByTestId(`thread-row-${threadId}`)
|
||||
.getByTitle('Delete thread')
|
||||
.click({ force: true });
|
||||
await expect(page.getByText(/delete/i).last()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(page.getByTestId(`thread-row-${threadId}`)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootRuntimeReadyGuestPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
signInViaCallbackToken,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
const MOCK_BASE = `http://127.0.0.1:${process.env.E2E_MOCK_PORT || '18473'}`;
|
||||
|
||||
type Toolkit = {
|
||||
slug: 'discord' | 'github' | 'jira';
|
||||
name: string;
|
||||
action: string;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
const TOOLKITS: Toolkit[] = [
|
||||
{
|
||||
slug: 'discord',
|
||||
name: 'Discord',
|
||||
action: 'DISCORD_FETCH_MESSAGES',
|
||||
connectionId: 'c-discord-pw',
|
||||
},
|
||||
{ slug: 'github', name: 'GitHub', action: 'GITHUB_LIST_REPOS', connectionId: 'c-github-pw' },
|
||||
{ slug: 'jira', name: 'Jira', action: 'JIRA_SEARCH_ISSUES', connectionId: 'c-jira-pw' },
|
||||
];
|
||||
|
||||
async function mockFetch(path: string, init?: RequestInit): Promise<{ data?: unknown }> {
|
||||
const response = await fetch(`${MOCK_BASE}${path}`, init);
|
||||
if (!response.ok) throw new Error(`mock ${path} failed: ${response.status}`);
|
||||
return response.json() as Promise<{ data?: unknown }>;
|
||||
}
|
||||
|
||||
async function resetMock(): Promise<void> {
|
||||
await mockFetch('/__admin/reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keepBehavior: false, keepRequests: false }),
|
||||
});
|
||||
}
|
||||
|
||||
async function setMockBehavior(behavior: Record<string, unknown>): Promise<void> {
|
||||
await mockFetch('/__admin/behavior', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ behavior }),
|
||||
});
|
||||
}
|
||||
|
||||
async function getRequestLog(): Promise<Array<{ method?: string; url?: string; body?: string }>> {
|
||||
const payload = await mockFetch('/__admin/requests');
|
||||
return (payload.data as Array<{ method?: string; url?: string; body?: string }>) ?? [];
|
||||
}
|
||||
|
||||
async function seedToolkits(status: 'ACTIVE' | 'FAILED' | 'EXPIRED' = 'ACTIVE'): Promise<void> {
|
||||
await setMockBehavior({
|
||||
composioToolkits: JSON.stringify(TOOLKITS.map(toolkit => toolkit.slug)),
|
||||
composioConnections: JSON.stringify(
|
||||
TOOLKITS.map(toolkit => ({ id: toolkit.connectionId, toolkit: toolkit.slug, status }))
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function bootSkills(page: Page, userId: string): Promise<void> {
|
||||
await resetMock();
|
||||
await seedToolkits('ACTIVE');
|
||||
await bootRuntimeReadyGuestPage(page);
|
||||
await signInViaCallbackToken(page, userId);
|
||||
await page.goto('/#/skills');
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
await expect(page.getByRole('heading', { name: 'Composio Integrations' })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertSessionAlive(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() => {
|
||||
const snapshot = (
|
||||
window as unknown as {
|
||||
__OPENHUMAN_CORE_STATE__?: () => {
|
||||
snapshot?: {
|
||||
currentUser?: { _id?: string | null } | null;
|
||||
sessionToken?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
).__OPENHUMAN_CORE_STATE__?.()?.snapshot;
|
||||
return {
|
||||
hash: window.location.hash,
|
||||
hasUser: Boolean(snapshot?.currentUser?._id),
|
||||
hasToken: Boolean(snapshot?.sessionToken),
|
||||
};
|
||||
})
|
||||
)
|
||||
.toEqual({ hash: '#/skills', hasUser: true, hasToken: true });
|
||||
}
|
||||
|
||||
test.describe('Connector session guard matrix', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
const slug = testInfo.title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
await bootSkills(page, `pw-connector-matrix-${slug}`);
|
||||
});
|
||||
|
||||
for (const toolkit of TOOLKITS) {
|
||||
test(`${toolkit.name} card opens management UI and authorize routes through mock backend`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const card = page.getByTestId(`skill-install-composio-${toolkit.slug}`);
|
||||
await expect(card).toContainText(toolkit.name);
|
||||
await card.click();
|
||||
await expect(page.getByRole('dialog', { name: new RegExp(toolkit.name, 'i') })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await callCoreRpc('openhuman.composio_authorize', { toolkit: toolkit.slug });
|
||||
const requests = await getRequestLog();
|
||||
const auth = requests.find(
|
||||
request =>
|
||||
request.method === 'POST' &&
|
||||
request.url?.includes('/agent-integrations/composio/authorize') &&
|
||||
JSON.parse(request.body || '{}').toolkit === toolkit.slug
|
||||
);
|
||||
expect(auth).toBeDefined();
|
||||
await assertSessionAlive(page);
|
||||
});
|
||||
}
|
||||
|
||||
test('Jira connect modal exposes the required site/subdomain input', async ({ page }) => {
|
||||
await setMockBehavior({
|
||||
composioConnections: JSON.stringify(
|
||||
TOOLKITS.filter(toolkit => toolkit.slug !== 'jira').map(toolkit => ({
|
||||
id: toolkit.connectionId,
|
||||
toolkit: toolkit.slug,
|
||||
status: 'ACTIVE',
|
||||
}))
|
||||
),
|
||||
});
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await page.getByTestId('skill-install-composio-jira').click();
|
||||
const dialog = page.getByRole('dialog', { name: /Jira/i });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('textbox', { name: /Atlassian subdomain/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('failed and expired connector states keep the user signed in and page usable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedToolkits('FAILED');
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await expect(page.getByTestId('skill-install-composio-discord')).toContainText('Discord');
|
||||
await assertSessionAlive(page);
|
||||
|
||||
await seedToolkits('EXPIRED');
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await expect(page.getByTestId('skill-install-composio-github')).toContainText(
|
||||
/Reconnect|GitHub/
|
||||
);
|
||||
await assertSessionAlive(page);
|
||||
});
|
||||
|
||||
test('Composio execute and disconnect errors do not clear auth session', async ({ page }) => {
|
||||
await setMockBehavior({ composioExecuteFails: '500' });
|
||||
await expect(
|
||||
callCoreRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-github-pw',
|
||||
tool: 'GITHUB_LIST_REPOS',
|
||||
arguments: {},
|
||||
})
|
||||
).rejects.toThrow(/failed/i);
|
||||
await assertSessionAlive(page);
|
||||
|
||||
await setMockBehavior({ composioDeleteFails: '500' });
|
||||
await expect(
|
||||
callCoreRpc('openhuman.composio_delete_connection', { connection_id: 'c-discord-pw' })
|
||||
).rejects.toThrow(/failed/i);
|
||||
await assertSessionAlive(page);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
type MemorySource = { id: string; kind: string; label: string; enabled: boolean };
|
||||
|
||||
async function openMemory(page: Page): Promise<void> {
|
||||
await bootAuthenticatedPage(page, 'pw-intelligence-memory-ui', '/intelligence');
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
const memoryTab = page.getByRole('tab', { name: /^Memory$/ });
|
||||
if (await memoryTab.isVisible().catch(() => false)) {
|
||||
await memoryTab.click();
|
||||
}
|
||||
await expect(page.getByTestId('memory-workspace')).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function addFolderSource(label: string): Promise<string> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'openhuman-pw-memory-'));
|
||||
mkdirSync(join(root, 'notes'), { recursive: true });
|
||||
writeFileSync(join(root, 'notes', 'project.md'), '# Project\n\nPlaywright memory source canary.');
|
||||
await callCoreRpc('openhuman.memory_sources_add', {
|
||||
kind: 'folder',
|
||||
label,
|
||||
enabled: true,
|
||||
path: root,
|
||||
glob: '**/*.md',
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
test.describe('Intelligence memory UI', () => {
|
||||
test('source row sync, toggle, remove, graph mode, and reset confirmations work', async ({
|
||||
page,
|
||||
}) => {
|
||||
const label = `Playwright Memory Source ${Date.now()}`;
|
||||
await openMemory(page);
|
||||
await addFolderSource(label);
|
||||
|
||||
const row = page.getByTestId('memory-source-row-folder').filter({ hasText: label });
|
||||
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await row.getByTestId('memory-source-sync-folder').click();
|
||||
await expect(row).toContainText(/Sync|Syncing/);
|
||||
|
||||
await row.getByTitle('Disable').click();
|
||||
await expect(row.getByTitle('Enable')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId('memory-graph-mode-contacts').click();
|
||||
await expect(page.getByTestId('memory-graph-mode-contacts')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
await page.getByTestId('memory-graph-mode-tree').click();
|
||||
await expect(page.getByTestId('memory-graph-mode-tree')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
);
|
||||
|
||||
page.once('dialog', dialog => dialog.dismiss());
|
||||
await page.getByTestId('memory-wipe-all').click();
|
||||
await expect(page.getByTestId('memory-wipe-all')).toBeEnabled();
|
||||
|
||||
page.once('dialog', dialog => dialog.dismiss());
|
||||
await page.getByTestId('memory-reset-tree').click();
|
||||
await expect(page.getByTestId('memory-reset-tree')).toBeEnabled();
|
||||
|
||||
await row.getByTitle('Remove').click();
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
seedBrowserCoreMode,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
async function resetOnboarding(userId: string): Promise<void> {
|
||||
await callCoreRpc('openhuman.auth_clear_session', {});
|
||||
await callCoreRpc('openhuman.config_set_onboarding_completed', { value: false });
|
||||
await callCoreRpc('openhuman.auth_store_session', {
|
||||
token: `eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.${Buffer.from(
|
||||
JSON.stringify({ sub: userId, userId, exp: Math.floor(Date.now() / 1000) + 3600 })
|
||||
).toString('base64url')}.sig`,
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseConfigure(page: Page): Promise<void> {
|
||||
const configure = page.getByRole('button', { name: /Configure/ }).first();
|
||||
if (await configure.isVisible().catch(() => false)) {
|
||||
await configure.click();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Onboarding custom configuration flow', () => {
|
||||
test('advanced path supports configure choices and back navigation through final setup', async ({
|
||||
page,
|
||||
}) => {
|
||||
await resetOnboarding('pw-onboarding-config');
|
||||
await seedBrowserCoreMode(page);
|
||||
await page.goto('/#/onboarding/welcome');
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
|
||||
await expect(page.getByTestId('onboarding-welcome-step')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Get Started' }).click();
|
||||
await expect(page.getByTestId('onboarding-runtime-choice-step')).toBeVisible();
|
||||
await page.getByTestId('onboarding-runtime-choice-custom').click();
|
||||
await page.getByTestId('onboarding-next-button').click();
|
||||
|
||||
await expect(page.getByTestId('onboarding-custom-inference-step')).toBeVisible();
|
||||
await chooseConfigure(page);
|
||||
await page.getByTestId('onboarding-next-button').click();
|
||||
|
||||
await expect(page.getByTestId('onboarding-custom-voice-step')).toBeVisible();
|
||||
await chooseConfigure(page);
|
||||
await page.getByRole('button', { name: /Back/ }).click();
|
||||
await expect(page.getByTestId('onboarding-custom-inference-step')).toBeVisible();
|
||||
await page.getByTestId('onboarding-next-button').click();
|
||||
|
||||
for (const id of [
|
||||
'onboarding-custom-voice-step',
|
||||
'onboarding-custom-oauth-step',
|
||||
'onboarding-custom-search-step',
|
||||
'onboarding-custom-embeddings-step',
|
||||
'onboarding-custom-activity-step',
|
||||
]) {
|
||||
await expect(page.getByTestId(id)).toBeVisible({ timeout: 20_000 });
|
||||
const configure = page.getByRole('button', { name: /Configure/ });
|
||||
if (
|
||||
await configure
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await configure.first().click();
|
||||
}
|
||||
await page.getByTestId('onboarding-next-button').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('onboarding-custom-vault-step')).toBeVisible({ timeout: 20_000 });
|
||||
await chooseConfigure(page);
|
||||
await expect(page.getByTestId('onboarding-next-button')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
async function openSettings(page: Page, userId: string, hash: string): Promise<void> {
|
||||
await bootAuthenticatedPage(page, userId, hash);
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
}
|
||||
|
||||
async function themeState(page: Page): Promise<{ mode?: string; tabBarLabels?: string }> {
|
||||
return page.evaluate(() => {
|
||||
const store = (
|
||||
window as unknown as {
|
||||
__OPENHUMAN_STORE__?: {
|
||||
getState?: () => { theme?: { mode?: string; tabBarLabels?: string } };
|
||||
};
|
||||
}
|
||||
).__OPENHUMAN_STORE__;
|
||||
return store?.getState?.().theme ?? {};
|
||||
});
|
||||
}
|
||||
|
||||
async function persistedThemeState(page: Page): Promise<{ mode?: string; tabBarLabels?: string }> {
|
||||
return page.evaluate(() => {
|
||||
const raw = localStorage.getItem('persist:theme');
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, string>;
|
||||
return {
|
||||
mode: parsed.mode ? JSON.parse(parsed.mode) : undefined,
|
||||
tabBarLabels: parsed.tabBarLabels ? JSON.parse(parsed.tabBarLabels) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unwrap<T>(value: T | { result: T }): T {
|
||||
if (value && typeof value === 'object' && 'result' in value) {
|
||||
return (value as { result: T }).result;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
test.describe('Settings leaf workflows', () => {
|
||||
test('appearance theme mode and tab bar label preference persist in app state', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSettings(page, 'pw-settings-appearance', '/settings/appearance');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Appearance' })).toBeVisible();
|
||||
await page.getByRole('radio', { name: /Dark/ }).click();
|
||||
const labelSwitch = page.getByRole('switch', { name: /Always show labels/ });
|
||||
if ((await labelSwitch.getAttribute('aria-checked')) !== 'true') {
|
||||
await labelSwitch.click();
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() => themeState(page))
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always' });
|
||||
await expect
|
||||
.poll(() => persistedThemeState(page))
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always' });
|
||||
|
||||
await page.reload();
|
||||
await waitForAppReady(page);
|
||||
await expect
|
||||
.poll(() => themeState(page))
|
||||
.toMatchObject({ mode: 'dark', tabBarLabels: 'always' });
|
||||
});
|
||||
|
||||
test('embeddings custom endpoint setup writes provider, model, and dimensions', async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSettings(page, 'pw-settings-embeddings', '/settings/embeddings');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Embeddings' })).toBeVisible();
|
||||
await page.getByRole('radio', { name: /Custom/i }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Set up/i })).toBeVisible();
|
||||
await page
|
||||
.getByPlaceholder('https://your-endpoint.com/v1')
|
||||
.fill('http://127.0.0.1:18473/openai/v1');
|
||||
await page.getByPlaceholder('text-embedding-3-small').fill('e2e-embedding-model');
|
||||
await page.getByPlaceholder('1024').fill('64');
|
||||
await page.getByRole('button', { name: 'Save & switch' }).click();
|
||||
|
||||
const wipe = page.getByRole('button', { name: 'Wipe & apply' });
|
||||
await expect(wipe).toBeVisible({ timeout: 15_000 });
|
||||
await wipe.click();
|
||||
|
||||
await expect(page.getByText('Saved.')).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await callCoreRpc<any>('openhuman.embeddings_get_settings', {});
|
||||
const settings =
|
||||
unwrap<{ provider?: string; model?: string; dimensions?: number }>(raw) ?? {};
|
||||
return {
|
||||
provider: settings.provider,
|
||||
model: settings.model,
|
||||
dimensions: settings.dimensions,
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
provider: 'custom:http://127.0.0.1:18473/openai/v1',
|
||||
model: 'e2e-embedding-model',
|
||||
dimensions: 64,
|
||||
});
|
||||
});
|
||||
|
||||
test('agents/new creates a custom agent that appears in the registry', async ({ page }) => {
|
||||
const agentId = `pw-researcher-${Date.now()}`;
|
||||
await openSettings(page, 'pw-settings-agent-new', '/settings/agents/new');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'New agent' })).toBeVisible();
|
||||
await page.getByRole('textbox', { name: 'Name' }).fill('Playwright Researcher');
|
||||
await page.getByRole('textbox', { name: /ID Lowercase/ }).fill(agentId);
|
||||
await page.getByLabel('Description').fill('Validates settings agent authoring in E2E.');
|
||||
await page.getByLabel('Model (optional)').selectOption('hint:reasoning');
|
||||
await page
|
||||
.getByLabel('System prompt (optional)')
|
||||
.fill('Prefer concise citations and explain uncertainty.');
|
||||
await page.getByRole('button', { name: 'Add tools' }).click();
|
||||
await page.getByRole('button', { name: /Allow all tools/ }).click();
|
||||
await page.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Create agent' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/#\/settings\/agents$/);
|
||||
const agent = await callCoreRpc<{
|
||||
agent?: { id: string; model?: string; tool_allowlist?: string[] };
|
||||
}>('openhuman.agent_registry_get', { id: agentId });
|
||||
expect(agent.agent).toMatchObject({
|
||||
id: agentId,
|
||||
model: 'hint:reasoning',
|
||||
tool_allowlist: ['*'],
|
||||
});
|
||||
});
|
||||
|
||||
test('task sources surface the web harness guard while preserving the create form', async ({
|
||||
page,
|
||||
}) => {
|
||||
const name = `Playwright Issues ${Date.now()}`;
|
||||
await openSettings(page, 'pw-settings-task-sources', '/settings/task-sources');
|
||||
|
||||
await expect(page.getByTestId('task-sources-panel')).toBeVisible();
|
||||
await expect(page.getByText('Not running in Tauri')).toBeVisible();
|
||||
await page.getByLabel('Provider').selectOption('github');
|
||||
await page.getByLabel('Name (optional)').fill(name);
|
||||
await page.getByLabel('Repository (owner/name, optional)').fill('tinyhumansai/openhuman');
|
||||
await page.getByLabel('Labels (comma-separated)').fill('e2e, regression');
|
||||
await expect(page.getByRole('button', { name: 'Add source' })).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Preview' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
bootAuthenticatedPage,
|
||||
callCoreRpc,
|
||||
dismissWalkthroughIfPresent,
|
||||
waitForAppReady,
|
||||
} from '../helpers/core-rpc';
|
||||
|
||||
const MOCK_BASE = `http://127.0.0.1:${process.env.E2E_MOCK_PORT || '18473'}`;
|
||||
|
||||
async function mockFetch(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(`${MOCK_BASE}${path}`, init);
|
||||
if (!response.ok) throw new Error(`mock ${path} failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function setMockBehavior(behavior: Record<string, unknown>): Promise<void> {
|
||||
await mockFetch('/__admin/behavior', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ behavior }),
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Top-level functional flows', () => {
|
||||
test('workflows create and delete round-trip through the top-level page', async ({ page }) => {
|
||||
const name = `Playwright Workflow ${Date.now()}`;
|
||||
await bootAuthenticatedPage(page, 'pw-workflows-create-delete', '/workflows');
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
|
||||
await page.getByTestId('workflows-create-btn').click();
|
||||
await expect(page.getByRole('dialog', { name: /New Workflow/i })).toBeVisible();
|
||||
await page.getByLabel(/Name/).fill(name);
|
||||
await page.getByLabel(/Description/).fill('Created by Playwright to cover workflow UX.');
|
||||
await page.getByLabel(/When to use/).fill('Use when validating E2E workflow CRUD.');
|
||||
await page.getByRole('button', { name: 'Create workflow' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name })).toBeVisible({ timeout: 15_000 });
|
||||
const workflows = await callCoreRpc<{ workflows?: Array<{ id: string; name: string }> }>(
|
||||
'openhuman.workflows_list',
|
||||
{}
|
||||
);
|
||||
const created = workflows.workflows?.find(workflow => workflow.name === name);
|
||||
expect(created?.id).toBeTruthy();
|
||||
|
||||
await page.getByTestId(`workflow-card-${created!.id}-delete`).click();
|
||||
await expect(page.getByRole('alertdialog')).toBeVisible();
|
||||
await page.getByTestId('wf-delete-confirm-btn').click();
|
||||
await expect(page.getByText(name)).toHaveCount(0, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('invites copies a rendered invite code and redeems a typed code', async ({ page }) => {
|
||||
const inviteCode = `PW${Date.now().toString().slice(-6)}`;
|
||||
await setMockBehavior({
|
||||
inviteCodes: JSON.stringify([
|
||||
{ _id: 'invite-pw-1', code: inviteCode, currentUses: 0, maxUses: 1, usageHistory: [] },
|
||||
]),
|
||||
});
|
||||
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (text: string) => {
|
||||
window.localStorage.setItem('pw:last-copied-invite', text);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await bootAuthenticatedPage(page, 'pw-invites-copy-redeem', '/invites');
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
|
||||
await expect(page.getByText(inviteCode)).toBeVisible();
|
||||
await page.getByTitle('Copy').click();
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('pw:last-copied-invite')))
|
||||
.toBe(inviteCode);
|
||||
|
||||
await page.getByPlaceholder('Search').fill('welcome42');
|
||||
await page.getByRole('button', { name: 'Referrals' }).click();
|
||||
await expect(page.getByText('Success')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test('major top-level pages render actionable UI without blanking', async ({ page }) => {
|
||||
await bootAuthenticatedPage(page, 'pw-top-level-ui', '/home');
|
||||
const routes: Array<[string, RegExp]> = [
|
||||
['/home', /Ask your assistant anything|Start/],
|
||||
['/skills', /Composio Integrations|Channels|MCP Servers/],
|
||||
['/chat', /How can I help you today|No messages yet|Threads/],
|
||||
['/intelligence', /Agent Tasks|Memory|Subconscious/],
|
||||
['/notifications', /Notifications|System Events/],
|
||||
['/rewards', /Rewards|Referrals|Redeem/],
|
||||
];
|
||||
|
||||
for (const [hash, text] of routes) {
|
||||
await page.goto(`/#${hash}`);
|
||||
await waitForAppReady(page);
|
||||
await dismissWalkthroughIfPresent(page);
|
||||
await expect(page.locator('#root')).toContainText(text);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { json } from "../http.mjs";
|
||||
import { behavior } from "../state.mjs";
|
||||
|
||||
export function handleInvites(ctx) {
|
||||
const { method, url, res } = ctx;
|
||||
@@ -11,7 +12,17 @@ export function handleInvites(ctx) {
|
||||
return true;
|
||||
}
|
||||
if (method === "GET" && /^\/invite\/my-codes\/?(\?.*)?$/.test(url)) {
|
||||
json(res, 200, { success: true, data: [] });
|
||||
const rawCodes = behavior().inviteCodes;
|
||||
let codes = [];
|
||||
if (typeof rawCodes === "string" && rawCodes.length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawCodes);
|
||||
if (Array.isArray(parsed)) codes = parsed;
|
||||
} catch {
|
||||
codes = [];
|
||||
}
|
||||
}
|
||||
json(res, 200, { success: true, data: codes });
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user