Files
nexu-io--open-design/apps/web/tests/components/HandoffButton.fallback-reveal.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

192 lines
6.6 KiB
TypeScript

// @vitest-environment jsdom
// Regression for the zero-editors fallback: when no editor is detected, the
// fallback button must perform a real reveal (open the project folder via the
// daemon's open-in catalogue: finder / explorer / file-manager) rather than a
// no-op that advertises an action it never runs.
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HandoffButton } from '../../src/components/HandoffButton';
import { I18nProvider } from '../../src/i18n';
import type { AgentInfo, HostEditorsResponse } from '@open-design/contracts';
const fetchHostEditors = vi.fn<() => Promise<HostEditorsResponse>>();
const openProjectInEditor = vi.fn();
const copyToClipboard = vi.fn();
vi.mock('../../src/providers/registry', () => ({
fetchHostEditors: () => fetchHostEditors(),
openProjectInEditor: (...args: unknown[]) => openProjectInEditor(...args),
}));
vi.mock('../../src/lib/copy-to-clipboard', () => ({
copyToClipboard: (...args: unknown[]) => copyToClipboard(...args),
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
window.localStorage.clear();
fetchHostEditors.mockReset();
openProjectInEditor.mockReset();
copyToClipboard.mockReset();
});
describe('HandoffButton zero-editors fallback', () => {
it('opens the project folder in the OS file manager via the daemon', async () => {
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [],
});
openProjectInEditor.mockResolvedValue(undefined);
render(
<I18nProvider initial="en">
<HandoffButton projectId="p1" />
</I18nProvider>,
);
const fallback = (await screen.findByText('Finder')).closest('button') as HTMLButtonElement;
fireEvent.click(fallback);
await waitFor(() => expect(openProjectInEditor).toHaveBeenCalledWith('p1', 'finder'));
});
it('surfaces a daemon spawn failure inline so the fallback is not a silent no-op', async () => {
// The production caller (`ProjectView`) mounts `<HandoffButton projectId={…} />`
// with no `onRequestRevealInFinder` callback, so a rejected
// `openProjectInEditor` would otherwise leave users with a CTA that
// advertises Finder/Explorer/File Manager but does nothing visible.
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [],
});
openProjectInEditor.mockRejectedValue(new Error('daemon refused: ENOENT'));
render(
<I18nProvider initial="en">
<HandoffButton projectId="p1" />
</I18nProvider>,
);
const fallback = (await screen.findByText('Finder')).closest('button') as HTMLButtonElement;
fireEvent.click(fallback);
const errorEl = await screen.findByTestId('handoff-fallback-error');
expect(errorEl.textContent).toContain('daemon refused: ENOENT');
});
it('copies a framework-specific CLI handoff prompt with the local project path', async () => {
const fetchMock = vi.fn(async () => new Response('{}', { status: 202 }));
vi.stubGlobal('fetch', fetchMock);
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [
{
id: 'cursor',
label: 'Cursor',
available: true,
},
],
});
copyToClipboard.mockResolvedValue(true);
const agents: AgentInfo[] = [
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
},
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: false,
},
];
render(
<I18nProvider initial="zh-CN">
<HandoffButton
projectId="p1"
projectName="Landing"
projectDir="/tmp/open-design/Landing"
agents={agents}
metricsConsent
installationId="od-install-abc"
/>
</I18nProvider>,
);
fireEvent.click(await screen.findByTestId('handoff-caret'));
fireEvent.click(await screen.findByRole('tab', { name: '复制给 CLI' }));
const amrWebsiteLink = screen.getByRole('link', { name: /打开 Open Design Cloud 官网/ }) as HTMLAnchorElement;
expect(amrWebsiteLink.getAttribute('href'))
.toBe('https://open-design.ai/amr');
fireEvent.click(amrWebsiteLink);
const amrWebsiteUrl = new URL(amrWebsiteLink.href);
expect(amrWebsiteUrl.searchParams.get('od_origin')).toBe('open_design');
expect(amrWebsiteUrl.searchParams.get('od_entry_source')).toBe('handoff_amr_website');
expect(amrWebsiteUrl.searchParams.get('od_device_id')).toBe('od-install-abc');
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/analytics-entry',
expect.objectContaining({ method: 'POST' }),
);
expect(screen.getByTestId('handoff-cli-item-amr').textContent).toContain('Open Design');
expect(screen.getByTestId('handoff-cli-item-amr').textContent).not.toContain('未安装');
expect(
screen.getByTestId('handoff-cli-item-amr').compareDocumentPosition(
screen.getByTestId('handoff-cli-item-codex'),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(await screen.findByRole('button', { name: 'Vue.js' }));
fireEvent.click(await screen.findByTestId('handoff-cli-item-claude'));
await waitFor(() => expect(copyToClipboard).toHaveBeenCalledTimes(1));
const prompt = copyToClipboard.mock.calls[0]?.[0] as string;
expect(prompt).toContain('/tmp/open-design/Landing');
expect(prompt).toContain('Vue.js');
expect(prompt).toContain('Claude Code');
expect(prompt).toContain('真实可运行');
});
it('keeps the project path hidden behind a compact copy row', async () => {
fetchHostEditors.mockResolvedValue({
platform: 'darwin',
editors: [
{
id: 'cursor',
label: 'Cursor',
available: true,
},
],
});
copyToClipboard.mockResolvedValue(true);
const projectDir = '/tmp/open-design/Landing';
render(
<I18nProvider initial="en">
<HandoffButton
projectId="p1"
projectName="Landing"
projectDir={projectDir}
/>
</I18nProvider>,
);
fireEvent.click(await screen.findByTestId('handoff-caret'));
const pathRow = await screen.findByTestId('handoff-project-path');
expect(pathRow.textContent).toContain('Copy path');
expect(pathRow.textContent).not.toContain(projectDir);
const copyPathButton = within(pathRow).getByRole('button', { name: 'Copy path' });
expect(copyPathButton.getAttribute('title')).toBe(projectDir);
fireEvent.click(copyPathButton);
await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith(projectDir));
});
});