mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -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')}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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 () => {});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user