mirror of
https://github.com/hanzoai/extension.git
synced 2026-10-31 21:57:06 +00:00
The sidebar drew chat with hand-written markup in sidebar.html and ~200 lines of DOM in sidebar.ts — its own composer, its own five-regex markdown, its own scroll-to-bottom, its own typing dots. The new tab drew it with a second composer, a second model menu and a second citation chip. Both are now the shared shell: Thread, Message, Composer, Sources, Code, Step and Failure from @hanzo/ui/chat, and ModelSelector from @hanzo/ui/models. sidebar.html declares one mount node and no chat control at all, so there is nothing for a second implementation to render over — the stronger form of the invariant sidebar-composer.test.ts was written to hold after chat-widget.tsx detached every cached handle. sidebar.ts keeps the whole wire (auth, retrieval, budgets, dedupe, the stream, persistence) and passes values and callbacks. Deleted: answer/Composer.tsx, answer/Models.tsx, answer/Source.tsx, the sidebar's private markdown, and ~700 lines of CSS for controls the shell now draws. The two markdown renderers become one shared/markdown.ts, which also splits fenced code out so it renders through the shell's Code with its copy control. Failure carries a retry, and retrying clears the dedupe key — a retry is by definition the request that just ran, so without that the control does nothing at all. The rail stays local. The shell's Sidebar has no collapsed presentation and its SidebarIconButton takes only a label, so it cannot carry the aria-expanded that answer-rail.test.ts asserts; a rail that cannot announce its own state is worse than one that is not the shell's. Three shell shapes had to be worked around, all found by driving the built extension rather than a jsdom render: ModelSelector's trigger is width:100% and pushes the send control off a narrow row; Thread's gutters are coupled to its reading-measure cap, so opting out of the cap removes them; the Composer toolbar row does not wrap for its children. The esbuild config that makes gui resolve for the web is now stated once instead of per-entry — the sidebar built without it and died on Flow syntax inside react-native, an error naming neither the alias nor either surface. The live-IAM e2e reads its password from the environment rather than from a line in the repo.
134 lines
6.0 KiB
TypeScript
134 lines
6.0 KiB
TypeScript
import { test, expect } from './fixtures';
|
|
|
|
/**
|
|
* The sidebar, driven in a real Chrome with the extension loaded.
|
|
*
|
|
* Chat is `@hanzo/ui/chat`, so the chat assertions select on the shell's own
|
|
* `data-slot` contract rather than on ids this page used to write by hand. That
|
|
* is the point of adopting the shell: the same selectors hold on every Hanzo
|
|
* surface, and a class or an id churning inside the design system does not
|
|
* silently pass a test that has stopped looking at anything.
|
|
*/
|
|
|
|
const open = async (context, extensionId: string) => {
|
|
const page = await context.newPage();
|
|
await page.setViewportSize({ width: 400, height: 800 });
|
|
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
|
return page;
|
|
};
|
|
|
|
test.describe('Sidebar', () => {
|
|
test('loads and shows tab bar immediately (no auth gate)', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
await expect(page.locator('#tab-bar')).toBeVisible();
|
|
});
|
|
|
|
test('defaults to chat tab', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
await expect(page.locator('#tab-chat')).toBeVisible();
|
|
});
|
|
|
|
test('chat renders the shared shell, mounted once', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
// One mount node, one React root, one composer — the invariant the sidebar
|
|
// lost the last time two implementations raced for the same container.
|
|
await expect(page.locator('#chat')).toHaveCount(1);
|
|
await expect(page.locator('#chat > *')).toHaveCount(1);
|
|
});
|
|
|
|
test('chat asks for sign-in, and offers no composer, when signed out', async ({
|
|
context,
|
|
extensionId,
|
|
}) => {
|
|
const page = await open(context, extensionId);
|
|
await page.locator('[data-tab="chat"]').click();
|
|
|
|
const chat = page.locator('#chat');
|
|
await expect(chat).toContainText('Sign in to Hanzo AI');
|
|
await expect(chat).toContainText('Zen AI models');
|
|
await expect(chat.getByRole('button', { name: 'Sign in' })).toBeVisible();
|
|
await expect(chat.getByRole('button', { name: 'Create account' })).toBeVisible();
|
|
await expect(chat.locator('a[href="https://docs.hanzo.ai"]')).toBeAttached();
|
|
|
|
// Signed out there is nothing to type into: the composer is not rendered.
|
|
await expect(page.locator('[data-slot="composer"]')).toHaveCount(0);
|
|
});
|
|
|
|
test('has chat, tools, and settings tabs', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
await expect(page.locator('[data-tab="chat"]')).toBeAttached();
|
|
await expect(page.locator('[data-tab="tools"]')).toBeAttached();
|
|
await expect(page.locator('[data-tab="settings"]')).toBeAttached();
|
|
});
|
|
|
|
test('tab switching works', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
|
|
await page.locator('[data-tab="chat"]').click();
|
|
await expect(page.locator('#tab-chat')).toBeVisible();
|
|
await expect(page.locator('#tab-tools')).toHaveClass(/hidden/);
|
|
|
|
await page.locator('[data-tab="tools"]').click();
|
|
await expect(page.locator('#tab-tools')).toBeVisible();
|
|
await expect(page.locator('#tab-chat')).toHaveClass(/hidden/);
|
|
|
|
await page.locator('[data-tab="settings"]').click();
|
|
await expect(page.locator('#tab-settings')).toBeVisible();
|
|
await expect(page.locator('#tab-tools')).toHaveClass(/hidden/);
|
|
});
|
|
|
|
test('the settings default-model select is filled from the catalog', async ({
|
|
context,
|
|
extensionId,
|
|
}) => {
|
|
const page = await open(context, extensionId);
|
|
// Auto is always there — it is the router, always valid — and it is written
|
|
// in exactly one place, so an empty select would mean the catalog never ran.
|
|
await expect(page.locator('#default-model option')).not.toHaveCount(0);
|
|
await expect(page.locator('#default-model')).toContainText('Auto');
|
|
});
|
|
|
|
test('tools tab has ZAP status and MCP info', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
await expect(page.locator('#mcp-status')).toBeAttached();
|
|
await expect(page.locator('#mcp-tools')).toBeAttached();
|
|
await expect(page.locator('#mcp-tool-list')).toBeAttached();
|
|
await expect(page.locator('#agent-count')).toBeAttached();
|
|
await expect(page.locator('#tab-fs')).toBeAttached();
|
|
await expect(page.locator('#hanzo-node-status')).toBeAttached();
|
|
await expect(page.locator('#hanzo-mcp-status')).toBeAttached();
|
|
});
|
|
|
|
test('settings tab has account and connection config', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
await expect(page.locator('#user-name')).toBeAttached();
|
|
await expect(page.locator('#user-email')).toBeAttached();
|
|
await expect(page.locator('#logout-btn')).toBeAttached();
|
|
await expect(page.locator('#mcp-port-setting')).toBeAttached();
|
|
await expect(page.locator('#cdp-port-setting')).toBeAttached();
|
|
await expect(page.locator('#node-port-setting')).toBeAttached();
|
|
await expect(page.locator('#rag-include-tab-context')).toBeAttached();
|
|
await expect(page.locator('#rag-top-k')).toBeAttached();
|
|
await expect(page.locator('#rag-kb')).toBeAttached();
|
|
await expect(page.locator('#rag-endpoint')).toBeAttached();
|
|
await expect(page.locator('#rag-api-key')).toBeAttached();
|
|
await expect(page.locator('#save-settings')).toBeAttached();
|
|
});
|
|
|
|
test('settings has links to console, billing, docs', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
const links = page.locator('.settings-links a');
|
|
const hrefs = await links.evaluateAll((els) => els.map((el: HTMLAnchorElement) => el.href));
|
|
expect(hrefs).toContain('https://console.hanzo.ai/');
|
|
expect(hrefs).toContain('https://billing.hanzo.ai/');
|
|
expect(hrefs).toContain('https://docs.hanzo.ai/');
|
|
});
|
|
|
|
test('launch agent button exists', async ({ context, extensionId }) => {
|
|
const page = await open(context, extensionId);
|
|
const launchBtn = page.locator('#launch-agent');
|
|
await expect(launchBtn).toBeAttached();
|
|
await expect(launchBtn).toContainText('Launch Agent');
|
|
});
|
|
});
|