This commit is contained in:
kami 2026-05-31 13:24:08 +08:00 committed by GitHub
commit e2e593dfe9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 698 additions and 14 deletions

View file

@ -121,6 +121,11 @@ function normalizeSavedComposioConfig(config: AppConfig['composio']): AppConfig[
return { ...(config ?? {}) };
}
type ProjectListRequest = {
generation: number;
mutationVersion: number;
};
export async function persistComposioConfigChange(
current: AppConfig,
composio: AppConfig['composio'],
@ -226,6 +231,11 @@ function AppInner() {
queued: [],
recent: [],
});
const pendingLocalProjectIdsRef = useRef<Set<string>>(new Set());
const locallyDeletedProjectIdsRef = useRef<Map<string, number>>(new Map());
const projectListMutationVersionRef = useRef(0);
const projectListRequestGenerationRef = useRef(0);
const latestAppliedProjectListGenerationRef = useRef(0);
const [templates, setTemplates] = useState<ProjectTemplate[]>([]);
const [promptTemplates, setPromptTemplates] = useState<
PromptTemplateSummary[]
@ -276,6 +286,103 @@ function AppInner() {
// `detectClientType` still feeds analytics identity via the provider.
void detectClientType;
const rememberLocalProject = useCallback((projectId: string) => {
pendingLocalProjectIdsRef.current.add(projectId);
locallyDeletedProjectIdsRef.current.delete(projectId);
projectListMutationVersionRef.current += 1;
}, []);
const clearLocalProject = useCallback((projectId: string, options?: { deleted?: boolean }) => {
pendingLocalProjectIdsRef.current.delete(projectId);
projectListMutationVersionRef.current += 1;
if (options?.deleted) {
locallyDeletedProjectIdsRef.current.set(
projectId,
projectListMutationVersionRef.current,
);
}
}, []);
const beginProjectListRequest = useCallback((): ProjectListRequest => {
projectListRequestGenerationRef.current += 1;
return {
generation: projectListRequestGenerationRef.current,
mutationVersion: projectListMutationVersionRef.current,
};
}, []);
const reconcileFetchedProjects = useCallback((list: Project[], request: ProjectListRequest) => {
const pendingLocalProjectIds = pendingLocalProjectIdsRef.current;
const locallyDeletedProjectIds = locallyDeletedProjectIdsRef.current;
const fetchedIds = new Set(list.map((project) => project.id));
if (request.generation < latestAppliedProjectListGenerationRef.current) {
const visibleList =
locallyDeletedProjectIds.size > 0
? list.filter((project) => !locallyDeletedProjectIds.has(project.id))
: list;
if (visibleList.length === 0) return false;
const hydratableProjects = visibleList.filter(
(project) =>
pendingLocalProjectIds.has(project.id),
);
if (hydratableProjects.length === 0) return false;
const hydratableById = new Map(
hydratableProjects.map((project) => [project.id, project]),
);
for (const project of hydratableProjects) {
pendingLocalProjectIds.delete(project.id);
}
setProjects((current) => {
let changed = false;
const currentIds = new Set<string>();
const next = current.map((project) => {
currentIds.add(project.id);
const hydrated = hydratableById.get(project.id);
if (!hydrated) return project;
changed = true;
hydratableById.delete(project.id);
return hydrated;
});
for (const project of visibleList) {
if (currentIds.has(project.id)) continue;
changed = true;
next.push(project);
}
return changed ? next : current;
});
return true;
}
latestAppliedProjectListGenerationRef.current = request.generation;
for (const id of fetchedIds) pendingLocalProjectIds.delete(id);
for (const [id, deletedAtMutationVersion] of locallyDeletedProjectIds) {
if (
request.mutationVersion >= deletedAtMutationVersion
&& !fetchedIds.has(id)
) {
locallyDeletedProjectIds.delete(id);
}
}
const activeDeletedProjectIds = new Set(locallyDeletedProjectIds.keys());
const visibleList =
activeDeletedProjectIds.size > 0
? list.filter((project) => !activeDeletedProjectIds.has(project.id))
: list;
const visibleFetchedIds =
activeDeletedProjectIds.size > 0
? new Set(visibleList.map((project) => project.id))
: fetchedIds;
setProjects((current) => {
const preserved = current.filter(
(project) =>
pendingLocalProjectIds.has(project.id) &&
!visibleFetchedIds.has(project.id) &&
!activeDeletedProjectIds.has(project.id),
);
return preserved.length > 0 ? [...preserved, ...visibleList] : visibleList;
});
return true;
}, []);
// Propagate the Privacy toggle through to PostHog without a reload —
// posthog-js's opt_out_capturing flips a localStorage flag that makes
// every subsequent capture() a no-op. When the user opts back in we
@ -444,9 +551,10 @@ function AppInner() {
setDsLoading(false);
});
const request = beginProjectListRequest();
void listProjects().then((list) => {
if (cancelled) return;
setProjects(list);
reconcileFetchedProjects(list, request);
setProjectsLoading(false);
});
@ -550,7 +658,7 @@ function AppInner() {
return () => {
cancelled = true;
};
}, []);
}, [beginProjectListRequest, reconcileFetchedProjects]);
// Auto-pick the first available agent once both the daemon-stored config
// and the agents listing have landed. Splitting this out of bootstrap
@ -617,9 +725,10 @@ function AppInner() {
}, []);
const refreshProjects = useCallback(async () => {
const request = beginProjectListRequest();
const list = await listProjects();
setProjects(list);
}, []);
reconcileFetchedProjects(list, request);
}, [beginProjectListRequest, reconcileFetchedProjects]);
const refreshDesignSystems = useCallback(async () => {
const list = await fetchDesignSystems();
@ -951,6 +1060,7 @@ function AppInner() {
appliedPluginSnapshotId: result.appliedPluginSnapshotId,
}
: result.project;
rememberLocalProject(project.id);
flushSync(() => {
setProjects((curr) => [
project,
@ -966,7 +1076,7 @@ function AppInner() {
navigate(projectRoute);
return true;
},
[analytics.track],
[analytics.track, rememberLocalProject],
);
const handleCreatePluginShareProject = useCallback(
@ -992,6 +1102,7 @@ function AppInner() {
appliedPluginSnapshotId: outcome.appliedPluginSnapshotId,
}
: outcome.project;
rememberLocalProject(project.id);
setProjects((curr) => [
project,
...curr.filter((p) => p.id !== project.id),
@ -1003,7 +1114,7 @@ function AppInner() {
});
return outcome;
},
[],
[rememberLocalProject],
);
const handleImportClaudeDesign = useCallback(async (
@ -1011,6 +1122,7 @@ function AppInner() {
): Promise<ImportClaudeDesignOutcome> => {
try {
const result = await importClaudeDesignZip(file);
rememberLocalProject(result.project.id);
setProjects((curr) => [
result.project,
...curr.filter((p) => p.id !== result.project.id),
@ -1027,36 +1139,56 @@ function AppInner() {
message: err instanceof Error ? err.message : 'The ZIP could not be imported.',
};
}
}, []);
}, [rememberLocalProject]);
const handleImportFolder = useCallback(async (baseDir: string) => {
const result = await importFolderProject({ baseDir });
rememberLocalProject(result.project.id);
setProjects((curr) => [result.project, ...curr.filter((p) => p.id !== result.project.id)]);
navigate({
kind: 'project',
projectId: result.project.id,
fileName: result.entryFile,
});
}, []);
}, [rememberLocalProject]);
// PR #974: on desktop, the host bridge owns the picker and import POST
// atomically. The renderer never sees the path, token, or daemon DTO;
// it receives host-owned project identifiers and refreshes project state
// through the normal daemon API.
const handleImportFolderResponse = useCallback(async (result: OpenDesignHostProjectImportSuccess) => {
rememberLocalProject(result.projectId);
const project = await getProject(result.projectId);
if (project != null) {
setProjects((curr) => [project, ...curr.filter((p) => p.id !== project.id)]);
} else {
// Daemon hasn't materialized the full record yet (race between the
// host's import POST and our /api/projects read). Seed a minimal
// placeholder so the route stays alive and ProjectView mounts; the
// pending-local id keeps reconcileFetchedProjects from evicting the
// stub until a project-list snapshot actually includes it, and the
// next refresh swaps it for the real Project record. Without the
// stub, a stale `[]` list response would replace `projects` with `[]`
// and the route-guard effect would bounce the user back to Home.
const stub: Project = {
id: result.projectId,
name: '',
skillId: null,
designSystemId: null,
createdAt: Date.now(),
updatedAt: Date.now(),
};
setProjects((curr) => [stub, ...curr.filter((p) => p.id !== stub.id)]);
const request = beginProjectListRequest();
const list = await listProjects();
setProjects(list);
reconcileFetchedProjects(list, request);
}
navigate({
kind: 'project',
projectId: result.projectId,
fileName: result.entryFile,
});
}, []);
}, [beginProjectListRequest, rememberLocalProject, reconcileFetchedProjects]);
const handleOpenProject = useCallback((id: string) => {
navigate({ kind: 'project', projectId: id, fileName: null });
@ -1095,13 +1227,14 @@ function AppInner() {
const handleDeleteProject = useCallback(async (id: string) => {
const ok = await deleteProjectApi(id);
if (!ok) return false;
clearLocalProject(id, { deleted: true });
iframeKeepAlivePool.evictProject(id, { includeActive: true });
setProjects((curr) => curr.filter((p) => p.id !== id));
if (route.kind === 'project' && route.projectId === id) {
navigate({ kind: 'home', view: 'home' });
}
return true;
}, [iframeKeepAlivePool, route]);
}, [clearLocalProject, iframeKeepAlivePool, route]);
const handleRenameProject = useCallback(async (id: string, name: string) => {
const trimmed = name.trim();
@ -1230,17 +1363,25 @@ function AppInner() {
});
return;
}
const request = beginProjectListRequest();
const list = await listProjects();
if (cancelled) return;
setProjects(list);
if (!list.find((p) => p.id === route.projectId)) {
const applied = reconcileFetchedProjects(list, request);
if (!applied) return;
const fetchedProject = locallyDeletedProjectIdsRef.current.has(route.projectId)
? undefined
: list.find((p) => p.id === route.projectId);
const staleRequest = request.mutationVersion < projectListMutationVersionRef.current;
const knownLocalProject =
staleRequest && pendingLocalProjectIdsRef.current.has(route.projectId);
if (!fetchedProject && !knownLocalProject) {
navigate({ kind: 'home', view: 'home' }, { replace: true });
}
})();
return () => {
cancelled = true;
};
}, [route, activeProject, projects, daemonLive]);
}, [route, activeProject, projects, daemonLive, beginProjectListRequest, reconcileFetchedProjects]);
const openSettings = useCallback((
section: SettingsSection = 'execution',

View file

@ -0,0 +1,543 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { App } from '../../src/App';
import type { AppConfig, Project } from '../../src/types';
import {
fetchComposioConfigFromDaemon,
fetchDaemonConfig,
loadConfig,
mergeDaemonConfig,
saveConfig,
syncComposioConfigToDaemon,
syncConfigToDaemon,
} from '../../src/state/config';
import {
daemonIsLive,
fetchAgents,
fetchAppVersionInfo,
fetchDesignSystems,
fetchDesignTemplates,
fetchPromptTemplates,
fetchSkills,
uploadProjectFiles,
} from '../../src/providers/registry';
import {
createProject,
deleteProject,
getProject,
listProjects,
listTemplates,
patchProject,
} from '../../src/state/projects';
vi.mock('../../src/components/EntryView', () => ({
EntryView: ({
onCreateProject,
onDeleteProject,
onImportFolderResponse,
onOpenProject,
projects,
}: {
onCreateProject: (input: unknown) => void;
onDeleteProject: (id: string) => void;
onImportFolderResponse?: (response: {
conversationId: string;
entryFile: string | null;
ok: true;
projectId: string;
}) => Promise<void> | void;
onOpenProject: (id: string) => void;
projects: Project[];
}) => (
<main>
<button
type="button"
onClick={() =>
onCreateProject({
name: 'Fresh project',
skillId: null,
designSystemId: null,
metadata: { kind: 'prototype' },
})
}
>
Create project
</button>
<button
type="button"
onClick={() =>
void onImportFolderResponse?.({
conversationId: 'conv-import',
entryFile: null,
ok: true,
projectId: 'project-new',
})
}
>
Host import folder
</button>
{projects.map((project) => (
<div key={project.id} data-testid={`entry-project-${project.id}`}>
<span>{project.name}</span>
<button type="button" onClick={() => onOpenProject(project.id)}>
Open {project.name}
</button>
<button type="button" onClick={() => void onDeleteProject(project.id)}>
Delete {project.name}
</button>
</div>
))}
</main>
),
}));
vi.mock('../../src/components/ProjectView', () => ({
ProjectView: ({
onBack,
onProjectsRefresh,
project,
}: {
onBack: () => void;
onProjectsRefresh: () => Promise<void>;
project: Project;
}) => (
<main data-testid="project-view">
<span data-testid="project-title">{project.name}</span>
<button type="button" onClick={onBack}>
Back to projects
</button>
<button type="button" onClick={() => void onProjectsRefresh()}>
Refresh projects
</button>
</main>
),
}));
vi.mock('../../src/components/WorkspaceTabsBar', () => ({
WorkspaceTabsBar: () => null,
openWorkspaceTab: () => {},
}));
vi.mock('../../src/components/pet/PetOverlay', () => ({
PetOverlay: () => null,
}));
vi.mock('../../src/components/pet/pets', () => ({
migrateCustomPetAtlas: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../src/components/SettingsDialog', () => ({
SettingsDialog: () => null,
switchApiProtocolConfig: (config: AppConfig) => config,
updateCurrentApiProtocolConfig: (config: AppConfig) => config,
}));
vi.mock('../../src/providers/registry', async () => {
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
'../../src/providers/registry',
);
return {
...actual,
daemonIsLive: vi.fn(),
fetchAgents: vi.fn(),
fetchAppVersionInfo: vi.fn(),
fetchDesignSystems: vi.fn(),
fetchDesignTemplates: vi.fn(),
fetchPromptTemplates: vi.fn(),
fetchSkills: vi.fn(),
uploadProjectFiles: vi.fn(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
createProject: vi.fn(),
deleteProject: vi.fn(),
getProject: vi.fn(),
listProjects: vi.fn(),
listTemplates: vi.fn(),
patchProject: vi.fn(),
};
});
vi.mock('../../src/state/config', async () => {
const actual = await vi.importActual<typeof import('../../src/state/config')>(
'../../src/state/config',
);
return {
...actual,
fetchDaemonConfig: vi.fn().mockResolvedValue({}),
fetchComposioConfigFromDaemon: vi.fn().mockResolvedValue(null),
loadConfig: vi.fn(),
mergeDaemonConfig: vi.fn(),
saveConfig: vi.fn(),
syncComposioConfigToDaemon: vi.fn().mockResolvedValue(true),
syncConfigToDaemon: vi.fn().mockResolvedValue(undefined),
};
});
const mockedDaemonIsLive = vi.mocked(daemonIsLive);
const mockedFetchAgents = vi.mocked(fetchAgents);
const mockedFetchAppVersionInfo = vi.mocked(fetchAppVersionInfo);
const mockedFetchDesignSystems = vi.mocked(fetchDesignSystems);
const mockedFetchDesignTemplates = vi.mocked(fetchDesignTemplates);
const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
const mockedFetchSkills = vi.mocked(fetchSkills);
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
const mockedCreateProject = vi.mocked(createProject);
const mockedDeleteProject = vi.mocked(deleteProject);
const mockedGetProject = vi.mocked(getProject);
const mockedListProjects = vi.mocked(listProjects);
const mockedListTemplates = vi.mocked(listTemplates);
const mockedPatchProject = vi.mocked(patchProject);
const mockedFetchDaemonConfig = vi.mocked(fetchDaemonConfig);
const mockedFetchComposioConfigFromDaemon = vi.mocked(fetchComposioConfigFromDaemon);
const mockedLoadConfig = vi.mocked(loadConfig);
const mockedMergeDaemonConfig = vi.mocked(mergeDaemonConfig);
const mockedSaveConfig = vi.mocked(saveConfig);
const mockedSyncComposioConfigToDaemon = vi.mocked(syncComposioConfigToDaemon);
const mockedSyncConfigToDaemon = vi.mocked(syncConfigToDaemon);
const baseConfig: AppConfig = {
mode: 'daemon',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
apiProtocolConfigs: {},
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1778244000000,
mediaProviders: {},
composio: {},
agentModels: {},
agentCliEnv: {},
};
const freshProject: Project = {
id: 'project-new',
name: 'Fresh project',
skillId: null,
designSystemId: null,
createdAt: 1778244000000,
updatedAt: 1778244000000,
metadata: { kind: 'prototype' },
};
const existingProject: Project = {
id: 'project-existing',
name: 'Existing project',
skillId: null,
designSystemId: null,
createdAt: 1778243000000,
updatedAt: 1778243000000,
metadata: { kind: 'prototype' },
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
describe('App project creation routing', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/');
mockedDaemonIsLive.mockResolvedValue(true);
mockedFetchAgents.mockResolvedValue([]);
mockedFetchSkills.mockResolvedValue([]);
mockedFetchDesignTemplates.mockResolvedValue([]);
mockedFetchDesignSystems.mockResolvedValue([]);
mockedFetchPromptTemplates.mockResolvedValue([]);
mockedFetchAppVersionInfo.mockResolvedValue(null);
mockedListTemplates.mockResolvedValue([]);
mockedFetchDaemonConfig.mockResolvedValue({});
mockedFetchComposioConfigFromDaemon.mockResolvedValue(null);
mockedMergeDaemonConfig.mockImplementation((local) => local);
mockedLoadConfig.mockReturnValue({ ...baseConfig });
mockedUploadProjectFiles.mockResolvedValue({ uploaded: [], failed: [] });
mockedCreateProject.mockResolvedValue({
project: freshProject,
conversationId: 'conv-new',
});
mockedDeleteProject.mockResolvedValue(true);
mockedGetProject.mockResolvedValue(null);
mockedPatchProject.mockResolvedValue(freshProject);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
}),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('keeps a newly created project open when the initial project list resolves stale', async () => {
const bootstrapProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(bootstrapProjects.promise)
.mockResolvedValue([]);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Create project' }));
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
});
expect(window.location.pathname).toBe('/projects/project-new');
await act(async () => {
bootstrapProjects.resolve([]);
await bootstrapProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
expect(window.location.pathname).toBe('/projects/project-new');
});
it('keeps a newly created project open when a post-create refresh resolves stale', async () => {
const bootstrapProjects = deferred<Project[]>();
const staleRefreshProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(bootstrapProjects.promise)
.mockReturnValueOnce(staleRefreshProjects.promise)
.mockResolvedValue([]);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Create project' }));
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
});
expect(window.location.pathname).toBe('/projects/project-new');
fireEvent.click(screen.getByRole('button', { name: 'Refresh projects' }));
await act(async () => {
staleRefreshProjects.resolve([]);
await staleRefreshProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
expect(window.location.pathname).toBe('/projects/project-new');
await act(async () => {
bootstrapProjects.resolve([]);
await bootstrapProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
expect(window.location.pathname).toBe('/projects/project-new');
});
it('ignores an older stale project list after a newer response confirms the local project', async () => {
const bootstrapProjects = deferred<Project[]>();
const refreshedProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(bootstrapProjects.promise)
.mockReturnValueOnce(refreshedProjects.promise)
.mockResolvedValue([]);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Create project' }));
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
});
expect(window.location.pathname).toBe('/projects/project-new');
fireEvent.click(screen.getByRole('button', { name: 'Refresh projects' }));
await act(async () => {
refreshedProjects.resolve([freshProject]);
await refreshedProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
expect(window.location.pathname).toBe('/projects/project-new');
await act(async () => {
bootstrapProjects.resolve([]);
await bootstrapProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
expect(window.location.pathname).toBe('/projects/project-new');
});
it('does not re-add a locally deleted project when an older project list resolves stale', async () => {
const initialProjects = deferred<Project[]>();
const staleRefreshProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(initialProjects.promise)
.mockReturnValueOnce(staleRefreshProjects.promise)
.mockResolvedValue([]);
render(<App />);
await act(async () => {
initialProjects.resolve([freshProject]);
await initialProjects.promise;
});
expect(screen.getByTestId('entry-project-project-new').textContent).toContain(
'Fresh project',
);
fireEvent.click(screen.getByRole('button', { name: 'Open Fresh project' }));
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
});
fireEvent.click(screen.getByRole('button', { name: 'Refresh projects' }));
expect(mockedListProjects).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole('button', { name: 'Back to projects' }));
fireEvent.click(screen.getByRole('button', { name: 'Delete Fresh project' }));
await waitFor(() => {
expect(mockedDeleteProject).toHaveBeenCalledWith('project-new');
expect(screen.queryByTestId('entry-project-project-new')).toBeNull();
});
await act(async () => {
staleRefreshProjects.resolve([freshProject]);
await staleRefreshProjects.promise;
});
expect(screen.queryByTestId('entry-project-project-new')).toBeNull();
});
it('keeps a host-imported project routable when getProject and the list lag behind', async () => {
// Desktop import flow (handleImportFolderResponse fallback): the host
// bridge has already POSTed the import, but `/api/projects/:id` and
// `/api/projects` are both still catching up. Without a placeholder
// the stale `[]` list response would drop the just-imported project
// from state and the route-guard effect would bounce to Home.
const bootstrapProjects = deferred<Project[]>();
const importListProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(bootstrapProjects.promise)
.mockReturnValueOnce(importListProjects.promise)
.mockResolvedValue([]);
mockedGetProject.mockResolvedValue(null);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Host import folder' }));
await act(async () => {
importListProjects.resolve([]);
await importListProjects.promise;
});
await waitFor(() => {
expect(screen.getByTestId('project-view')).toBeTruthy();
});
expect(window.location.pathname).toBe('/projects/project-new');
await act(async () => {
bootstrapProjects.resolve([]);
await bootstrapProjects.promise;
});
expect(screen.getByTestId('project-view')).toBeTruthy();
expect(window.location.pathname).toBe('/projects/project-new');
});
it('hydrates a host-import placeholder from an older project list that contains the import', async () => {
const bootstrapProjects = deferred<Project[]>();
const importListProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(bootstrapProjects.promise)
.mockReturnValueOnce(importListProjects.promise)
.mockResolvedValue([]);
mockedGetProject.mockResolvedValue(null);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Host import folder' }));
await act(async () => {
importListProjects.resolve([]);
await importListProjects.promise;
});
await waitFor(() => {
expect(screen.getByTestId('project-view')).toBeTruthy();
});
expect(screen.getByTestId('project-title').textContent).toBe('');
expect(window.location.pathname).toBe('/projects/project-new');
await act(async () => {
bootstrapProjects.resolve([freshProject]);
await bootstrapProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
expect(window.location.pathname).toBe('/projects/project-new');
});
it('keeps non-conflicting projects from an older list that hydrates a host import', async () => {
const bootstrapProjects = deferred<Project[]>();
const importListProjects = deferred<Project[]>();
mockedListProjects
.mockReturnValueOnce(bootstrapProjects.promise)
.mockReturnValueOnce(importListProjects.promise)
.mockResolvedValue([]);
mockedGetProject.mockResolvedValue(null);
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Host import folder' }));
await act(async () => {
importListProjects.resolve([]);
await importListProjects.promise;
});
await waitFor(() => {
expect(screen.getByTestId('project-view')).toBeTruthy();
});
expect(screen.getByTestId('project-title').textContent).toBe('');
expect(window.location.pathname).toBe('/projects/project-new');
await act(async () => {
bootstrapProjects.resolve([freshProject, existingProject]);
await bootstrapProjects.promise;
});
expect(screen.getByTestId('project-title').textContent).toBe('Fresh project');
fireEvent.click(screen.getByRole('button', { name: 'Back to projects' }));
expect(screen.getByTestId('entry-project-project-new').textContent).toContain(
'Fresh project',
);
expect(screen.getByTestId('entry-project-project-existing').textContent).toContain(
'Existing project',
);
});
});