Files
nexu-io--open-design/apps/web/tests/components/ProjectView.pendingPrompt.test.tsx
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

1172 lines
37 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import type { Brand } from '@open-design/contracts';
import type { ComponentProps, ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import { BROWSER_PAGE_ARCHIVE_SCHEMA } from '../../src/components/design-browser-tools';
import type {
AgentInfo,
AppConfig,
ChatMessage,
Conversation,
DesignSystemSummary,
Project,
SkillSummary,
} from '../../src/types';
import {
createConversation,
listConversations,
listMessages,
saveMessage,
} from '../../src/state/projects';
import { fetchPreviewComments } from '../../src/providers/registry';
import {
cancelBrandExtraction,
continueBrandExtraction,
extractBrandFromHtml,
fetchBrands,
} from '../../src/runtime/brands';
const brandBrowserBridgeMocks = vi.hoisted(() => ({
getBrandBrowser: vi.fn(),
}));
const fileWorkspaceSpy = vi.hoisted(() => vi.fn());
const chatPaneSpy = vi.hoisted(() => vi.fn());
type MockChatPaneProps = {
messages?: ChatMessage[];
activeConversationId?: string | null;
initialDraft?: string;
onBrandBrowserAssistConfirm?: (card: {
kind: 'brand-browser-assist';
brandId: string;
browserTabId?: string;
url?: string;
reason?: string;
}) => Promise<{ ok: boolean; action?: 'opened' | 'confirmed'; message?: string } | void>;
onContinueBrandExtraction?: () => void;
continueBrandExtractionBusy?: boolean;
onContinueBrandAgentExtraction?: () => void;
continueBrandAgentExtractionBusy?: boolean;
};
vi.mock('../../src/i18n', () => ({
useI18n: () => ({
locale: 'en',
setLocale: () => undefined,
t: (key: string) => key,
}),
useT: () => (key: string) => key,
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/providers/anthropic', () => ({
streamMessage: vi.fn(),
}));
vi.mock('../../src/providers/daemon', () => ({
fetchChatRunStatus: vi.fn(),
listActiveChatRuns: vi.fn().mockResolvedValue([]),
listProjectRuns: vi.fn().mockResolvedValue([]),
reattachDaemonRun: vi.fn(),
streamViaDaemon: vi.fn(),
}));
vi.mock('../../src/providers/project-events', () => ({
useProjectFileEvents: vi.fn(),
}));
vi.mock('../../src/runtime/brands', async () => {
const actual = await vi.importActual<typeof import('../../src/runtime/brands')>(
'../../src/runtime/brands',
);
return {
...actual,
cancelBrandExtraction: vi.fn().mockResolvedValue({ ok: true, status: 'failed' }),
continueBrandExtraction: vi.fn().mockResolvedValue({
ok: true,
result: {
id: 'brand-retry',
projectId: 'brand-retry',
conversationId: 'conv-brand-retry',
sourceUrl: 'https://economist.com/',
status: 'extracting',
designSystemId: 'user:brand-retry',
},
}),
extractBrandFromHtml: vi.fn().mockResolvedValue({
ok: true,
result: {
id: 'brand-retry',
brand: null,
designSystemId: 'user:brand-retry',
projectId: 'brand-retry',
files: [],
},
}),
fetchBrands: vi.fn().mockResolvedValue([]),
};
});
vi.mock('../../src/runtime/brand-browser-bridge', () => ({
BRAND_BROWSER_TAB_ID: '__browser__:1',
getBrandBrowser: brandBrowserBridgeMocks.getBrandBrowser,
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
deletePreviewComment: vi.fn(),
fetchDesignSystem: vi.fn(),
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
fetchPreviewComments: vi.fn(),
fetchProjectFiles: vi.fn().mockResolvedValue([]),
fetchSkill: vi.fn(),
getTemplate: vi.fn(),
patchPreviewCommentStatus: vi.fn(),
upsertPreviewComment: vi.fn(),
writeProjectTextFile: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
createConversation: vi.fn(),
listConversations: vi.fn(),
listMessages: vi.fn(),
loadTabs: vi.fn().mockResolvedValue({ tabs: [], active: null }),
patchConversation: vi.fn(),
patchProject: vi.fn(),
saveMessage: vi.fn(),
saveTabs: vi.fn(),
};
});
vi.mock('../../src/components/AppChromeHeader', () => ({
AppChromeHeader: ({ children }: { children: ReactNode }) => (
<header>{children}</header>
),
}));
vi.mock('../../src/components/AvatarMenu', () => ({
AvatarMenu: () => null,
}));
vi.mock('../../src/components/FileWorkspace', () => ({
DESIGN_SYSTEM_TAB: '__design_system__',
FileWorkspace: (props: {
openRequest?: { name: string } | null;
browserOpenRequest?: { tabId?: string; url: string; nonce: number } | null;
onBrandExtractionStopRequest?: () => void;
designSystemEditable?: boolean;
filesRefreshKey?: number;
}) => {
fileWorkspaceSpy(props);
return <div data-testid="file-workspace" />;
},
}));
vi.mock('../../src/components/Loading', () => ({
CenteredLoader: () => <div data-testid="loader" />,
}));
vi.mock('../../src/components/ChatPane', () => ({
ChatPane: (props: MockChatPaneProps) => {
chatPaneSpy(props);
return (
<>
<div data-testid="active-conversation">{props.activeConversationId ?? ''}</div>
<div data-testid="chat-message-content">
{props.messages?.map((message) => message.content).join('\n') ?? ''}
</div>
<textarea
data-testid="chat-composer-input"
readOnly
value={props.initialDraft ?? ''}
/>
</>
);
},
}));
const mockedListConversations = vi.mocked(listConversations);
const mockedCreateConversation = vi.mocked(createConversation);
const mockedListMessages = vi.mocked(listMessages);
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
const mockedCancelBrandExtraction = vi.mocked(cancelBrandExtraction);
const mockedContinueBrandExtraction = vi.mocked(continueBrandExtraction);
const mockedExtractBrandFromHtml = vi.mocked(extractBrandFromHtml);
const mockedFetchBrands = vi.mocked(fetchBrands);
const mockedSaveMessage = vi.mocked(saveMessage);
const config: AppConfig = {
mode: 'api',
apiKey: '',
baseUrl: '',
model: '',
agentId: null,
skillId: null,
designSystemId: null,
};
const project = (id: string, pendingPrompt?: string): Project => ({
id,
name: `Project ${id}`,
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
...(pendingPrompt ? { pendingPrompt } : {}),
});
const conversation = (projectId: string): Conversation => ({
id: `conv-${projectId}`,
projectId,
title: null,
createdAt: 1,
updatedAt: 1,
});
function renderProjectView(
currentProject: Project,
onClearPendingPrompt = vi.fn(),
overrides: Partial<ComponentProps<typeof ProjectView>> = {},
) {
return render(
<ProjectView
project={currentProject}
routeFileName={null}
config={config}
agents={[] as AgentInfo[]}
skills={[] as SkillSummary[]}
designTemplates={[] as SkillSummary[]}
designSystems={[] as DesignSystemSummary[]}
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onRefreshAgents={vi.fn()}
onOpenSettings={vi.fn()}
onBack={vi.fn()}
onClearPendingPrompt={onClearPendingPrompt}
onTouchProject={vi.fn()}
onProjectChange={vi.fn()}
onProjectsRefresh={vi.fn()}
{...overrides}
/>,
);
}
describe('ProjectView pending prompt seeding', () => {
beforeEach(() => {
window.sessionStorage.clear();
window.localStorage.clear();
mockedListConversations.mockImplementation(async (projectId) => [
conversation(projectId),
]);
mockedCreateConversation.mockImplementation(async (projectId) =>
conversation(projectId),
);
mockedListMessages.mockResolvedValue([]);
mockedFetchPreviewComments.mockResolvedValue([]);
mockedFetchBrands.mockResolvedValue([]);
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue(null);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('prefills chat once when the project has a pending prompt and requests persistence clear', async () => {
const onClearPendingPrompt = vi.fn();
renderProjectView(project('with-prompt', 'Use this prompt'), onClearPendingPrompt);
await waitFor(() => {
expect(composerValue()).toBe('Use this prompt');
});
expect(onClearPendingPrompt).toHaveBeenCalledTimes(1);
});
it('auto-sends the Home-carried workspace context with the first user message', async () => {
const projectId = 'with-context';
const workspaceItem = {
id: 'project:reference-a',
kind: 'project',
label: 'Reference A',
title: 'Reference A',
path: 'reference-a',
absolutePath: '/tmp/open-design/missing-reference-a',
};
window.sessionStorage.setItem(`od:auto-send-first:${projectId}`, '1');
window.sessionStorage.setItem(
`od:auto-send-context:${projectId}`,
JSON.stringify({ workspaceItems: [workspaceItem] }),
);
renderProjectView(project(projectId, 'Use the reference project'));
await waitFor(() => {
expect(mockedSaveMessage).toHaveBeenCalled();
});
const userMessageCall = mockedSaveMessage.mock.calls.find(
([, , message]) => message.role === 'user',
);
expect(userMessageCall?.[0]).toBe(projectId);
expect(userMessageCall?.[1]).toBe(`conv-${projectId}`);
expect(userMessageCall?.[2]).toEqual(expect.objectContaining({
role: 'user',
content: 'Use the reference project',
runContext: {
workspaceItems: [workspaceItem],
},
}));
});
it('does not prefill when re-entering a project after the pending prompt was cleared', async () => {
renderProjectView(project('cleared'));
await waitFor(() => {
expect(composerValue()).toBe('');
});
});
it('refreshes design systems when a brand project references a missing backing system', async () => {
const onDesignSystemsRefresh = vi.fn();
renderProjectView(
{
...project('brand-draft'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-draft',
brandDesignSystemId: 'user:brand-draft',
},
},
vi.fn(),
{ onDesignSystemsRefresh },
);
await waitFor(() => expect(onDesignSystemsRefresh).toHaveBeenCalledTimes(1));
});
it('auto-opens a draft design system tab before the registry summary refreshes', async () => {
renderProjectView(
{
...project('brand-draft-open'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-draft-open',
brandSourceUrl: 'https://nexu.io/',
brandDesignSystemId: 'user:brand-draft-open',
},
},
);
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.openRequest?.name === '__design_system__',
),
).toBe(true);
});
});
it('auto-opens the design system tab once a brand extraction backing system is available', async () => {
renderProjectView(
{
...project('brand-ready'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-ready',
brandDesignSystemId: 'user:brand-ready',
},
},
vi.fn(),
{
designSystems: [
{
id: 'user:brand-ready',
title: 'Brand Ready',
category: 'Brands',
summary: '',
swatches: [],
surface: 'web',
body: '# Brand Ready',
source: 'user',
status: 'draft',
isEditable: true,
} as DesignSystemSummary,
],
},
);
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.openRequest?.name === '__design_system__',
),
).toBe(true);
});
});
it('stops a programmatic brand extraction and returns to the draft design system tab', async () => {
const onDesignSystemsRefresh = vi.fn();
const onProjectsRefresh = vi.fn();
renderProjectView(
{
...project('brand-stop'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-stop',
brandSourceUrl: 'https://nexu.io/',
brandDesignSystemId: 'user:brand-stop',
},
},
vi.fn(),
{ onDesignSystemsRefresh, onProjectsRefresh },
);
await waitFor(() => {
expect(fileWorkspaceSpy.mock.calls.at(-1)?.[0].onBrandExtractionStopRequest).toBeTypeOf('function');
});
expect(fileWorkspaceSpy.mock.calls.at(-1)?.[0].designSystemEditable).toBe(false);
fileWorkspaceSpy.mock.calls.at(-1)?.[0].onBrandExtractionStopRequest?.();
await waitFor(() => expect(mockedCancelBrandExtraction).toHaveBeenCalledWith('brand-stop'));
await waitFor(() => expect(onDesignSystemsRefresh).toHaveBeenCalled());
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.openRequest?.name === '__design_system__',
),
).toBe(true);
});
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.designSystemEditable === true && props.openRequest?.name === '__design_system__',
),
).toBe(true);
});
});
it('continues a programmatic brand extraction from the next-step action', async () => {
const onDesignSystemsRefresh = vi.fn();
const onProjectsRefresh = vi.fn();
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
vi.fn(),
{ onDesignSystemsRefresh, onProjectsRefresh },
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry'));
await waitFor(() => expect(onDesignSystemsRefresh).toHaveBeenCalled());
await waitFor(() => expect(onProjectsRefresh).toHaveBeenCalled());
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.openRequest?.name === 'brand.html',
),
).toBe(true);
});
});
it('switches to the replacement conversation returned by a brand extraction retry', async () => {
const projectId = 'brand-retry-missing';
const replacementConversation = {
...conversation(projectId),
id: 'conv-brand-replacement',
title: 'Brand retry',
};
mockedListConversations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([replacementConversation]);
mockedCreateConversation.mockResolvedValueOnce({
...conversation(projectId),
id: 'conv-brand-empty',
title: 'Empty fallback',
});
mockedListMessages.mockImplementation(async (_projectId, conversationId) => {
if (conversationId === 'conv-brand-replacement') {
return [
{
id: 'replacement-transcript',
role: 'assistant',
content: 'Replacement retry transcript loaded',
createdAt: 3,
},
];
}
return [];
});
mockedContinueBrandExtraction.mockResolvedValueOnce({
ok: true,
result: {
id: projectId,
projectId,
conversationId: 'conv-brand-replacement',
sourceUrl: 'https://economist.com/',
status: 'extracting',
designSystemId: `user:${projectId}`,
},
});
renderProjectView(
{
...project(projectId),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: projectId,
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: `user:${projectId}`,
},
},
);
await waitFor(() => {
expect(screen.getByTestId('active-conversation').textContent).toBe('conv-brand-empty');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => {
expect(mockedContinueBrandExtraction).toHaveBeenCalledWith(projectId);
});
await waitFor(() => {
expect(mockedListMessages).toHaveBeenCalledWith(projectId, 'conv-brand-replacement');
});
await waitFor(() => {
expect(screen.getByTestId('active-conversation').textContent).toBe('conv-brand-replacement');
});
await waitFor(() => {
expect(screen.getByTestId('chat-message-content').textContent).toContain(
'Replacement retry transcript loaded',
);
});
});
it('opens the browser assist tab from the assist card action without extracting yet', async () => {
renderProjectView(
{
...project('brand-open'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-open',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-open',
},
},
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onBrandBrowserAssistConfirm).toBeTypeOf('function');
});
const result = await chatPaneSpy.mock.calls.at(-1)?.[0].onBrandBrowserAssistConfirm?.({
kind: 'brand-browser-assist',
brandId: 'brand-open',
browserTabId: '__browser__:1',
url: 'https://economist.com/',
reason: 'Cloudflare',
});
expect(result).toEqual({ ok: true, action: 'opened' });
expect(mockedExtractBrandFromHtml).not.toHaveBeenCalled();
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.browserOpenRequest?.tabId === '__browser__:1' &&
props.browserOpenRequest.url === 'https://economist.com/',
),
).toBe(true);
});
});
it('continues a blocked brand extraction from the unblocked Browser tab DOM when available', async () => {
const onDesignSystemsRefresh = vi.fn();
const onProjectsRefresh = vi.fn();
const executeJavaScript = vi.fn()
.mockResolvedValueOnce('<html><head><title>Economist</title></head><body><h1>Economist</h1></body></html>')
.mockResolvedValueOnce('body { color: #111111; background: #ffffff; }');
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://economist.com/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
vi.fn(),
{ onDesignSystemsRefresh, onProjectsRefresh },
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => {
expect(mockedExtractBrandFromHtml).toHaveBeenCalledWith('brand-retry', {
html: '<html><head><title>Economist</title></head><body><h1>Economist</h1></body></html>',
css: 'body { color: #111111; background: #ffffff; }',
baseUrl: 'https://economist.com/',
});
});
expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry');
await waitFor(() => expect(onDesignSystemsRefresh).toHaveBeenCalled());
await waitFor(() => expect(onProjectsRefresh).toHaveBeenCalled());
await waitFor(() => {
expect(
fileWorkspaceSpy.mock.calls.some(([props]) =>
props.openRequest?.name === 'brand.html',
),
).toBe(true);
});
});
it('continues to the current Browser tab when a saved page archive is stale', async () => {
const onDesignSystemsRefresh = vi.fn();
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
if (url.includes('/raw/browser/latest-page-snapshot.json')) {
return new Response(JSON.stringify({
schema: BROWSER_PAGE_ARCHIVE_SCHEMA,
capturedAt: 123,
title: 'Stale wall',
url: 'https://economist.com/',
baseUrl: 'https://economist.com/',
htmlFile: 'browser/page-archive/stale.html',
cssFile: 'browser/page-archive/stale.css',
manifestFile: 'browser/latest-page-snapshot.json',
resources: [],
}), { status: 200 });
}
if (url.includes('/raw/browser/page-archive/stale.html')) {
return new Response('<html><body>Human verification required</body></html>', { status: 200 });
}
if (url.includes('/raw/browser/page-archive/stale.css')) {
return new Response('body { color: #888888; }', { status: 200 });
}
return new Response('', { status: 404 });
});
mockedExtractBrandFromHtml
.mockResolvedValueOnce({
ok: false,
error: 'Programmatic extraction blocked by Cloudflare.',
})
.mockResolvedValueOnce({
ok: true,
result: {
id: 'brand-retry',
brand: {
name: 'Economist',
tagline: '',
description: '',
sourceUrl: 'https://economist.com/',
logo: { primary: null, alternates: [], notes: '' },
colors: [],
typography: {
display: { family: 'serif', fallbacks: [], weights: [400] },
body: { family: 'serif', fallbacks: [], weights: [400] },
},
voice: {
adjectives: [],
tone: '',
messagingPillars: [],
vocabulary: { use: [], avoid: [] },
},
imagery: { style: '', subjects: [], treatment: '', avoid: [] },
layout: { radius: '0', borderWeight: '1px', spacing: 'normal', postureRules: [] },
} satisfies Brand,
designSystemId: 'user:brand-retry',
projectId: 'brand-retry',
files: [],
},
});
const executeJavaScript = vi.fn()
.mockResolvedValueOnce('<html><head><title>Economist</title></head><body><h1>Current page</h1></body></html>')
.mockResolvedValueOnce('body { color: #111111; background: #ffffff; }');
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://economist.com/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
vi.fn(),
{ onDesignSystemsRefresh },
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => {
expect(mockedExtractBrandFromHtml).toHaveBeenCalledTimes(2);
});
expect(mockedExtractBrandFromHtml).toHaveBeenNthCalledWith(1, 'brand-retry', {
html: '<html><body>Human verification required</body></html>',
css: 'body { color: #888888; }',
baseUrl: 'https://economist.com/',
});
expect(mockedExtractBrandFromHtml).toHaveBeenNthCalledWith(2, 'brand-retry', {
html: '<html><head><title>Economist</title></head><body><h1>Current page</h1></body></html>',
css: 'body { color: #111111; background: #ffffff; }',
baseUrl: 'https://economist.com/',
});
expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry');
await waitFor(() => expect(onDesignSystemsRefresh).toHaveBeenCalled());
fetchSpy.mockRestore();
});
it('continues browser DOM extraction after a www redirect from the source URL', async () => {
const executeJavaScript = vi.fn()
.mockResolvedValueOnce('<html><head><title>The Economist</title></head><body><h1>The Economist</h1></body></html>')
.mockResolvedValueOnce('body { color: #e3120b; background: #ffffff; }');
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://www.economist.com/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => {
expect(mockedExtractBrandFromHtml).toHaveBeenCalledWith('brand-retry', {
html: '<html><head><title>The Economist</title></head><body><h1>The Economist</h1></body></html>',
css: 'body { color: #e3120b; background: #ffffff; }',
baseUrl: 'https://www.economist.com/',
});
});
expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry');
});
it('continues browser DOM extraction after a same-site locale redirect from the source URL', async () => {
const executeJavaScript = vi.fn()
.mockResolvedValueOnce('<html><head><title>Example</title></head><body><h1>Example</h1></body></html>')
.mockResolvedValueOnce('body { color: #1f2937; background: #ffffff; }');
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://example.com/en/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://example.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => {
expect(mockedExtractBrandFromHtml).toHaveBeenCalledWith('brand-retry', {
html: '<html><head><title>Example</title></head><body><h1>Example</h1></body></html>',
css: 'body { color: #1f2937; background: #ffffff; }',
baseUrl: 'https://example.com/en/',
});
});
expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry');
});
it('falls back to programmatic retry when the Browser tab is on another origin', async () => {
const executeJavaScript = vi.fn()
.mockResolvedValueOnce('<html><head><title>Wrong</title></head><body><h1>Wrong</h1></body></html>')
.mockResolvedValueOnce('body { color: #111111; background: #ffffff; }');
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://wrong.example/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry'));
expect(mockedExtractBrandFromHtml).not.toHaveBeenCalled();
});
it('falls back to programmatic retry when the Browser tab is same-origin but off-path', async () => {
const executeJavaScript = vi.fn()
.mockResolvedValueOnce('<html><head><title>Login</title></head><body><h1>Login</h1></body></html>')
.mockResolvedValueOnce('body { color: #111111; background: #ffffff; }');
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://economist.com/login?next=/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry'));
expect(mockedExtractBrandFromHtml).not.toHaveBeenCalled();
});
it('falls back to programmatic retry when the Browser tab DOM read fails', async () => {
const executeJavaScript = vi.fn().mockRejectedValueOnce(new Error('bridge timed out'));
brandBrowserBridgeMocks.getBrandBrowser.mockReturnValue({
isDesktopWebview: true,
getURL: () => 'https://economist.com/',
executeJavaScript,
});
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => {
expect(chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction).toBeTypeOf('function');
});
chatPaneSpy.mock.calls.at(-1)?.[0].onContinueBrandExtraction?.();
await waitFor(() => expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry'));
expect(mockedExtractBrandFromHtml).not.toHaveBeenCalled();
});
it('does not duplicate a persisted browser-assist card already in the conversation', async () => {
mockedFetchBrands.mockResolvedValue([
{
meta: {
id: 'brand-blocked',
sourceUrl: 'https://economist.com/',
createdAt: 1,
updatedAt: 2,
status: 'failed',
blocked: true,
blockedReason: 'Cloudflare',
designSystemId: 'user:brand-blocked',
},
brand: null,
},
]);
mockedListMessages.mockResolvedValueOnce([
{
id: 'existing-assist',
role: 'assistant',
content:
'chat.brandBrowserAssistMessage\n\n<od-card type="brand-browser-assist">{"brandId":"brand-blocked","browserTabId":"__browser__:1","url":"https://economist.com/","reason":"Cloudflare"}</od-card>',
createdAt: 2,
},
]);
renderProjectView(
{
...project('brand-blocked'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-blocked',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-blocked',
},
},
);
await waitFor(() => expect(mockedFetchBrands).toHaveBeenCalled());
await Promise.resolve();
await Promise.resolve();
expect(
mockedSaveMessage.mock.calls.some((call) =>
String(call[2]?.content ?? '').includes('<od-card type="brand-browser-assist"'),
),
).toBe(false);
});
it('lets a terminal failed poll supersede a local retry status override', async () => {
mockedFetchBrands.mockResolvedValue([
{
meta: {
id: 'brand-retry',
sourceUrl: 'https://economist.com/',
createdAt: 1,
updatedAt: 2,
status: 'failed',
designSystemId: 'user:brand-retry',
},
brand: null,
},
]);
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => {
expect(fileWorkspaceSpy.mock.calls.at(-1)?.[0].designSystemEditable).toBe(true);
});
const callsBeforeContinue = fileWorkspaceSpy.mock.calls.length;
latestChatPaneProps().onContinueBrandExtraction?.();
await waitFor(() => expect(mockedContinueBrandExtraction).toHaveBeenCalledWith('brand-retry'));
await waitFor(() => {
const callsAfterContinue = fileWorkspaceSpy.mock.calls.slice(callsBeforeContinue);
expect(callsAfterContinue.some(([props]) => props.designSystemEditable === false)).toBe(true);
expect(fileWorkspaceSpy.mock.calls.at(-1)?.[0].designSystemEditable).toBe(true);
});
});
it('dedupes rapid programmatic brand continuation before the busy rerender lands', async () => {
let resolveContinue:
| ((value: Awaited<ReturnType<typeof continueBrandExtraction>>) => void)
| undefined;
mockedContinueBrandExtraction.mockReturnValueOnce(
new Promise((resolve) => {
resolveContinue = resolve;
}),
);
renderProjectView(
{
...project('brand-retry'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-retry',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-retry',
},
},
);
await waitFor(() => expect(latestChatPaneProps().onContinueBrandExtraction).toBeTruthy());
const { onContinueBrandExtraction } = latestChatPaneProps();
onContinueBrandExtraction?.();
onContinueBrandExtraction?.();
await waitFor(() => {
expect(mockedContinueBrandExtraction).toHaveBeenCalledTimes(1);
expect(latestChatPaneProps().continueBrandExtractionBusy).toBe(true);
});
resolveContinue?.({
ok: true,
result: {
id: 'brand-retry',
projectId: 'brand-retry',
conversationId: 'conv-brand-retry',
sourceUrl: 'https://economist.com/',
status: 'extracting',
designSystemId: 'user:brand-retry',
},
});
await waitFor(() => {
expect(latestChatPaneProps().continueBrandExtractionBusy).toBe(false);
});
});
it('refreshes brand.html when the brand extraction reaches a failed terminal state', async () => {
mockedFetchBrands.mockResolvedValue([
{
meta: {
id: 'brand-failed',
sourceUrl: 'https://economist.com/',
createdAt: 1,
updatedAt: 2,
status: 'failed',
designSystemId: 'user:brand-failed',
},
brand: null,
},
]);
renderProjectView(
{
...project('brand-failed'),
metadata: {
kind: 'brand',
importedFrom: 'brand-extraction',
brandId: 'brand-failed',
brandSourceUrl: 'https://economist.com/',
brandDesignSystemId: 'user:brand-failed',
},
},
);
await waitFor(() => {
expect(fileWorkspaceSpy.mock.calls.some(([props]) => (props.filesRefreshKey ?? 0) > 0)).toBe(true);
});
});
it('does not leak a prior project prompt into a template project without one', async () => {
const first = project('source', 'Old seed');
const second = {
...project('template'),
metadata: { kind: 'template' as const, templateId: 'tmpl-1' },
};
const view = renderProjectView(first);
await waitFor(() => {
expect(composerValue()).toBe('Old seed');
});
view.rerender(
<ProjectView
project={second}
routeFileName={null}
config={config}
agents={[]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onRefreshAgents={vi.fn()}
onOpenSettings={vi.fn()}
onBack={vi.fn()}
onClearPendingPrompt={vi.fn()}
onTouchProject={vi.fn()}
onProjectChange={vi.fn()}
onProjectsRefresh={vi.fn()}
/>,
);
await waitFor(() => {
expect(composerValue()).toBe('');
});
});
});
function composerValue(): string {
return (screen.getByTestId('chat-composer-input') as HTMLTextAreaElement)
.value;
}
function latestChatPaneProps(): MockChatPaneProps {
const latest = chatPaneSpy.mock.calls.at(-1);
if (!latest) throw new Error('ChatPane was not rendered');
return latest[0] as MockChatPaneProps;
}