Files
nexu-io-open-design/apps/web/tests/components/ProjectReferenceModal.test.tsx
Tom Huang 31d98634c3 feat(web): add composer context sources (#4869)
* feat(web): add composer context sources

* fix(web): require resolved context paths

* fix(web): keep local context removal authoritative

* fix(web): preserve project reference fallbacks

* fix(web): clear linked dirs with context chips

* fix(web): preserve context dirs when changing workdir

* fix(web): promote matching context workdir

* fix(web): preserve shared context dir ownership

* fix(web): retain home context dirs in composer

* fix(web): preserve promoted context dirs on clear

* fix(web): keep linked context chips after send

* fix(web): hydrate home workspace context chips

* fix(web): stabilize workspace context submissions

* fix(web): drop stale home workspace context dirs

* fix(web): fail missing workspace context dirs

* fix(web): keep home workspace context visible

* feat(web): implement project directory materialization for references

- Added `ensureReferencedProjectDir` function to create a managed project's folder if it doesn't exist, ensuring that references to new projects resolve correctly.
- Updated `getProjectDetail` to include an option for directory materialization, preventing errors when referencing uninitialized projects.
- Enhanced `ProjectReferenceModal` to filter out external projects with missing directories, improving user experience.
- Introduced tests for the new functionality to ensure proper behavior when referencing projects.

This change stabilizes project referencing and enhances the overall workflow for users dealing with managed projects.

* fix reference context fail-fast paths
2026-07-01 10:10:26 +00:00

164 lines
5.0 KiB
TypeScript

// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Project } from '../../src/types';
import {
ProjectReferenceModal,
type ProjectReferenceSelection,
} from '../../src/components/ProjectReferenceModal';
import { I18nProvider } from '../../src/i18n';
import type { Locale } from '../../src/i18n/types';
import { getProjectDetail, listProjects } from '../../src/state/projects';
vi.mock('../../src/state/projects', () => ({
getProjectDetail: vi.fn(),
listProjects: vi.fn(),
}));
const project: Project = {
id: 'project-ref',
name: 'Reference Project',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
metadata: { kind: 'prototype' },
};
const importedProject: Project = {
...project,
id: 'imported-project',
name: 'Imported Project',
metadata: { kind: 'prototype', baseDir: '/Users/me/imported' },
};
type ProjectSelectHandler = (items: ProjectReferenceSelection[]) => void;
function renderModal(options: {
onSelect?: ProjectSelectHandler;
projects?: Project[];
listError?: Error;
} = {}) {
const onSelect = options.onSelect ?? vi.fn<ProjectSelectHandler>();
if (options.listError) {
vi.mocked(listProjects).mockRejectedValue(options.listError);
} else {
vi.mocked(listProjects).mockResolvedValue(options.projects ?? [project]);
}
render(
<I18nProvider initial={'en' as Locale}>
<ProjectReferenceModal onClose={vi.fn()} onSelect={onSelect} />
</I18nProvider>,
);
return { onSelect };
}
async function confirmSelection(projectName = 'Reference Project') {
await screen.findByText(projectName);
fireEvent.click(screen.getByRole('button', { name: 'Reference project' }));
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('ProjectReferenceModal', () => {
it('loads projects with a required error surface', async () => {
renderModal();
await screen.findByText('Reference Project');
expect(listProjects).toHaveBeenCalledWith({ throwOnError: true });
});
it('shows a load error instead of an empty state when project loading fails', async () => {
renderModal({ listError: new Error('daemon unavailable') });
expect((await screen.findByRole('alert')).textContent).toContain('Could not load projects');
expect(screen.queryByText('No other projects yet')).toBeNull();
});
it('does not select a project when detail loading fails', async () => {
const { onSelect } = renderModal();
vi.mocked(getProjectDetail).mockResolvedValue(null);
await confirmSelection();
await screen.findByRole('alert');
expect(onSelect).not.toHaveBeenCalled();
});
it('does not synthesize a project id as a filesystem path', async () => {
const { onSelect } = renderModal();
vi.mocked(getProjectDetail).mockResolvedValue({ project, resolvedDir: '' });
await confirmSelection();
await screen.findByRole('alert');
expect(onSelect).not.toHaveBeenCalled();
});
it('selects a project only when the daemon returns a resolved directory', async () => {
const { onSelect } = renderModal();
vi.mocked(getProjectDetail).mockResolvedValue({
project,
resolvedDir: '/tmp/open-design/project-ref',
});
await confirmSelection();
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith([
{ project, resolvedDir: '/tmp/open-design/project-ref' },
]);
});
});
it('falls back to imported project metadata when older daemons omit resolvedDir', async () => {
const { onSelect } = renderModal({ projects: [importedProject] });
vi.mocked(getProjectDetail).mockResolvedValue({
project: importedProject,
resolvedDir: null,
});
await confirmSelection('Imported Project');
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith([
{ project: importedProject, resolvedDir: '/Users/me/imported' },
]);
});
});
it('selects multiple referenced projects in one confirmation', async () => {
const secondProject: Project = {
...project,
id: 'second-project',
name: 'Second Project',
};
const { onSelect } = renderModal({ projects: [project, secondProject] });
vi.mocked(getProjectDetail).mockImplementation(async (id: string) => {
if (id === project.id) {
return { project, resolvedDir: '/tmp/open-design/project-ref' };
}
if (id === secondProject.id) {
return { project: secondProject, resolvedDir: '/tmp/open-design/second-project' };
}
return null;
});
await screen.findByText('Reference Project');
fireEvent.click(screen.getByText('Second Project'));
fireEvent.click(screen.getByRole('button', { name: 'Reference project' }));
await waitFor(() => {
expect(onSelect).toHaveBeenCalledWith([
{ project, resolvedDir: '/tmp/open-design/project-ref' },
{ project: secondProject, resolvedDir: '/tmp/open-design/second-project' },
]);
});
});
});