open-design/apps/web/tests/components/App.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

372 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @vitest-environment jsdom
import { 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,
fetchMediaProvidersFromDaemon,
loadConfig,
mergeDaemonConfig,
saveConfig,
syncComposioConfigToDaemon,
syncConfigToDaemon,
} from '../../src/state/config';
import {
daemonIsLive,
fetchAgents,
fetchAppVersionInfo,
fetchDesignSystems,
fetchDesignTemplates,
fetchPromptTemplates,
fetchSkills,
} from '../../src/providers/registry';
import { listProjects, listTemplates } from '../../src/state/projects';
import { useIframeKeepAlivePool } from '../../src/components/IframeKeepAlivePool';
const evictProjectMock = vi.fn();
const evictMatchingMock = vi.fn();
const useRouteMock = vi.fn(() => ({
kind: 'project' as const,
projectId: 'project-1',
conversationId: null,
fileName: null,
}));
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
useRoute: () => useRouteMock(),
}));
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/components/EntryView', () => ({
EntryView: () => <div>Entry view</div>,
}));
vi.mock('../../src/components/ProjectView', () => ({
ProjectView: ({
project,
onProjectChange,
onOpenSettings,
}: {
project: Project;
onProjectChange: (project: Project) => void;
onOpenSettings: (section?: string) => void;
}) => (
<div>
<button
type="button"
onClick={() =>
onProjectChange({
...project,
skillId: 'fresh-skill',
})
}
>
Change skill
</button>
<button
type="button"
onClick={() =>
onProjectChange({
...project,
designSystemId: 'fresh-design-system',
})
}
>
Change design system
</button>
<button
type="button"
onClick={() =>
onProjectChange({
...project,
customInstructions: 'Fresh project instructions',
})
}
>
Change custom instructions
</button>
<button type="button" onClick={() => onOpenSettings('skills')}>
Open settings
</button>
</div>
),
}));
vi.mock('../../src/components/SettingsDialog', () => ({
SettingsDialog: ({
onSkillsChanged,
onDesignSystemsChanged,
}: {
onSkillsChanged?: (id?: string) => void;
onDesignSystemsChanged?: (id?: string) => void;
}) => (
<div>
<button
type="button"
onClick={() => onSkillsChanged?.('old-skill')}
>
Trigger skill body change
</button>
<button
type="button"
onClick={() => onSkillsChanged?.('unrelated-skill')}
>
Trigger unrelated skill change
</button>
<button
type="button"
onClick={() => onDesignSystemsChanged?.('old-design-system')}
>
Trigger design system change
</button>
</div>
),
}));
vi.mock('../../src/components/pet/PetOverlay', () => ({
PetOverlay: () => null,
}));
vi.mock('../../src/components/pet/pets', () => ({
migrateCustomPetAtlas: vi.fn().mockResolvedValue(null),
}));
vi.mock('../../src/components/WorkspaceTabsBar', () => ({
WorkspaceTabsBar: () => null,
}));
vi.mock('../../src/components/MemoryToast', () => ({
MemoryToast: () => null,
}));
vi.mock('../../src/components/PrivacyConsentModal', () => ({
PrivacyConsentModal: () => null,
}));
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(),
};
});
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
listProjects: vi.fn(),
listTemplates: vi.fn(),
};
});
vi.mock('../../src/state/config', async () => {
const actual = await vi.importActual<typeof import('../../src/state/config')>(
'../../src/state/config',
);
return {
...actual,
fetchComposioConfigFromDaemon: vi.fn(),
fetchDaemonConfig: vi.fn(),
fetchMediaProvidersFromDaemon: vi.fn(),
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 mockedListProjects = vi.mocked(listProjects);
const mockedListTemplates = vi.mocked(listTemplates);
const mockedFetchComposioConfigFromDaemon = vi.mocked(fetchComposioConfigFromDaemon);
const mockedFetchDaemonConfig = vi.mocked(fetchDaemonConfig);
const mockedFetchMediaProvidersFromDaemon = vi.mocked(fetchMediaProvidersFromDaemon);
const mockedLoadConfig = vi.mocked(loadConfig);
const mockedMergeDaemonConfig = vi.mocked(mergeDaemonConfig);
const mockedUseIframeKeepAlivePool = vi.mocked(useIframeKeepAlivePool);
const baseConfig: AppConfig = {
mode: 'api',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
apiProtocolConfigs: {},
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
privacyDecisionAt: 1778244000000,
};
const project: Project = {
id: 'project-1',
name: 'Project 1',
skillId: 'old-skill',
designSystemId: 'old-design-system',
customInstructions: 'Old project instructions',
createdAt: 1,
updatedAt: 1,
};
describe('App preview keep-alive invalidation', () => {
beforeEach(() => {
mockedUseIframeKeepAlivePool.mockReturnValue({
attach: vi.fn(),
release: vi.fn(),
evict: vi.fn(),
evictProject: evictProjectMock,
evictMatching: evictMatchingMock,
});
mockedDaemonIsLive.mockResolvedValue(true);
mockedFetchAgents.mockResolvedValue([]);
mockedFetchSkills.mockResolvedValue([]);
mockedFetchDesignTemplates.mockResolvedValue([]);
mockedFetchDesignSystems.mockResolvedValue([]);
mockedFetchPromptTemplates.mockResolvedValue([]);
mockedFetchAppVersionInfo.mockResolvedValue(null);
mockedListProjects.mockResolvedValue([project]);
mockedListTemplates.mockResolvedValue([]);
mockedFetchDaemonConfig.mockResolvedValue({});
mockedFetchComposioConfigFromDaemon.mockResolvedValue(null);
mockedFetchMediaProvidersFromDaemon.mockResolvedValue({ status: 'ok', providers: {} });
mockedMergeDaemonConfig.mockImplementation((local) => local);
mockedLoadConfig.mockReturnValue({ ...baseConfig });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
}),
);
window.history.replaceState(null, '', '/projects/project-1');
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it.each([
'Change skill',
'Change design system',
'Change custom instructions',
])('evicts the active preview when the open project prompt context changes: %s', async (buttonName) => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: buttonName }));
await waitFor(() => {
expect(evictProjectMock).toHaveBeenCalledWith('project-1', { includeActive: true });
});
});
// Regression for the mrcfps follow-up on PR #2190: ProjectView's
// signature only hashes SkillSummary / DesignSystemSummary fields, so a
// body-only registry edit leaves every signature unchanged and the
// signature-driven eviction path silently misses it. Settings →
// Skills / Design Systems now call back through App.tsx after every
// mutation so the pool drops any project that depends on the affected
// id — active or parked — regardless of which summary fields moved.
it('evicts pool entries for projects that use a changed skill, even on body-only edits', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Open settings' }));
fireEvent.click(await screen.findByRole('button', { name: 'Trigger skill body change' }));
await waitFor(() => {
expect(evictMatchingMock).toHaveBeenCalled();
});
const [predicate, options] = evictMatchingMock.mock.calls.at(-1) ?? [];
expect(options).toEqual({ includeActive: true });
expect(typeof predicate).toBe('function');
expect(
(predicate as (entry: { projectId: string; key: string; fileName: string }) => boolean)({
key: 'project-1file.html',
projectId: 'project-1',
fileName: 'file.html',
}),
).toBe(true);
});
it('does not evict pool entries for projects that use a different skill', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Open settings' }));
fireEvent.click(
await screen.findByRole('button', { name: 'Trigger unrelated skill change' }),
);
await waitFor(() => {
expect(evictMatchingMock).toHaveBeenCalled();
});
const [predicate] = evictMatchingMock.mock.calls.at(-1) ?? [];
expect(
(predicate as (entry: { projectId: string; key: string; fileName: string }) => boolean)({
key: 'project-1file.html',
projectId: 'project-1',
fileName: 'file.html',
}),
).toBe(false);
});
it('evicts pool entries for projects that use a changed design system', async () => {
render(<App />);
fireEvent.click(await screen.findByRole('button', { name: 'Open settings' }));
fireEvent.click(
await screen.findByRole('button', { name: 'Trigger design system change' }),
);
await waitFor(() => {
expect(evictMatchingMock).toHaveBeenCalled();
});
const [predicate, options] = evictMatchingMock.mock.calls.at(-1) ?? [];
expect(options).toEqual({ includeActive: true });
expect(
(predicate as (entry: { projectId: string; key: string; fileName: string }) => boolean)({
key: 'project-1file.html',
projectId: 'project-1',
fileName: 'file.html',
}),
).toBe(true);
});
});