Files
webmail/lib/__tests__/plugin-app-side-panel.test.ts
T
Linus Rath a256beb918 fix: tell each plugin whether it may show the side panel
api.host.features.sidePanel now says whether this plugin may offer the
app-side-panel (ui:side-panel granted), not only that the webmail has
it. A plugin enabled before an update added the permission can then say
so instead of offering a switch that does nothing. The host computes the
assistant's flags per plugin for a frame's init and for each push.

plugin-host-features.test.ts: the host bridge assertions take the
assistant's flags as the test plugin sees them (it lacks ui:side-panel,
so sidePanel is false there).
2026-11-02 23:54:52 +01:00

100 lines
5.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { slotPropsFor, mayOfferSlot } from '@/lib/plugin-sandbox/permissions';
import { PANEL_SLOTS, onPanelRequest, requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
import { ALL_PERMISSIONS, type InstalledPlugin, type Permission } from '@/lib/plugin-types';
import { slotSpec } from '@/lib/plugin-slot-catalog';
import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
import { runPluginAction } from '@/lib/plugin-sandbox/actions';
import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
import { APP_SIDE_PANEL_CONTEXT_FIELDS } from '@/lib/plugin-sandbox/assistant/f.types';
// The app-side-panel slot (area f of the assistant's host APIs): a side
// panel in Calendar, Contacts, Files and Settings. Its context carries the
// open event, contact, files or settings page, each behind the permission
// that guards that data elsewhere.
const plugin = (permissions: Permission[]): InstalledPlugin => ({
id: 'p', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
permissions, grantedPermissions: permissions, entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
});
const context = {
event: { id: 'e1', title: 'Team offsite' },
contact: { id: 'c1', fullName: 'Aa Bob' },
selectedCount: 3,
files: [{ id: 'f1', name: 'agenda.pdf' }],
fileCount: 1,
folder: { id: 'd1', path: '/Projects' },
page: { id: 'reading', label: 'Reading' },
secret: 'not in the catalogue',
};
afterEach(() => resetPanelRequests());
describe('app-side-panel', () => {
it('is a slot of its own permission, fillable, and a panel plugins can open', () => {
expect(ALL_PERMISSIONS).toContain('ui:side-panel');
expect(slotSpec('app-side-panel')?.permissions).toEqual(['ui:side-panel']);
expect(mayOfferSlot(plugin(['ui:side-panel']), 'app-side-panel')).toBe(true);
expect(mayOfferSlot(plugin(['ui:email-details']), 'app-side-panel')).toBe(false);
expect(isFillableSlot('app-side-panel')).toBe(true);
expect(PANEL_SLOTS).toContain('app-side-panel');
expect(assistantFeatures().sidePanel).toBe(true);
});
it('gives the plugin only the parts of the context it has permissions for', () => {
const props = { view: 'calendar', context, focusRequest: 2 };
// ui:side-panel alone: the view and the settings page, nothing of the user's data.
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', props)).toEqual({
view: 'calendar', context: { page: context.page }, focusRequest: 2,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'calendar:read']), 'app-side-panel', props).context).toEqual({
event: context.event, page: context.page,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'contacts:read']), 'app-side-panel', props).context).toEqual({
contact: context.contact, selectedCount: 3, page: context.page,
});
expect(slotPropsFor(plugin(['ui:side-panel', 'files:read']), 'app-side-panel', props).context).toEqual({
files: context.files, fileCount: 1, folder: context.folder, page: context.page,
});
// A part the catalogue doesn't name never goes out, whatever the plugin may do.
const all = slotPropsFor(plugin(['ui:side-panel', 'calendar:read', 'contacts:read', 'files:read']), 'app-side-panel', props);
expect(all.context).not.toHaveProperty('secret');
expect(Object.keys(all.context as object).sort()).toEqual(Object.keys(APP_SIDE_PANEL_CONTEXT_FIELDS).sort());
});
it("cuts the context the same way in the click that asks the plugin for its dialog (the loader's dispatcher)", async () => {
// Importing the loader installs its action dispatcher, as in the app.
await import('@/lib/plugin-sandbox/loader');
const invokeHook = vi.fn(async () => undefined);
register({ plugin: plugin(['ui:side-panel', 'calendar:read']), code: '', tier: 'untrusted', background: { invokeHook }, slotOffers: [], hookDisposables: [] } as unknown as ActivePlugin);
try {
await runPluginAction('p', 'app-side-panel', 'open', { view: 'contacts', context: { contact: context.contact, page: context.page, secret: 'x' } });
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'app-side-panel', actionId: 'open', context: { view: 'contacts', context: { page: context.page } } }]);
} finally {
deregister('p');
}
// Garbage in the context is dropped, not passed on.
expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', { context: 'x' }).context).toEqual({});
// The loader's import is slow under load.
}, 30_000);
it('tells each plugin whether it may show the panel (sidePanel follows its grant)', () => {
expect(assistantFeatures(plugin(['ui:side-panel'])).sidePanel).toBe(true);
// Enabled before an update added the permission: not granted yet.
const notGranted = { ...plugin(['ui:side-panel']), grantedPermissions: [] };
expect(assistantFeatures(notGranted).sidePanel).toBe(false);
expect(assistantFeatures(plugin([])).sidePanel).toBe(false);
});
it('routes api.ui.openPanel for it to the view that shows it, and nowhere else', () => {
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBeNull();
const handler = vi.fn((request: { slot: string }) => (request.slot === 'app-side-panel' ? true : null));
onPanelRequest(handler);
expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBe(true);
expect(handler).toHaveBeenCalledWith({ pluginId: 'p', slot: 'app-side-panel', mode: 'open' });
expect(requestPanel('p', 'email-detail-sidebar', 'open', undefined)).toBeNull();
});
});