Install child-friendly replies by default through signed Marketplace delivery

This commit is contained in:
2026-09-28 23:00:54 +08:00
parent efff238a75
commit e70b6eab91
8 changed files with 276 additions and 18 deletions

View File

@@ -18,16 +18,18 @@ test.describe('Unified plugin workspace', () => {
stableVersion: '1.0.0', betaVersion: null,
};
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
const defaultItem = { ...item, pluginId: 'makelore.child-friendly-replies', title: '对孩子说清楚' };
const entry = (value: typeof item) => ({
pluginId: value.pluginId, title: value.title, summary: value.summary, category: value.category,
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
acquiredAt: '2026-09-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
});
const library = () => ({
library: {
items: acquired ? [{
pluginId: item.pluginId, title: item.title, summary: item.summary, category: item.category,
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
acquiredAt: '2026-09-03T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
}] : [],
total: acquired ? 1 : 0, stale: false, fetchedAt: 1,
items: [entry(defaultItem), ...(acquired ? [entry(item)] : [])],
total: acquired ? 2 : 1, stale: false, fetchedAt: 1,
},
installations: [],
installations: [{ pluginId: defaultItem.pluginId, status: 'installed', releaseId: 'child-1', version: '1.0.0', channel: 'stable' }],
});
(globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E = { requests };
ipcMain.removeHandler('hostapi:fetch');
@@ -59,7 +61,7 @@ test.describe('Unified plugin workspace', () => {
},
});
if (requestPath.startsWith('/api/coding/plugin-marketplace/catalog')) return result({
items: [item], nextCursor: null, total: 1, catalogGeneration: 1,
items: [item, defaultItem], nextCursor: null, total: 2, catalogGeneration: 1,
etag: 'plugins-1', pricingVersionId: null, stale: false, fetchedAt: 1,
});
if (requestPath === '/api/coding/plugin-marketplace/plugins/makelore.notes') return result({
@@ -93,10 +95,14 @@ test.describe('Unified plugin workspace', () => {
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page).toHaveURL(/\/project-config\/plugins\?scope=all/);
await expect(page.getByText('为你的智能体添加插件,拓展更多能力。')).toBeVisible();
await expect(page.getByTestId('plugin-tabs')).toBeVisible();
await expect(page.getByRole('button', { name: /刷新/ })).toHaveCount(0);
await expect(page.getByRole('searchbox')).toHaveCount(0);
await expect(page.getByRole('combobox')).toHaveCount(0);
await page.getByTestId('plugin-tab-installed').click();
await expect(page.getByRole('heading', { name: '对孩子说清楚' })).toBeVisible();
await expect(page.getByRole('button', { name: '安装对孩子说清楚', exact: true })).toHaveCount(0);
await page.getByTestId('plugin-tab-available').click();
await expect(page.getByRole('button', { name: '添加灵感笔记' })).toBeVisible();
await page.getByRole('button', { name: '查看灵感笔记详情' }).click();
await expect(page.getByRole('dialog', { name: '灵感笔记' })).toContainText('详细介绍');
@@ -111,6 +117,7 @@ test.describe('Unified plugin workspace', () => {
(globalThis as typeof globalThis & { __pluginsE2E?: { requests: string[] } }).__pluginsE2E?.requests ?? []
));
expect(requests).toContain('PUT /api/coding/plugin-marketplace/library/makelore.notes');
expect(requests.some((request) => request.includes('/installations/makelore.child-friendly-replies'))).toBe(false);
expect(requests.some((request) => request.startsWith('PUT /api/coding/plugins/'))).toBe(false);
} finally {
await closeElectronApp(app);

View File

@@ -375,6 +375,7 @@ describe('Marketplace public Library projection', () => {
it('rebuilds fresh-process installation state from the Package Store current selection', async () => {
const marketplace = {
getCurrentAccountBinding: () => null,
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot),
acquire: vi.fn(), remove: vi.fn(),
};
@@ -400,6 +401,7 @@ describe('Marketplace public Library projection', () => {
it('projects an installed but client-incompatible package without discarding its cached release', async () => {
const marketplace = {
getCurrentAccountBinding: () => null,
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot),
acquire: vi.fn(), remove: vi.fn(),
};
@@ -425,6 +427,7 @@ describe('Marketplace public Library projection', () => {
it.each(['acquire', 'remove'] as const)('returns an authoritative joined snapshot after %s', async (action) => {
const marketplace = {
getCurrentAccountBinding: () => null,
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(),
acquire: vi.fn().mockResolvedValue(snapshot), remove: vi.fn().mockResolvedValue(snapshot),
};

View File

@@ -0,0 +1,118 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import { createCodingPluginMarketplaceService } from '../../electron/api/coding-product-services';
import type { MarketplaceLibrarySnapshot } from '../../electron/coding-plugins/account-plugin-cache';
const pluginId = 'makelore.child-friendly-replies';
function fixture() {
let binding = { accountKey: 'a'.repeat(64), epoch: 1 };
let library: MarketplaceLibrarySnapshot = { items: [], total: 0, stale: false, fetchedAt: 1 };
let installed: object | null = null;
const entry = {
pluginId, title: '对孩子说清楚', summary: '简明表达', category: 'communication',
acquisition: 'free' as const, acquisitionMode: 'user_acquired' as const,
catalogStatus: 'active' as const, runtimeStatus: 'enabled' as const,
acquiredAt: '2026-09-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
};
const marketplace = {
getCurrentAccountBinding: () => binding,
readLibrary: vi.fn(async () => library),
readDetail: vi.fn(async () => ({
pluginId, acquisition: 'free', runtimeStatus: 'enabled', stale: false,
stableRelease: { deliveryKind: 'artifact', signingKeyId: 'platform', descriptorSignature: 'signed',
minMakeloreVersion: '2.0.0', maxMakeloreVersion: null },
})),
acquire: vi.fn(async () => (library = { ...library, items: [entry], total: 1 })),
remove: vi.fn(async () => (library = {
...library, items: [{ ...entry, removedAt: '2026-09-28T01:00:00Z' }], total: 1,
})),
};
const packageStore = {
getInstalled: vi.fn(async () => installed),
resolveAndInstall: vi.fn(async () => {
installed = { pluginId, releaseId: 'child-1', version: '1.0.0', channel: 'stable' };
return { ...installed, status: 'installed' };
}),
uninstall: vi.fn(async () => { installed = null; return { pluginId, status: 'removed' }; }),
};
const onChanged = vi.fn();
const service = createCodingPluginMarketplaceService({
marketplace: marketplace as never, packageStore: packageStore as never,
clientVersion: '2.0.0', onChanged,
});
return { service, marketplace, packageStore, onChanged,
switchAccount() { binding = { accountKey: 'b'.repeat(64), epoch: 2 }; },
};
}
describe('default child-friendly replies installation', () => {
it('acquires and installs without a manual action, then leaves the installed package alone', async () => {
const f = fixture();
const result = await f.service.readLibrary();
expect(result.installations).toEqual([{ pluginId, status: 'installed', releaseId: 'child-1', version: '1.0.0', channel: 'stable' }]);
expect(f.marketplace.acquire).toHaveBeenCalledWith(pluginId);
expect(f.packageStore.resolveAndInstall).toHaveBeenCalledWith({ pluginId, makeloreVersion: '2.0.0', channel: 'stable' });
expect(f.onChanged).toHaveBeenCalledWith({ pluginId, kind: 'install' });
await f.service.readLibrary();
expect(f.packageStore.resolveAndInstall).toHaveBeenCalledTimes(1);
});
it('respects account removal and default-plugin device uninstall on later refreshes', async () => {
const f = fixture();
await f.service.readLibrary();
await f.service.uninstall(pluginId);
expect(f.marketplace.remove).toHaveBeenCalledWith(pluginId);
expect((await f.service.readLibrary()).installations).toEqual([]);
expect(f.packageStore.resolveAndInstall).toHaveBeenCalledTimes(1);
});
it('does not acquire an unsigned release or mistake it for installed', async () => {
const f = fixture();
f.marketplace.readDetail.mockImplementationOnce(async () => ({
pluginId, acquisition: 'free', runtimeStatus: 'enabled', stale: false,
stableRelease: { deliveryKind: 'artifact', signingKeyId: '', descriptorSignature: '',
minMakeloreVersion: '2.0.0', maxMakeloreVersion: null },
}));
expect((await f.service.readLibrary()).installations).toEqual([]);
expect(f.marketplace.acquire).not.toHaveBeenCalled();
expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled();
expect((await f.service.readLibrary()).installations).toHaveLength(1);
});
it('retries a failed download on refresh without duplicating acquisition', async () => {
const f = fixture();
f.packageStore.resolveAndInstall.mockRejectedValueOnce(new Error('download unavailable'));
expect((await f.service.readLibrary()).installations).toEqual([]);
expect((await f.service.readLibrary()).installations).toHaveLength(1);
expect(f.marketplace.acquire).toHaveBeenCalledTimes(1);
});
it('does not acquire for a different account when login changes during the detail request', async () => {
const f = fixture();
const original = f.marketplace.readDetail.getMockImplementation()!;
f.marketplace.readDetail.mockImplementationOnce(async () => {
f.switchAccount();
return original();
});
await expect(f.service.readLibrary()).rejects.toMatchObject({ code: 'marketplace_account_changed' });
expect(f.marketplace.acquire).not.toHaveBeenCalled();
expect(f.packageStore.resolveAndInstall).not.toHaveBeenCalled();
});
it('keeps an explicit removal made during automatic installation as the final choice', async () => {
const f = fixture();
let finish!: () => void;
const paused = new Promise<void>((resolve) => { finish = resolve; });
const install = f.packageStore.resolveAndInstall.getMockImplementation()!;
f.packageStore.resolveAndInstall.mockImplementationOnce(async () => { await paused; return install(); });
const refreshing = f.service.readLibrary();
await vi.waitFor(() => expect(f.packageStore.resolveAndInstall).toHaveBeenCalled());
const removing = f.service.remove(pluginId);
finish();
await refreshing;
await removing;
const result = await f.service.readLibrary();
expect(result.library.items[0].removedAt).toBeTruthy();
expect(f.packageStore.resolveAndInstall).toHaveBeenCalledTimes(1);
});
});