mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Merge 28874ee6b5 into 53fb175855
This commit is contained in:
commit
e2e593dfe9
2 changed files with 698 additions and 14 deletions
|
|
@ -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',
|
||||
|
|
|
|||
543
apps/web/tests/components/App.project-create-race.test.tsx
Normal file
543
apps/web/tests/components/App.project-create-race.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue