open-design/apps/web/tests/components/ProjectView.previewKeepAlive.test.tsx
kami 1efa1dc7b5
Add preview iframe keep-alive pool (#2190)
* 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>
2026-05-29 03:01:17 +00:00

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 });
});
});
});