mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 23:09:34 +08:00
* 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
164 lines
5.0 KiB
TypeScript
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' },
|
|
]);
|
|
});
|
|
});
|
|
});
|