fix(e2e): adapt Playwright specs + Vitest setup to chat-as-home shell (#3751) (#3754)

This commit is contained in:
Steven Enamakel
2026-06-18 17:57:45 +05:30
committed by GitHub
parent 628f40d69b
commit d798bf8299
29 changed files with 372 additions and 90 deletions
@@ -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 () => {
@@ -457,12 +457,16 @@ const McpServersTab = () => {
{/* Empty states */}
{activeChip === 'installed' && filteredInstalled.length === 0 && (
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
<div
data-testid="mcp-installed-empty"
className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
{t('mcp.installed.empty')}
</div>
)}
{activeChip === 'registry' && filteredCatalog.length === 0 && !catalogLoading && (
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
<div
data-testid="mcp-catalog-empty"
className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
{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 && (
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
<div
data-testid="mcp-catalog-empty"
className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
{searchQuery
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
: t('mcp.catalog.noResults')}
+107
View File
@@ -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<string | null>(null);
// Inline thread-title rename in the conversation header.
const [editingTitle, setEditingTitle] = useState(false);
const [editTitleValue, setEditTitleValue] = useState('');
const editTitleInputRef = useRef<HTMLInputElement>(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 = ({
</button>
)}
{/* Thread title + inline rename (page variant). Hover the title to
reveal the edit affordance; Enter commits, Escape cancels. */}
{!isSidebar && selectedThreadId && (
<div className="mt-2 min-w-0">
{editingTitle ? (
<input
ref={editTitleInputRef}
value={editTitleValue}
onChange={e => 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
/>
) : (
<div className="group/title flex min-w-0 items-center gap-1">
<h3 className="truncate text-sm font-medium text-stone-700 dark:text-neutral-200">
{resolveThreadDisplayTitle(selectedThreadId)}
</h3>
<button
type="button"
data-analytics-id="chat-header-edit-thread-title"
onMouseDown={e => {
e.preventDefault();
handleStartEditTitle();
}}
onClick={handleStartEditTitle}
aria-label={t('chat.editThreadTitle')}
title={t('chat.editThreadTitle')}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-stone-400 opacity-0 transition-all hover:bg-stone-100 hover:text-stone-600 group-hover/title:opacity-100 group-focus-within/title:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-300 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
</button>
</div>
)}
</div>
)}
{/* Model + token stats (left) and the quick/reasoning toggle + files
chip (right) share one line. */}
<div
@@ -638,3 +638,123 @@ describe('Conversations — attachment feature', () => {
});
});
});
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<typeof vi.fn>).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();
});
});
});
+13 -1
View File
@@ -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() {
+11 -5
View File
@@ -78,13 +78,19 @@ 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 /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<void> {
.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<void> {
.poll(async () => page.evaluate(() => window.location.hash), {
timeout: AUTH_CALLBACK_HOME_TIMEOUT_MS,
})
.toMatch(/^#\/home/);
.toMatch(/^#\/chat/);
}
export async function resetCoreForWebGuest(): Promise<void> {
@@ -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');
@@ -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();
@@ -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(
@@ -65,9 +65,20 @@ async function openChat(page: Page, userId: string): Promise<void> {
}
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))!;
// 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')
@@ -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);
});
@@ -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 ({
@@ -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 () => {});
+6 -2
View File
@@ -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/')
@@ -48,7 +48,7 @@ async function completeCloudOnboarding(page: Page): Promise<void> {
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
@@ -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 });
});
});
@@ -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<number> {
@@ -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/);
});
});
+2 -1
View File
@@ -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' },
@@ -61,7 +61,7 @@ async function expectOnboardingCompleted(): Promise<void> {
async function ensureHomeOrForceComplete(page: Page): Promise<void> {
const reachedHome = await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 20_000 })
.toMatch(/^#\/home/)
.toMatch(/^#\/(home|chat)/)
.then(
() => true,
() => false
@@ -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');
@@ -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 () => {
@@ -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();
});
});
@@ -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 }) => {
@@ -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/],
@@ -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 });
});
});
@@ -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<typeof test>[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) {
@@ -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 () => {
@@ -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 () => {
+6 -2
View File
@@ -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;
}