// @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>(); 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( , ); 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 `` // 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( , ); 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( , ); 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( , ); 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)); }); });