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
1172 lines
37 KiB
TypeScript
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;
|
|
}
|