From d798bf8299518dafb1e40c472010c5d3ebf88c59 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:27:45 -0700 Subject: [PATCH] fix(e2e): adapt Playwright specs + Vitest setup to chat-as-home shell (#3751) (#3754) --- .../channels/mcp/McpServersTab.test.tsx | 2 + .../components/channels/mcp/McpServersTab.tsx | 12 +- app/src/pages/Conversations.tsx | 107 ++++++++++++++++ .../Conversations.attachments.test.tsx | 120 ++++++++++++++++++ app/src/test/setup.ts | 14 +- app/test/playwright/helpers/core-rpc.ts | 16 ++- .../playwright/specs/agent-review.spec.ts | 6 +- .../specs/auth-access-control.spec.ts | 8 +- .../specs/chat-conversation-history.spec.ts | 7 +- .../specs/chat-management-functional.spec.ts | 28 +++- .../specs/core-port-conflict-recovery.spec.ts | 4 +- .../playwright/specs/cron-jobs-flow.spec.ts | 9 +- .../specs/linux-cef-deb-runtime.spec.ts | 8 +- app/test/playwright/specs/login-flow.spec.ts | 8 +- .../specs/logout-relogin-onboarding.spec.ts | 2 +- .../playwright/specs/mcp-tab-flow.spec.ts | 12 +- .../specs/navigation-smoothness.spec.ts | 16 ++- app/test/playwright/specs/navigation.spec.ts | 3 +- .../playwright/specs/onboarding-modes.spec.ts | 2 +- .../specs/runtime-picker-login.spec.ts | 4 +- .../specs/skill-execution-flow.spec.ts | 6 +- .../specs/skill-socket-reconnect.spec.ts | 10 +- .../playwright/specs/tauri-commands.spec.ts | 8 +- .../specs/top-level-functional-flows.spec.ts | 5 +- .../specs/user-journey-full-task.spec.ts | 9 +- .../user-journey-settings-round-trip.spec.ts | 16 +-- .../specs/webhooks-ingress-flow.spec.ts | 6 +- .../specs/webhooks-tunnel-flow.spec.ts | 6 +- src/openhuman/workflows/ops_create.rs | 8 +- 29 files changed, 372 insertions(+), 90 deletions(-) diff --git a/app/src/components/channels/mcp/McpServersTab.test.tsx b/app/src/components/channels/mcp/McpServersTab.test.tsx index 1025f0697..f27f8bc53 100644 --- a/app/src/components/channels/mcp/McpServersTab.test.tsx +++ b/app/src/components/channels/mcp/McpServersTab.test.tsx @@ -505,6 +505,8 @@ describe('McpServersTab', () => { await waitFor(() => { expect(screen.queryByText('File Server')).not.toBeInTheDocument(); }); + // No installed and no catalog match → the catalog empty-state renders. + expect(screen.getByTestId('mcp-catalog-empty')).toBeInTheDocument(); }); it('Registry chip hides installed rows', async () => { diff --git a/app/src/components/channels/mcp/McpServersTab.tsx b/app/src/components/channels/mcp/McpServersTab.tsx index 9326f7bd8..ccb25c195 100644 --- a/app/src/components/channels/mcp/McpServersTab.tsx +++ b/app/src/components/channels/mcp/McpServersTab.tsx @@ -457,12 +457,16 @@ const McpServersTab = () => { {/* Empty states */} {activeChip === 'installed' && filteredInstalled.length === 0 && ( -
+
{t('mcp.installed.empty')}
)} {activeChip === 'registry' && filteredCatalog.length === 0 && !catalogLoading && ( -
+
{searchQuery ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) : t('mcp.catalog.noResults')} @@ -472,7 +476,9 @@ const McpServersTab = () => { filteredInstalled.length === 0 && filteredCatalog.length === 0 && !catalogLoading && ( -
+
{searchQuery ? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery) : t('mcp.catalog.noResults')} diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 3ae97c273..bf1b1d0e5 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -62,6 +62,7 @@ import { persistReaction, setSelectedThread, THREAD_NOT_FOUND_MESSAGE, + updateThreadTitle, } from '../store/threadSlice'; import type { ConfirmationModal as ConfirmationModalType } from '../types/intelligence'; import type { ThreadMessage } from '../types/thread'; @@ -307,6 +308,11 @@ const Conversations = ({ ); const rustChat = useRustChat(); const [reactionPickerMsgId, setReactionPickerMsgId] = useState(null); + // Inline thread-title rename in the conversation header. + const [editingTitle, setEditingTitle] = useState(false); + const [editTitleValue, setEditTitleValue] = useState(''); + const editTitleInputRef = useRef(null); + const ignoreNextTitleBlurRef = useRef(false); const { teamUsage, @@ -434,6 +440,46 @@ const Conversations = ({ } }; + const handleStartEditTitle = () => { + if (!selectedThreadId) return; + const thr = threads.find(t => t.id === selectedThreadId); + debug('[chat] thread rename: start thread=%s', selectedThreadId); + setEditTitleValue(thr?.title ?? ''); + ignoreNextTitleBlurRef.current = true; + setEditingTitle(true); + const scheduleSelect = window.requestAnimationFrame ?? window.setTimeout; + scheduleSelect(() => { + editTitleInputRef.current?.select(); + ignoreNextTitleBlurRef.current = false; + }); + }; + + const handleCommitTitle = () => { + const trimmed = editTitleValue.trim(); + setEditingTitle(false); + // Title length only — never log the title text itself (may carry PII). + if (!selectedThreadId || !trimmed) { + debug('[chat] thread rename: commit skipped thread=%s empty=%s', selectedThreadId, !trimmed); + return; + } + const currentTitle = threads.find(t => t.id === selectedThreadId)?.title?.trim(); + if (trimmed === currentTitle) { + debug('[chat] thread rename: commit skipped thread=%s (unchanged)', selectedThreadId); + return; + } + debug('[chat] thread rename: commit thread=%s len=%d', selectedThreadId, trimmed.length); + void dispatch(updateThreadTitle({ threadId: selectedThreadId, title: trimmed })) + .unwrap() + .then(() => debug('[chat] thread rename: committed thread=%s', selectedThreadId)) + .catch(err => + debug( + '[chat] thread rename: failed thread=%s err=%s', + selectedThreadId, + err instanceof Error ? err.message : String(err) + ) + ); + }; + const handleSelectAgentProfile = async (profileId: string) => { try { await dispatch(selectAgentProfile(profileId)).unwrap(); @@ -2468,6 +2514,67 @@ const Conversations = ({ )} + {/* Thread title + inline rename (page variant). Hover the title to + reveal the edit affordance; Enter commits, Escape cancels. */} + {!isSidebar && selectedThreadId && ( +
+ {editingTitle ? ( + setEditTitleValue(e.target.value)} + onKeyDown={e => { + // Ignore the Enter that confirms an IME composition candidate + // (CJK input) so it doesn't prematurely commit the rename. + if (isImeCompositionKeyEvent(e)) return; + if (e.key === 'Enter') { + e.preventDefault(); + handleCommitTitle(); + } else if (e.key === 'Escape') { + setEditingTitle(false); + } + }} + onBlur={() => { + if (ignoreNextTitleBlurRef.current) { + ignoreNextTitleBlurRef.current = false; + return; + } + handleCommitTitle(); + }} + aria-label={t('chat.editThreadTitle')} + className="h-6 w-full min-w-0 border-b border-primary-400 bg-transparent py-0 text-sm font-medium leading-none text-stone-700 outline-none dark:text-neutral-200" + autoFocus + /> + ) : ( +
+

+ {resolveThreadDisplayTitle(selectedThreadId)} +

+ +
+ )} +
+ )} + {/* Model + token stats (left) and the quick/reasoning toggle + files chip (right) share one line. */}
{ }); }); }); + +describe('Conversations — thread rename', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetThreads.mockResolvedValue({ threads: [], count: 0 }); + mockGetThreadMessages.mockResolvedValue({ messages: [], count: 0 }); + }); + + it('commits an inline thread-title rename from the conversation header', async () => { + const { thread } = await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + + // Enter edit mode via the header pencil affordance. + fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.change(input, { target: { value: 'Renamed in header' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(threadApi.updateTitle).toHaveBeenCalledWith(thread.id, 'Renamed in header'); + }); + }); + + it('cancels the rename on Escape without dispatching an update', async () => { + await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + + fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.change(input, { target: { value: 'Discarded title' } }); + fireEvent.keyDown(input, { key: 'Escape' }); + + // Editor closes back to the title heading; no persistence call fired. + await waitFor(() => { + expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull(); + }); + expect(threadApi.updateTitle).not.toHaveBeenCalled(); + }); + + it('does not commit on the Enter that confirms an IME composition', async () => { + await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + + fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.change(input, { target: { value: '日本語' } }); + // keyCode 229 marks an IME composition keydown — Enter here confirms a + // candidate, not the rename. + fireEvent.keyDown(input, { key: 'Enter', keyCode: 229 }); + + expect(threadApi.updateTitle).not.toHaveBeenCalled(); + // Editor stays open for continued composition. + expect(screen.getByRole('textbox', { name: 'Edit thread title' })).toBeInTheDocument(); + }); + + it('skips persistence when the committed title is unchanged', async () => { + await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + + // The input seeds with the current title ("Attach Thread"); committing it + // unchanged must not dispatch an update. + fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull(); + }); + expect(threadApi.updateTitle).not.toHaveBeenCalled(); + }); + + it('skips persistence when the committed title is blank', async () => { + await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + + fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull(); + }); + expect(threadApi.updateTitle).not.toHaveBeenCalled(); + }); + + it('opens the editor via mousedown and ignores the immediate focus-blur', async () => { + await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + + // onMouseDown opens edit mode; the blur fired while the input is grabbing + // focus is ignored (no spurious commit). + fireEvent.mouseDown(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.blur(input); + + expect(threadApi.updateTitle).not.toHaveBeenCalled(); + }); + + it('swallows a rename persistence failure without crashing', async () => { + const { thread } = await renderWithSelectedThread(); + const { threadApi } = await import('../../services/api/threadApi'); + (threadApi.updateTitle as ReturnType).mockRejectedValueOnce( + new Error('rename boom') + ); + + fireEvent.click(screen.getByRole('button', { name: 'Edit thread title' })); + const input = await screen.findByRole('textbox', { name: 'Edit thread title' }); + fireEvent.change(input, { target: { value: 'Doomed rename' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(threadApi.updateTitle).toHaveBeenCalledWith(thread.id, 'Doomed rename'); + }); + // The editor still closes and the UI stays mounted. + await waitFor(() => { + expect(screen.queryByRole('textbox', { name: 'Edit thread title' })).toBeNull(); + }); + }); +}); diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts index de766eb2b..a6bfe3cba 100644 --- a/app/src/test/setup.ts +++ b/app/src/test/setup.ts @@ -8,7 +8,7 @@ * - Resets rate limiter module-level state between tests */ import '@testing-library/jest-dom/vitest'; -import { cleanup } from '@testing-library/react'; +import { cleanup, configure } from '@testing-library/react'; import type React from 'react'; import { afterAll, afterEach, beforeEach, vi } from 'vitest'; @@ -20,6 +20,18 @@ import { stopMockServer, } from '../../../scripts/mock-api-core.mjs'; +// The full Vitest run is executed under v8 coverage instrumentation with a +// single worker (see test/vitest.config.ts), which makes individual renders +// markedly slower than an isolated, un-instrumented file run. Testing Library's +// default 1000ms async-utility timeout is too tight in that environment, so +// `findBy*`/`waitFor` assertions in render-heavy suites (e.g. the workflow +// orchestration tab) flake intermittently — and which test trips first is +// non-deterministic. Raising the global async-util budget removes that whole +// class of false timeouts without masking real failures: `waitFor`/`findBy` +// still resolve the instant their condition is met, this only widens the +// ceiling before they give up (well within the 30s testTimeout). +configure({ asyncUtilTimeout: 5000 }); + const DEFAULT_TEST_MOCK_API_PORT = 5005; function readMockApiPort() { diff --git a/app/test/playwright/helpers/core-rpc.ts b/app/test/playwright/helpers/core-rpc.ts index 43fdaac54..1a0b9e094 100644 --- a/app/test/playwright/helpers/core-rpc.ts +++ b/app/test/playwright/helpers/core-rpc.ts @@ -78,13 +78,19 @@ async function completeAuthCallback(page: Page, token: string): Promise { 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 /home; CI occasionally needs - // more than Playwright's default 10s assertion window here. + // 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(/^#\/home/); + .toMatch(/^#\/chat/); return; } catch { const runtimePickerVisible = await page @@ -94,7 +100,7 @@ async function completeAuthCallback(page: Page, token: string): Promise { .catch(() => false); if (!runtimePickerVisible) { throw new Error( - 'auth callback did not reach /home and no runtime picker fallback was available' + 'auth callback did not reach the post-auth landing surface (/home → /chat) and no runtime picker fallback was available' ); } } @@ -105,7 +111,7 @@ async function completeAuthCallback(page: Page, token: string): Promise { .poll(async () => page.evaluate(() => window.location.hash), { timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS, }) - .toMatch(/^#\/home/); + .toMatch(/^#\/chat/); } export async function resetCoreForWebGuest(): Promise { diff --git a/app/test/playwright/specs/agent-review.spec.ts b/app/test/playwright/specs/agent-review.spec.ts index 7504725c8..e08b425fd 100644 --- a/app/test/playwright/specs/agent-review.spec.ts +++ b/app/test/playwright/specs/agent-review.spec.ts @@ -23,11 +23,11 @@ test.describe('Agent review - canonical onboarding + privacy flow', () => { test('launches, reaches the shell, and opens the privacy panel', async ({ page }) => { await bootReviewedFlow(page, 'pw-agent-review'); + // Home folded into the unified chat surface: post-login landing is /chat, + // whose sidebar/new-window state renders these structural markers. const shellText = await page.locator('#root').innerText(); expect( - ['Ask your assistant anything', 'Your device is connected', 'Settings', 'Home'].some(marker => - shellText.includes(marker) - ) + ['New Conversation', 'Threads', 'Settings'].some(marker => shellText.includes(marker)) ).toBe(true); await page.goto('/#/settings/privacy'); diff --git a/app/test/playwright/specs/auth-access-control.spec.ts b/app/test/playwright/specs/auth-access-control.spec.ts index ac0858677..1323f1536 100644 --- a/app/test/playwright/specs/auth-access-control.spec.ts +++ b/app/test/playwright/specs/auth-access-control.spec.ts @@ -45,7 +45,9 @@ test.describe('Auth & Access Control', () => { test('authenticated sign-in reaches home', async ({ page }) => { await signInViaBypassUser(page, 'pw-auth-access-token'); - await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toMatch(/^#\/(home|chat)/); await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy(); }); @@ -55,7 +57,9 @@ test.describe('Auth & Access Control', () => { await signInViaBypassUser(page, 'pw-auth-access-second'); - await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toMatch(/^#\/(home|chat)/); await expect .poll(async () => { const requests = await mockRequests(); diff --git a/app/test/playwright/specs/chat-conversation-history.spec.ts b/app/test/playwright/specs/chat-conversation-history.spec.ts index c66ce8201..63d3514f0 100644 --- a/app/test/playwright/specs/chat-conversation-history.spec.ts +++ b/app/test/playwright/specs/chat-conversation-history.spec.ts @@ -134,9 +134,10 @@ test.describe('Chat Conversation History', () => { await createNewThread(page); await sendMessage(page, FIRST_PROMPT); - await expect( - page.locator('div.bg-stone-200').filter({ hasText: FIRST_RESPONSE }).first() - ).toBeVisible({ timeout: 20_000 }); + // The assistant bubble's Tailwind class carries an opacity modifier + // (`bg-stone-200/80`), which is a single class token — a plain `.bg-stone-200` + // selector can't match it. Assert on the rendered response text instead. + await expect(page.getByText(FIRST_RESPONSE).first()).toBeVisible({ timeout: 20_000 }); await resetMock(); await setMockBehavior( diff --git a/app/test/playwright/specs/chat-management-functional.spec.ts b/app/test/playwright/specs/chat-management-functional.spec.ts index 52a57dcd1..82207137e 100644 --- a/app/test/playwright/specs/chat-management-functional.spec.ts +++ b/app/test/playwright/specs/chat-management-functional.spec.ts @@ -65,9 +65,20 @@ async function openChat(page: Page, userId: string): Promise { } async function newThread(page: Page): Promise { - await page.getByRole('button', { name: /^New$/ }).click(); - await expect.poll(() => selectedThreadId(page), { timeout: 10_000 }).not.toBeNull(); - return (await selectedThreadId(page))!; + // The sidebar "new thread" control now reads "New Conversation" (was "New"), + // so anchor on its stable testid rather than the accessible name. + // + // chat-as-home may already have a non-null selectedThreadId (an auto-created + // empty thread) before this click, so waiting only for "non-null" could + // return that stale id while the click-created thread is still racing in. + // Capture the prior id and wait for the selection to advance to the freshly + // created thread (handleCreateNewThread always creates a new unique id). + const before = await selectedThreadId(page); + await page.getByTestId('new-thread-button').click({ force: true }); + await expect.poll(() => selectedThreadId(page), { timeout: 10_000 }).not.toBe(before); + const created = await selectedThreadId(page); + expect(created).not.toBeNull(); + return created!; } test.describe('Chat management functional coverage', () => { @@ -159,12 +170,19 @@ test.describe('Chat management functional coverage', () => { const threadId = await newThread(page); const title = `Playwright thread ${Date.now()}`; + // Inline rename from the conversation header (restored after #3751). The + // chat-as-home surface may auto-select a different empty thread, so assert + // on the unique renamed title appearing in the thread list rather than + // pinning to a specific row — that deterministically proves the header + // rename committed end-to-end. (Rename targeting is covered deterministically + // by the Conversations rename unit test.) 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 expect(page.getByText(title).first()).toBeVisible({ timeout: 10_000 }); - await page.getByRole('button', { name: 'Show sidebar' }).click(); + // Deletion remains available from the thread row in the chat sidebar, which + // is visible by default on the /chat surface. await page .getByTestId(`thread-row-${threadId}`) .getByTitle('Delete thread') diff --git a/app/test/playwright/specs/core-port-conflict-recovery.spec.ts b/app/test/playwright/specs/core-port-conflict-recovery.spec.ts index 05f39acb7..3b03a164f 100644 --- a/app/test/playwright/specs/core-port-conflict-recovery.spec.ts +++ b/app/test/playwright/specs/core-port-conflict-recovery.spec.ts @@ -12,8 +12,8 @@ test.describe('Core port conflict recovery', () => { await waitForAppReady(page); const text = await page.locator('#root').innerText(); expect( - ['Ask your assistant anything', 'Your device is connected', 'Welcome', 'Get Started'].some( - marker => text.includes(marker) + ['New Conversation', 'Threads', 'Welcome', 'Get Started'].some(marker => + text.includes(marker) ) ).toBe(true); }); diff --git a/app/test/playwright/specs/cron-jobs-flow.spec.ts b/app/test/playwright/specs/cron-jobs-flow.spec.ts index 62b320812..ec3cb31a4 100644 --- a/app/test/playwright/specs/cron-jobs-flow.spec.ts +++ b/app/test/playwright/specs/cron-jobs-flow.spec.ts @@ -18,14 +18,11 @@ test.describe('Cron jobs settings panel', () => { await bootAuthenticatedPage(page, 'pw-cron-jobs-flow', '/home'); }); - test('home screen is reachable after login', async ({ page }) => { + test('chat surface is reachable after login', async ({ page }) => { + // Home folded into the unified chat surface: post-login landing is /chat. await waitForAppReady(page); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true); }); test('cron jobs panel renders in the browser lane and surfaces the current fallback state', async ({ diff --git a/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts b/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts index 867ab3983..432d504e0 100644 --- a/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts +++ b/app/test/playwright/specs/linux-cef-deb-runtime.spec.ts @@ -21,11 +21,9 @@ test.describe('Linux CEF deb package runtime', () => { test('main web shell is created and visible', async ({ page }) => { await waitForAppReady(page); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected', 'Home', 'Chat'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads', 'Chat'].some(marker => text.includes(marker))).toBe( + true + ); }); test.skip('native core_rpc_url / tray / CEF packaging assertions are desktop-only', async () => {}); diff --git a/app/test/playwright/specs/login-flow.spec.ts b/app/test/playwright/specs/login-flow.spec.ts index a31443d6e..c2b0015a1 100644 --- a/app/test/playwright/specs/login-flow.spec.ts +++ b/app/test/playwright/specs/login-flow.spec.ts @@ -50,14 +50,18 @@ test.describe('Login Flow', () => { test('callback login consumes the mock login token and lands on home', async ({ page }) => { await signInViaCallbackToken(page, 'playwright-login-token'); - await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toMatch(/^#\/(home|chat)/); await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy(); }); test('bypass login skips token consume and still lands on home', async ({ page }) => { await signInViaBypassUser(page, 'playwright-bypass-user'); - await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toMatch(/^#\/(home|chat)/); const consumeCall = (await requests()).find( request => request.method === 'POST' && request.url.includes('/telegram/login-tokens/') diff --git a/app/test/playwright/specs/logout-relogin-onboarding.spec.ts b/app/test/playwright/specs/logout-relogin-onboarding.spec.ts index cd64b01c7..50d3feb43 100644 --- a/app/test/playwright/specs/logout-relogin-onboarding.spec.ts +++ b/app/test/playwright/specs/logout-relogin-onboarding.spec.ts @@ -48,7 +48,7 @@ async function completeCloudOnboarding(page: Page): Promise { await clickOnboardingNext(page); const reachedHome = await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 15_000 }) - .toMatch(/^#\/home/) + .toMatch(/^#\/(home|chat)/) .then( () => true, () => false diff --git a/app/test/playwright/specs/mcp-tab-flow.spec.ts b/app/test/playwright/specs/mcp-tab-flow.spec.ts index 729815019..64476247d 100644 --- a/app/test/playwright/specs/mcp-tab-flow.spec.ts +++ b/app/test/playwright/specs/mcp-tab-flow.spec.ts @@ -478,8 +478,10 @@ test.describe('MCP Tab — Empty & Edge States', () => { await navigateToMcpTab(page); await page.getByRole('button', { name: /Installed/ }).click(); - const emptyMsg = page.locator('text=/no.*servers|no.*installed/i'); - await expect(emptyMsg).toBeVisible({ timeout: 5_000 }); + // Target the empty-state element directly: a broad `text=/no.*servers/i` + // locator also matches ancestor containers (the root shell wraps the panel), + // tripping Playwright strict mode. + await expect(page.getByTestId('mcp-installed-empty')).toBeVisible({ timeout: 10_000 }); }); test('search with no results shows no-results message', async ({ page }) => { @@ -504,8 +506,8 @@ test.describe('MCP Tab — Empty & Edge States', () => { await navigateToMcpTab(page); await page.locator('input[type="search"]').fill('xyznonexistent999'); - // Wait for the no-results state to appear rather than using a fixed delay - const noResults = page.locator('text=/no.*results|no.*found|no.*servers/i'); - await expect(noResults).toBeVisible({ timeout: 5_000 }); + // Target the catalog empty-state element directly — a broad text regex also + // matches the root-shell ancestor container and trips strict mode. + await expect(page.getByTestId('mcp-catalog-empty')).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/app/test/playwright/specs/navigation-smoothness.spec.ts b/app/test/playwright/specs/navigation-smoothness.spec.ts index de7f3e2ea..042bb8eff 100644 --- a/app/test/playwright/specs/navigation-smoothness.spec.ts +++ b/app/test/playwright/specs/navigation-smoothness.spec.ts @@ -10,13 +10,15 @@ interface RouteCheck { const routes: RouteCheck[] = [ { hash: '/chat', markers: ['Threads', 'Chat', 'Message', 'New'] }, { hash: '/connections', markers: ['Composio', 'Channels', 'MCP Servers', 'Skills'] }, - { hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] }, + // Home folded into the unified chat surface — /home redirects to /chat. + { hash: '/home', markers: ['New Conversation'] }, { hash: '/channels', markers: ['Channels', 'Connections', 'Telegram', 'Discord'] }, { hash: '/notifications', markers: ['Notifications', 'Alerts', 'No alerts yet'] }, { hash: '/rewards', markers: ['Rewards', 'Referral', 'Credits', 'Invite'] }, { hash: '/settings', markers: ['Settings', 'Account', 'Billing', 'Advanced'] }, { hash: '/settings/notifications-hub', markers: ['Notifications'] }, - { hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] }, + // Home folded into the unified chat surface — /home redirects to /chat. + { hash: '/home', markers: ['New Conversation'] }, ]; async function rootTextLength(page: import('@playwright/test').Page): Promise { @@ -53,11 +55,13 @@ test.describe('Navigation Smoothness', () => { } }); - test('final state is /home with correct content', async ({ page }) => { + test('final state is the chat surface with correct content', async ({ page }) => { + // Home folded into the unified chat surface: /home redirects to /chat and + // the chat "new window" empty state renders the former Home hero card. await page.goto('/#/home'); await waitForAppReady(page); - await expect(page.getByRole('button', { name: /Ask your assistant anything/i })).toBeVisible(); - await expect(page.getByText(/Your device is connected/i)).toBeVisible(); - await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/); + await expect(page.locator('[data-walkthrough="home-card"]')).toBeVisible(); + await expect(page.getByText('New Conversation')).toBeVisible(); + await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/chat/); }); }); diff --git a/app/test/playwright/specs/navigation.spec.ts b/app/test/playwright/specs/navigation.spec.ts index 460616942..51818b819 100644 --- a/app/test/playwright/specs/navigation.spec.ts +++ b/app/test/playwright/specs/navigation.spec.ts @@ -14,8 +14,9 @@ interface RouteEntry { // /skills → /connections (Phase 2) // /activity → /settings/notifications (Phase 6) // /intelligence → /settings/notifications (Phase 6) +// /home → /chat (Home folded into the unified two-panel chat surface) const ROUTES: RouteEntry[] = [ - { route: '/home' }, + { route: '/home', expectedHash: '/chat' }, // back-compat redirect (Home → chat) { route: '/human' }, // first-class route again (no longer redirects to /chat) { route: '/chat' }, { route: '/connections' }, diff --git a/app/test/playwright/specs/onboarding-modes.spec.ts b/app/test/playwright/specs/onboarding-modes.spec.ts index 608c00b66..8eb04deab 100644 --- a/app/test/playwright/specs/onboarding-modes.spec.ts +++ b/app/test/playwright/specs/onboarding-modes.spec.ts @@ -61,7 +61,7 @@ async function expectOnboardingCompleted(): Promise { async function ensureHomeOrForceComplete(page: Page): Promise { const reachedHome = await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 20_000 }) - .toMatch(/^#\/home/) + .toMatch(/^#\/(home|chat)/) .then( () => true, () => false diff --git a/app/test/playwright/specs/runtime-picker-login.spec.ts b/app/test/playwright/specs/runtime-picker-login.spec.ts index a994ce6a6..9fda33b7e 100644 --- a/app/test/playwright/specs/runtime-picker-login.spec.ts +++ b/app/test/playwright/specs/runtime-picker-login.spec.ts @@ -110,7 +110,9 @@ test.describe('Runtime picker -> login -> logout', () => { await signInViaBypassUser(page, 'pw-runtime-picker-login'); await dismissWalkthroughIfPresent(page); - await expect.poll(async () => page.evaluate(() => window.location.hash)).toMatch(/^#\/home/); + await expect + .poll(async () => page.evaluate(() => window.location.hash)) + .toMatch(/^#\/(home|chat)/); await expect(await waitForMockRequest('GET', '/auth/me')).toBeTruthy(); await page.goto('/#/settings/account'); diff --git a/app/test/playwright/specs/skill-execution-flow.spec.ts b/app/test/playwright/specs/skill-execution-flow.spec.ts index 92ab3bafd..690b82829 100644 --- a/app/test/playwright/specs/skill-execution-flow.spec.ts +++ b/app/test/playwright/specs/skill-execution-flow.spec.ts @@ -11,11 +11,7 @@ test.describe('Skill discovery (UI + core RPC)', () => { test('lands the user on a logged-in shell', async ({ page }) => { await waitForAppReady(page); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true); }); test('core.ping responds over the same JSON-RPC URL the UI uses', async () => { diff --git a/app/test/playwright/specs/skill-socket-reconnect.spec.ts b/app/test/playwright/specs/skill-socket-reconnect.spec.ts index bd3ce9039..3c0ee70ef 100644 --- a/app/test/playwright/specs/skill-socket-reconnect.spec.ts +++ b/app/test/playwright/specs/skill-socket-reconnect.spec.ts @@ -7,12 +7,14 @@ import { } from '../helpers/core-rpc'; test.describe('Socket reconnect skill sync smoke', () => { - test('reaches Home after login as baseline for post-reconnect flows', async ({ page }) => { + test('reaches the chat surface after login as baseline for post-reconnect flows', async ({ + page, + }) => { + // Home folded into the unified chat surface: post-login landing is /chat, + // whose "new window" empty state renders the former Home hero card. await bootAuthenticatedPage(page, 'pw-skill-socket-reconnect', '/home'); await waitForAppReady(page); await dismissWalkthroughIfPresent(page); - await expect( - page.getByRole('button', { name: 'Ask your assistant anything...' }) - ).toBeVisible(); + await expect(page.locator('[data-walkthrough="home-card"]')).toBeVisible(); }); }); diff --git a/app/test/playwright/specs/tauri-commands.spec.ts b/app/test/playwright/specs/tauri-commands.spec.ts index f537be3f7..13299fde2 100644 --- a/app/test/playwright/specs/tauri-commands.spec.ts +++ b/app/test/playwright/specs/tauri-commands.spec.ts @@ -11,11 +11,9 @@ test.describe('Tauri commands', () => { test('app chrome is visible', async ({ page }) => { await waitForAppReady(page); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected', 'Home', 'Chat'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads', 'Chat'].some(marker => text.includes(marker))).toBe( + true + ); }); test('browser lane exposes the core RPC URL and token bootstrap values', async ({ page }) => { diff --git a/app/test/playwright/specs/top-level-functional-flows.spec.ts b/app/test/playwright/specs/top-level-functional-flows.spec.ts index a119761ac..91ff31275 100644 --- a/app/test/playwright/specs/top-level-functional-flows.spec.ts +++ b/app/test/playwright/specs/top-level-functional-flows.spec.ts @@ -94,9 +94,10 @@ test.describe('Top-level functional flows', () => { 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/], + // Home folded into the unified chat surface — /home redirects to /chat. + ['/home', /New Conversation|Threads/], ['/connections', /Composio Integrations|Composio|Channels|MCP Servers/], - ['/chat', /How can I help you today|No messages yet|Threads/], + ['/chat', /New Conversation|No messages yet|Threads/], ['/settings/notifications-hub', /Notifications/], ['/notifications', /Notifications|System Events/], ['/rewards', /Rewards|Referrals|Redeem/], diff --git a/app/test/playwright/specs/user-journey-full-task.spec.ts b/app/test/playwright/specs/user-journey-full-task.spec.ts index ea1e9eb25..7453184bb 100644 --- a/app/test/playwright/specs/user-journey-full-task.spec.ts +++ b/app/test/playwright/specs/user-journey-full-task.spec.ts @@ -137,14 +137,21 @@ test.describe('User journey - full research task', () => { await sendMessage(page, PROMPT); await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 45_000 }); + // Navigate away and back to confirm the thread (and its messages) persist. + // Home folded into the unified chat surface, so /home now redirects to + // /chat — the landing hash settles on /chat. await page.goto('/#/home'); await waitForAppReady(page); await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 }) - .toContain('/home'); + .toContain('/chat'); + // The chat-as-home surface lands on its "new window" hero rather than + // re-opening the last thread, so re-select the thread from the sidebar to + // confirm its messages persisted across navigation. await page.goto('/#/chat'); await waitForAppReady(page); + await page.getByTestId(`thread-row-${threadId}`).click({ force: true }); await expect(page.getByText(CANARY_FINAL).first()).toBeVisible({ timeout: 15_000 }); }); }); diff --git a/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts b/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts index a36c7208d..282bc91df 100644 --- a/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts +++ b/app/test/playwright/specs/user-journey-settings-round-trip.spec.ts @@ -17,9 +17,10 @@ const panels: PanelCheck[] = [ hash: '/settings/billing', markers: ['Billing moved to the web', 'Open billing dashboard', 'credits'], }, - { hash: '/home', markers: ['Ask your assistant anything', 'Your device is connected'] }, + // Home folded into the unified chat surface — /home redirects to /chat. + { hash: '/home', markers: ['New Conversation'] }, // /chat is the Assistant surface (thread list + agent chat header). - { hash: '/chat', markers: ['Threads', 'New thread', 'Talk to Tiny', 'Reasoning'] }, + { hash: '/chat', markers: ['New Conversation', 'Threads', 'New thread', 'Reasoning'] }, ]; async function waitForPanelLoad(page: Parameters[0]['page']) { @@ -34,17 +35,14 @@ test.describe('User journey - settings round-trip', () => { await bootAuthenticatedPage(page, 'pw-settings-round-trip-' + testSlug, '/home'); }); - test('starts on /home after login', async ({ page }) => { + test('starts on the chat surface after login', async ({ page }) => { + // Home folded into the unified chat surface: post-login landing is /chat. await waitForAppReady(page); await expect .poll(async () => page.evaluate(() => window.location.hash), { timeout: PANEL_TIMEOUT }) - .toMatch(/^#\/home/); + .toMatch(/^#\/chat/); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true); }); for (const panel of panels) { diff --git a/app/test/playwright/specs/webhooks-ingress-flow.spec.ts b/app/test/playwright/specs/webhooks-ingress-flow.spec.ts index fe267e024..f68b9ccc0 100644 --- a/app/test/playwright/specs/webhooks-ingress-flow.spec.ts +++ b/app/test/playwright/specs/webhooks-ingress-flow.spec.ts @@ -11,11 +11,7 @@ test.describe('Webhooks ingress surface (stub-level)', () => { test('reaches the app shell after onboarding', async ({ page }) => { await waitForAppReady(page); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true); }); test('exposes the stub webhook RPC surface with stable result and log shapes', async () => { diff --git a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts index 31c2833a4..0b70a5164 100644 --- a/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts +++ b/app/test/playwright/specs/webhooks-tunnel-flow.spec.ts @@ -63,11 +63,7 @@ test.describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => { test('reached the logged-in shell after onboarding', async ({ page }) => { await waitForAppReady(page); const text = await page.locator('#root').innerText(); - expect( - ['Ask your assistant anything', 'Your device is connected'].some(marker => - text.includes(marker) - ) - ).toBe(true); + expect(['New Conversation', 'Threads'].some(marker => text.includes(marker))).toBe(true); }); test('creates a tunnel, lists it, deletes it, and matches mock-backend traffic', async () => { diff --git a/src/openhuman/workflows/ops_create.rs b/src/openhuman/workflows/ops_create.rs index 813c9b51a..b6fd90500 100644 --- a/src/openhuman/workflows/ops_create.rs +++ b/src/openhuman/workflows/ops_create.rs @@ -707,8 +707,12 @@ mod render_skill_toml_tests { let mut saw = false; loop { match rx.try_recv() { - Ok(DomainEvent::WorkflowsChanged { reason }) => { - assert_eq!(reason, "create"); + // The event bus is a process-wide singleton, so other tests + // running in parallel publish their own WorkflowsChanged events + // (e.g. "install"/"uninstall" from ops_install). Match only our + // own "create" reason and skip the rest rather than asserting on + // whichever event happens to arrive first. + Ok(DomainEvent::WorkflowsChanged { reason }) if reason == "create" => { saw = true; break; }