mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* Add preview iframe keep-alive pool * Fix active preview eviction on prompt context changes * Evict preview iframes on skill/design-system registry edits Bridge Settings → Skills / Design Systems to App.tsx so the keep-alive pool drops any preview iframe whose project depends on the affected id after every successful mutation. Without this, body-only edits leave SkillSummary / DesignSystemSummary fields untouched and ProjectView's signature-driven eviction never fires, so the active preview keeps serving stale prompt context. The handler also re-fetches the App shell's skill / design-system lists so summary-field changes propagate to ProjectView's signature on the next render. Also extend IframeKeepAlivePool.evictMatching with an includeActive option so the new handler can drop the currently-visible iframe along with parked ones; the fallback pool only ever holds active entries so includeActive is a no-op there. Regression tests: - App.previewKeepAlive: clicking a Settings stub that fires onSkillsChanged / onDesignSystemsChanged drives evictMatching with includeActive=true and a predicate that matches projects using the affected id while skipping unrelated projects. - SkillsSection: onSkillsChanged fires after a body-only edit and after a delete. * fix: reattach active keep-alive iframe after eviction * fix(web): refresh design systems after rename --------- Co-authored-by: kami.c <kami.c@chative.com>
256 lines
6.7 KiB
TypeScript
256 lines
6.7 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, render, waitFor } from '@testing-library/react';
|
|
import type { ReactNode } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ProjectView } from '../../src/components/ProjectView';
|
|
import { useIframeKeepAlivePool } from '../../src/components/IframeKeepAlivePool';
|
|
import type {
|
|
AgentInfo,
|
|
AppConfig,
|
|
Conversation,
|
|
DesignSystemSummary,
|
|
Project,
|
|
SkillSummary,
|
|
} from '../../src/types';
|
|
import {
|
|
createConversation,
|
|
listConversations,
|
|
listMessages,
|
|
loadTabs,
|
|
} from '../../src/state/projects';
|
|
import { fetchPreviewComments } from '../../src/providers/registry';
|
|
|
|
const evictProjectMock = vi.fn();
|
|
|
|
vi.mock('../../src/i18n', () => ({
|
|
useT: () => (key: string) => key,
|
|
useI18n: () => ({
|
|
t: (key: string) => key,
|
|
locale: 'en',
|
|
setLocale: () => {},
|
|
}),
|
|
}));
|
|
|
|
vi.mock('../../src/router', () => ({
|
|
navigate: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/components/IframeKeepAlivePool', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/components/IframeKeepAlivePool')>(
|
|
'../../src/components/IframeKeepAlivePool',
|
|
);
|
|
return {
|
|
...actual,
|
|
useIframeKeepAlivePool: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/providers/anthropic', () => ({
|
|
streamMessage: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/daemon', () => ({
|
|
fetchChatRunStatus: vi.fn(),
|
|
listActiveChatRuns: vi.fn().mockResolvedValue([]),
|
|
reattachDaemonRun: vi.fn(),
|
|
streamViaDaemon: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/project-events', () => ({
|
|
useProjectFileEvents: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/registry', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
|
'../../src/providers/registry',
|
|
);
|
|
return {
|
|
...actual,
|
|
deletePreviewComment: vi.fn(),
|
|
fetchDesignSystem: vi.fn(),
|
|
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
|
|
fetchPreviewComments: vi.fn(),
|
|
fetchProjectFiles: vi.fn().mockResolvedValue([]),
|
|
fetchSkill: vi.fn(),
|
|
getTemplate: vi.fn(),
|
|
patchPreviewCommentStatus: vi.fn(),
|
|
upsertPreviewComment: vi.fn(),
|
|
writeProjectTextFile: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/state/projects', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
|
|
'../../src/state/projects',
|
|
);
|
|
return {
|
|
...actual,
|
|
createConversation: vi.fn(),
|
|
listConversations: vi.fn(),
|
|
listMessages: vi.fn(),
|
|
loadTabs: vi.fn(),
|
|
patchConversation: vi.fn(),
|
|
patchProject: vi.fn(),
|
|
saveMessage: vi.fn(),
|
|
saveTabs: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/components/AppChromeHeader', () => ({
|
|
AppChromeHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
|
}));
|
|
|
|
vi.mock('../../src/components/AvatarMenu', () => ({
|
|
AvatarMenu: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/FileWorkspace', () => ({
|
|
FileWorkspace: () => <div data-testid="file-workspace" />,
|
|
}));
|
|
|
|
vi.mock('../../src/components/Loading', () => ({
|
|
CenteredLoader: () => <div data-testid="loader" />,
|
|
}));
|
|
|
|
vi.mock('../../src/components/ChatPane', () => ({
|
|
ChatPane: () => <div data-testid="chat-pane" />,
|
|
}));
|
|
|
|
const mockedUseIframeKeepAlivePool = vi.mocked(useIframeKeepAlivePool);
|
|
const mockedListConversations = vi.mocked(listConversations);
|
|
const mockedCreateConversation = vi.mocked(createConversation);
|
|
const mockedListMessages = vi.mocked(listMessages);
|
|
const mockedLoadTabs = vi.mocked(loadTabs);
|
|
const mockedFetchPreviewComments = vi.mocked(fetchPreviewComments);
|
|
|
|
const config: AppConfig = {
|
|
mode: 'api',
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
model: '',
|
|
agentId: null,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
};
|
|
|
|
const project: Project = {
|
|
id: 'project-1',
|
|
name: 'Project 1',
|
|
skillId: 'skill-1',
|
|
designSystemId: 'ds-1',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
|
|
const conversation: Conversation = {
|
|
id: 'conv-1',
|
|
projectId: project.id,
|
|
title: null,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
|
|
const skill: SkillSummary = {
|
|
id: 'skill-1',
|
|
name: 'Prompt skill',
|
|
description: 'Old prompt context',
|
|
triggers: ['prompt'],
|
|
mode: 'prototype',
|
|
previewType: 'html',
|
|
designSystemRequired: false,
|
|
defaultFor: [],
|
|
upstream: null,
|
|
hasBody: true,
|
|
examplePrompt: 'Create a prototype.',
|
|
aggregatesExamples: false,
|
|
};
|
|
|
|
const designSystem: DesignSystemSummary = {
|
|
id: 'ds-1',
|
|
title: 'Design System',
|
|
category: 'product',
|
|
summary: 'Old system context',
|
|
};
|
|
|
|
function renderProjectView(props?: {
|
|
skills?: SkillSummary[];
|
|
designSystems?: DesignSystemSummary[];
|
|
}) {
|
|
return render(
|
|
<ProjectView
|
|
project={project}
|
|
routeFileName={null}
|
|
config={config}
|
|
agents={[] as AgentInfo[]}
|
|
skills={props?.skills ?? [skill]}
|
|
designTemplates={[] as SkillSummary[]}
|
|
designSystems={props?.designSystems ?? [designSystem]}
|
|
daemonLive
|
|
onModeChange={vi.fn()}
|
|
onAgentChange={vi.fn()}
|
|
onAgentModelChange={vi.fn()}
|
|
onRefreshAgents={vi.fn()}
|
|
onOpenSettings={vi.fn()}
|
|
onBack={vi.fn()}
|
|
onClearPendingPrompt={vi.fn()}
|
|
onTouchProject={vi.fn()}
|
|
onProjectChange={vi.fn()}
|
|
onProjectsRefresh={vi.fn()}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
describe('ProjectView preview keep-alive invalidation', () => {
|
|
beforeEach(() => {
|
|
mockedUseIframeKeepAlivePool.mockReturnValue({
|
|
attach: vi.fn(),
|
|
release: vi.fn(),
|
|
evict: vi.fn(),
|
|
evictProject: evictProjectMock,
|
|
evictMatching: vi.fn(),
|
|
});
|
|
mockedListConversations.mockResolvedValue([conversation]);
|
|
mockedCreateConversation.mockResolvedValue(conversation);
|
|
mockedListMessages.mockResolvedValue([]);
|
|
mockedLoadTabs.mockResolvedValue({ tabs: [], active: null });
|
|
mockedFetchPreviewComments.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('evicts the active preview when registry prompt context changes for the open project', async () => {
|
|
const view = renderProjectView();
|
|
|
|
view.rerender(
|
|
<ProjectView
|
|
project={project}
|
|
routeFileName={null}
|
|
config={config}
|
|
agents={[] as AgentInfo[]}
|
|
skills={[{ ...skill, description: 'Fresh prompt context' }]}
|
|
designTemplates={[] as SkillSummary[]}
|
|
designSystems={[designSystem]}
|
|
daemonLive
|
|
onModeChange={vi.fn()}
|
|
onAgentChange={vi.fn()}
|
|
onAgentModelChange={vi.fn()}
|
|
onRefreshAgents={vi.fn()}
|
|
onOpenSettings={vi.fn()}
|
|
onBack={vi.fn()}
|
|
onClearPendingPrompt={vi.fn()}
|
|
onTouchProject={vi.fn()}
|
|
onProjectChange={vi.fn()}
|
|
onProjectsRefresh={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(evictProjectMock).toHaveBeenCalledWith('project-1', { includeActive: true });
|
|
});
|
|
});
|
|
});
|