fix: complete marketplace client remediation
This commit is contained in:
@@ -56,4 +56,126 @@ test.describe('Plugin Marketplace', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps Beta explicit and exposes bounded unavailable, disabled, and device-delete states', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await app.evaluate(({ ipcMain }) => {
|
||||
let installedChannel: 'stable' | 'beta' | null = 'stable';
|
||||
const installedVersion = '1.0.0';
|
||||
const requests: string[] = [];
|
||||
const library = {
|
||||
items: [
|
||||
{
|
||||
pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感。', category: '效率',
|
||||
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
|
||||
acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: '2.0.0-beta.1',
|
||||
},
|
||||
{
|
||||
pluginId: 'makelore.paused', title: '暂停插件', summary: '暂时暂停。', category: '效率',
|
||||
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'suspended',
|
||||
acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
|
||||
},
|
||||
{
|
||||
pluginId: 'makelore.unavailable', title: '不可用插件', summary: '服务不可用。', category: '效率',
|
||||
acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled',
|
||||
acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null,
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
stale: false,
|
||||
fetchedAt: 1,
|
||||
};
|
||||
const projection = () => ({
|
||||
library,
|
||||
installations: [
|
||||
...(installedChannel ? [{
|
||||
status: 'installed', pluginId: 'makelore.notes', releaseId: installedChannel === 'beta' ? 'beta-2' : 'stable-1',
|
||||
version: installedChannel === 'beta' ? '2.0.0-beta.1' : installedVersion, channel: installedChannel,
|
||||
}] : []),
|
||||
{ status: 'unavailable', pluginId: 'makelore.unavailable', reason: 'plugin_backend_unavailable' },
|
||||
],
|
||||
});
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
||||
(globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests };
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
|
||||
const requestPath = request.path ?? '';
|
||||
const method = (request.method ?? 'GET').toUpperCase();
|
||||
requests.push(`${method} ${requestPath}`);
|
||||
if (requestPath === '/api/auth/session/sync') {
|
||||
const response = result({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'marketplace-e2e-token', tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } });
|
||||
if (requestPath === '/api/coding/plugin-marketplace/library') return result(projection());
|
||||
if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') {
|
||||
installedChannel = 'beta';
|
||||
return result({ status: 'installed', pluginId: 'makelore.notes', releaseId: 'beta-2', version: '2.0.0-beta.1', channel: 'beta' });
|
||||
}
|
||||
if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes' && method === 'DELETE') {
|
||||
installedChannel = null;
|
||||
return result({ status: 'removed', pluginId: 'makelore.notes', releaseId: 'beta-2', version: '2.0.0-beta.1', channel: 'beta' });
|
||||
}
|
||||
return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } };
|
||||
});
|
||||
});
|
||||
await page.addInitScript(({ key, value }) => {
|
||||
localStorage.setItem(key, value);
|
||||
}, {
|
||||
key: 'niancode-auth',
|
||||
value: JSON.stringify({
|
||||
state: {
|
||||
authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'marketplace-e2e-token',
|
||||
tokenType: 'Bearer', expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false,
|
||||
legacyRefreshToken: null,
|
||||
user: { username: 'marketplace-e2e', userId: 'marketplace-e2e-user', tenantId: null, deptId: null, authorities: [] },
|
||||
moduleAccess: { programming: true, design: true, learning: true, robot: true },
|
||||
},
|
||||
version: 2,
|
||||
}),
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page.getByTestId('ai-module-option-programming')).toBeVisible();
|
||||
const programmingOption = page.getByTestId('ai-module-option-programming');
|
||||
await programmingOption.click();
|
||||
await expect(page.getByTestId('main-layout')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await page.getByTestId('sidebar-nav-my-plugins').click();
|
||||
await expect(page.getByTestId('my-plugins-page')).toBeVisible();
|
||||
await expect.poll(async () => app.evaluate(() => (
|
||||
((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? [])
|
||||
.filter((requestPath) => requestPath.includes('/api/auth') || requestPath.includes('/plugin-marketplace'))
|
||||
))).toEqual(expect.arrayContaining([
|
||||
'GET /api/auth/me',
|
||||
'GET /api/coding/plugin-marketplace/library',
|
||||
]));
|
||||
await expect(page.getByRole('heading', { name: '暂停插件' })).toBeVisible();
|
||||
await expect(page.getByText('运行已暂停')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '下载暂停插件' })).toBeDisabled();
|
||||
await expect(page.getByText('Marketplace 服务暂不可用,设备版本未替换。')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '安装 Beta灵感笔记' }).click();
|
||||
await expect(page.getByText('当前频道:Beta')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '安装 Beta灵感笔记' })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '删除设备上的灵感笔记' }).click();
|
||||
const notesCard = page.locator('article').filter({ has: page.getByRole('heading', { name: '灵感笔记' }) });
|
||||
await expect(notesCard.getByText('尚未下载到设备')).toBeVisible();
|
||||
await expect(notesCard.getByRole('button', { name: '下载灵感笔记' })).toBeVisible();
|
||||
const requests = await app.evaluate(() => (
|
||||
(globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []
|
||||
));
|
||||
expect(requests).toContain('POST /api/coding/plugin-marketplace/install/makelore.notes/beta');
|
||||
expect(requests).toContain('DELETE /api/coding/plugin-marketplace/install/makelore.notes');
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,72 @@ async function resolve(effective: EffectivePluginResolver, assignedSkillIds = ['
|
||||
}
|
||||
|
||||
describe('effective plugin resolver', () => {
|
||||
it('rejects Marketplace Skill ID collisions with core and other packages without changing project assignment', async () => {
|
||||
const coreCollision: CodingPluginDefinition = {
|
||||
...skillOnlyDefinition,
|
||||
id: 'makelore.core-collision',
|
||||
releaseId: 'core-collision-1',
|
||||
provenance: { source: 'marketplace', packageRoot: 'C:/packages/core-collision-1' },
|
||||
skills: [{ id: 'agent-browser', entryPath: 'skills/agent-browser/SKILL.md', grants: [] }],
|
||||
};
|
||||
const packageA: CodingPluginDefinition = {
|
||||
...skillOnlyDefinition,
|
||||
id: 'makelore.collision-a',
|
||||
releaseId: 'collision-a-1',
|
||||
provenance: { source: 'marketplace', packageRoot: 'C:/packages/collision-a-1' },
|
||||
skills: [{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', grants: [] }],
|
||||
};
|
||||
const packageB: CodingPluginDefinition = {
|
||||
...skillOnlyDefinition,
|
||||
id: 'makelore.collision-b',
|
||||
releaseId: 'collision-b-1',
|
||||
provenance: { source: 'marketplace', packageRoot: 'C:/packages/collision-b-1' },
|
||||
skills: [{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', grants: [] }],
|
||||
};
|
||||
const definitions = [coreCollision, packageA, packageB];
|
||||
const result = await createEffectivePluginResolver({
|
||||
definitions,
|
||||
getAccountBinding: () => binding,
|
||||
getLibrary: vi.fn(async () => ({
|
||||
items: definitions.map((definition) => ({
|
||||
pluginId: definition.id,
|
||||
title: definition.displayName,
|
||||
summary: definition.description,
|
||||
category: 'tools',
|
||||
acquisition: 'free' as const,
|
||||
acquisitionMode: 'user_acquired' as const,
|
||||
catalogStatus: 'active' as const,
|
||||
runtimeStatus: 'enabled' as const,
|
||||
acquiredAt: null,
|
||||
removedAt: null,
|
||||
stableVersion: definition.version,
|
||||
betaVersion: null,
|
||||
})),
|
||||
total: definitions.length,
|
||||
stale: false,
|
||||
fetchedAt: 1,
|
||||
})),
|
||||
getInstalled: vi.fn(async (pluginId: string) => {
|
||||
const definition = definitions.find((candidate) => candidate.id === pluginId);
|
||||
return definition ? installed(definition) : null;
|
||||
}),
|
||||
getEnabledPluginIds: vi.fn(async () => definitions.map(({ id }) => id)),
|
||||
}).resolve({
|
||||
projectId: 'project-a',
|
||||
projectPath: 'C:/project-a',
|
||||
assignedSkillIds: ['agent-browser', 'shared-skill'],
|
||||
role: 'parent',
|
||||
});
|
||||
|
||||
expect(result.effectiveSkillIds).toEqual(['agent-browser', 'shared-skill']);
|
||||
expect(result.skillEntries).toEqual([
|
||||
{ id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' },
|
||||
{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', packageRoot: 'C:/packages/collision-a-1' },
|
||||
]);
|
||||
expect(result.pluginReleaseIds).toEqual(['collision-a-1']);
|
||||
expect(result.unavailableReasons).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires current Library, installed Release, project selection, and assignment', async () => {
|
||||
const effective = resolver();
|
||||
await expect(resolve(effective)).resolves.toMatchObject({
|
||||
|
||||
@@ -33,6 +33,13 @@ const RELEASE_ID = 'release-1';
|
||||
const ADMISSION_ID = 'admission-1';
|
||||
const SHA256 = 'a'.repeat(64);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const catalogPage = {
|
||||
items: [{
|
||||
plugin_id: PLUGIN_ID,
|
||||
@@ -160,6 +167,27 @@ function signedGrant(
|
||||
describe('Marketplace client and account cache', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
function rawLibraryEntry(pluginId: string, title: string) {
|
||||
return {
|
||||
plugin_id: pluginId,
|
||||
title,
|
||||
summary: title,
|
||||
category: 'tools',
|
||||
acquisition: 'free',
|
||||
acquisition_mode: 'user_acquired',
|
||||
catalog_status: 'active',
|
||||
runtime_status: 'enabled',
|
||||
acquired_at: '2026-08-28T00:00:00Z',
|
||||
removed_at: null,
|
||||
stable_version: '1.0.0',
|
||||
beta_version: null,
|
||||
};
|
||||
}
|
||||
|
||||
function rawLibrary(entries: readonly Record<string, unknown>[]) {
|
||||
return { items: entries, total: entries.length };
|
||||
}
|
||||
|
||||
it('keeps Library and admission snapshots isolated by account and invalidates on logout', () => {
|
||||
const cache = new AccountPluginCache();
|
||||
const aLibrary: MarketplaceLibrarySnapshot = {
|
||||
@@ -199,6 +227,64 @@ describe('Marketplace client and account cache', () => {
|
||||
expect(cache.referencedReleaseIds()).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('does not let an older Library read overwrite a newer mutation/read intent', async () => {
|
||||
const oldRead = deferred<Response>();
|
||||
const mutation = deferred<Response>();
|
||||
const newestRead = deferred<Response>();
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockImplementationOnce(() => oldRead.promise)
|
||||
.mockImplementationOnce(() => mutation.promise)
|
||||
.mockImplementationOnce(() => newestRead.promise);
|
||||
const cache = new AccountPluginCache();
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
accountCache: cache,
|
||||
});
|
||||
const old = client.readLibrary();
|
||||
const acquired = client.acquire(PLUGIN_ID);
|
||||
mutation.resolve(response(rawLibraryEntry(PLUGIN_ID, 'acquired')));
|
||||
await Promise.resolve();
|
||||
newestRead.resolve(response(rawLibrary([rawLibraryEntry(PLUGIN_ID, 'newest')])));
|
||||
oldRead.resolve(response(rawLibrary([rawLibraryEntry(PLUGIN_ID, 'old')])));
|
||||
await Promise.all([old, acquired]);
|
||||
expect(cache.getLibrary(ACCOUNT_A)?.items[0]?.title).toBe('newest');
|
||||
});
|
||||
|
||||
it('keeps the latest same-account mutation intent when mutation responses complete out of order', async () => {
|
||||
const firstMutation = deferred<Response>();
|
||||
const secondMutation = deferred<Response>();
|
||||
const firstRead = deferred<Response>();
|
||||
const secondRead = deferred<Response>();
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
.mockImplementationOnce(() => firstMutation.promise)
|
||||
.mockImplementationOnce(() => secondMutation.promise)
|
||||
.mockImplementationOnce(() => secondRead.promise)
|
||||
.mockImplementationOnce(() => firstRead.promise);
|
||||
const cache = new AccountPluginCache();
|
||||
const client = createMarketplaceClient({
|
||||
fetchImpl: fetcher,
|
||||
apiBaseUrl: 'https://square.example',
|
||||
getAccessToken: async () => 'token',
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
subscribeSession: () => () => undefined,
|
||||
accountCache: cache,
|
||||
});
|
||||
const first = client.acquire('makelore.first');
|
||||
const second = client.acquire('makelore.second');
|
||||
secondMutation.resolve(response(rawLibraryEntry('makelore.second', 'second mutation')));
|
||||
await Promise.resolve();
|
||||
secondRead.resolve(response(rawLibrary([rawLibraryEntry('makelore.second', 'second newest')])));
|
||||
firstMutation.resolve(response(rawLibraryEntry('makelore.first', 'first mutation')));
|
||||
await Promise.resolve();
|
||||
firstRead.resolve(response(rawLibrary([rawLibraryEntry('makelore.first', 'first stale')])));
|
||||
await Promise.all([first, second]);
|
||||
expect(cache.getLibrary(ACCOUNT_A)?.items[0]?.title).toBe('second newest');
|
||||
});
|
||||
|
||||
it('parses bounded catalog metadata, refreshes exactly once after a 401, and marks stale data', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
fetcher
|
||||
@@ -584,12 +670,91 @@ describe('PluginPackageStore', () => {
|
||||
|
||||
store.registerActiveWorker('release-1');
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
|
||||
status: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0',
|
||||
status: 'kept', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0',
|
||||
});
|
||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' });
|
||||
});
|
||||
|
||||
it('persists the installed channel and client range, and fails closed after a client upgrade', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const stable = signedGrant(archive, { releaseId: 'release-stable', minMakeloreVersion: '1.0.0', maxMakeloreVersion: '1.5.0' });
|
||||
let current = stable;
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
releaseId: current.grant.releaseId,
|
||||
sha256: current.grant.sha256,
|
||||
sizeBytes: current.grant.sizeBytes,
|
||||
})),
|
||||
issueDownload: vi.fn(async () => current.grant),
|
||||
downloadContent: async () => archive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
clientVersion: '1.0.0',
|
||||
keyStore: new Map([['test-key', stable.publicKey]]),
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
|
||||
|
||||
const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as {
|
||||
releases: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(index.releases[0]).toMatchObject({
|
||||
channel: 'stable',
|
||||
min_makelore_version: '1.0.0',
|
||||
max_makelore_version: '1.5.0',
|
||||
});
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
|
||||
channel: 'stable', minMakeloreVersion: '1.0.0', maxMakeloreVersion: '1.5.0',
|
||||
});
|
||||
|
||||
const upgradedStore = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
clientVersion: '2.0.0',
|
||||
keyStore: new Map([['test-key', stable.publicKey]]),
|
||||
});
|
||||
await expect(upgradedStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({
|
||||
unavailableReason: 'plugin_incompatible_client',
|
||||
});
|
||||
await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes only old releases and never guesses a new current selection from installedAt', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const first = signedGrant(archive, { releaseId: 'release-old' });
|
||||
const second = signedGrant(archive, { releaseId: 'release-current', signingKeyId: 'test-key-2' });
|
||||
let current = first;
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
releaseId: current.grant.releaseId,
|
||||
sha256: current.grant.sha256,
|
||||
sizeBytes: current.grant.sizeBytes,
|
||||
})),
|
||||
issueDownload: vi.fn(async () => current.grant),
|
||||
downloadContent: async () => archive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot, marketplace, getAccountBinding: () => ACCOUNT_A,
|
||||
keyStore: new Map([['test-key', first.publicKey], ['test-key-2', second.publicKey]]), clientVersion: '1.0.0',
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
current = second;
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ status: 'kept', releaseId: 'release-current' });
|
||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-current' });
|
||||
await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('release-current');
|
||||
});
|
||||
|
||||
it('preserves the old release across download, signature, and extraction failures', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const oldArchive = buildSkillOnlyArchive();
|
||||
|
||||
@@ -350,6 +350,31 @@ describe('Marketplace public Library projection', () => {
|
||||
expect(JSON.stringify(result)).not.toMatch(/sha256|sizeBytes|installedAt|packageRoot|definition|account|admission|token/i);
|
||||
});
|
||||
|
||||
it('projects an installed but client-incompatible package without discarding its cached release', async () => {
|
||||
const marketplace = {
|
||||
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot),
|
||||
acquire: vi.fn(), remove: vi.fn(),
|
||||
};
|
||||
const packageStore = {
|
||||
readInstalledIndex: vi.fn().mockResolvedValue(records),
|
||||
getInstalled: vi.fn().mockResolvedValue({
|
||||
...records[0], channel: 'beta', packageRoot: 'ignored', definition: {},
|
||||
unavailableReason: 'plugin_incompatible_client' as const,
|
||||
}),
|
||||
resolveAndInstall: vi.fn(), removeUnused: vi.fn(),
|
||||
};
|
||||
const service = createCodingPluginMarketplaceService({
|
||||
marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '3.0.0',
|
||||
});
|
||||
|
||||
await expect(service.readLibrary()).resolves.toMatchObject({
|
||||
installations: [{
|
||||
status: 'unavailable', pluginId: 'makelore.notes', releaseId: 'release-old', version: '1.0.0',
|
||||
channel: 'beta', reason: 'plugin_incompatible_client',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['acquire', 'remove'] as const)('returns an authoritative joined snapshot after %s', async (action) => {
|
||||
const marketplace = {
|
||||
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(),
|
||||
|
||||
@@ -236,6 +236,7 @@ describe('final Pi product artifact verification', () => {
|
||||
roots.push(root);
|
||||
const source = path.join(root, 'source');
|
||||
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
|
||||
await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' }));
|
||||
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
|
||||
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
|
||||
'const unrelatedConfiguration = process.env.NIANCODE_E2E;',
|
||||
@@ -253,6 +254,7 @@ describe('final Pi product artifact verification', () => {
|
||||
roots.push(root);
|
||||
const source = path.join(root, 'source');
|
||||
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
|
||||
await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' }));
|
||||
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
|
||||
'const dC = Object.freeze({});',
|
||||
'function createTrust() { return Object.freeze({ get: load, sourceMarker: "makelore.plugin-trust.code-owned.v1" }); }',
|
||||
@@ -275,6 +277,22 @@ describe('final Pi product artifact verification', () => {
|
||||
)).toMatchObject({ productionTrust: 'official-key-absent-fail-closed' });
|
||||
});
|
||||
|
||||
it('does not accept a stale unreachable trust marker outside package.json.main reachability', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-stale-trust-asar-'));
|
||||
roots.push(root);
|
||||
const source = path.join(root, 'source');
|
||||
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
|
||||
await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' }));
|
||||
await writeFile(path.join(source, 'dist-electron', 'main.js'), 'export const app = true;');
|
||||
await writeFile(path.join(source, 'dist-electron', 'stale-trusted-keys.js'), [
|
||||
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
|
||||
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
|
||||
].join('\n'));
|
||||
const appAsar = path.join(root, 'app.asar');
|
||||
await createPackage(source, appAsar);
|
||||
await expect(readPackagedMarketplaceTrustSource(appAsar)).rejects.toThrow('trust source');
|
||||
});
|
||||
|
||||
it('rejects a packaged plugin tree that drops an SDK asset or catalog marker', async () => {
|
||||
const fixture = await bundledResourceFixture();
|
||||
await rm(path.join(
|
||||
|
||||
@@ -112,6 +112,35 @@ describe('My Plugins', () => {
|
||||
expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
|
||||
});
|
||||
|
||||
it('updates an installed Beta package only through the explicit Beta action', () => {
|
||||
const onUpdate = vi.fn();
|
||||
const onInstallBeta = vi.fn();
|
||||
render(<MemoryRouter><MyPluginsView
|
||||
library={{ ...library, items: [library.items[0]] }}
|
||||
installations={{ 'makelore.notes': { status: 'installed', pluginId: 'makelore.notes', channel: 'beta', version: '2.0.0', releaseId: 'beta-1' } }}
|
||||
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={onUpdate}
|
||||
onInstallBeta={onInstallBeta} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
|
||||
/></MemoryRouter>);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '更新灵感笔记' })).not.toBeInTheDocument();
|
||||
const updateBeta = screen.getByRole('button', { name: '更新 Beta灵感笔记' });
|
||||
fireEvent.click(updateBeta);
|
||||
expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes');
|
||||
expect(onUpdate).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('当前频道:Beta')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not silently fall back to stable when the installed Beta channel has no release', () => {
|
||||
render(<MemoryRouter><MyPluginsView
|
||||
library={{ ...library, items: [{ ...library.items[0], betaVersion: null }] }}
|
||||
installations={{ 'makelore.notes': { status: 'installed', pluginId: 'makelore.notes', channel: 'beta', version: '2.0.0', releaseId: 'beta-1' } }}
|
||||
state="ready" pending={{}} onRefresh={vi.fn()} onInstall={vi.fn()} onUpdate={vi.fn()}
|
||||
onInstallBeta={vi.fn()} onUninstall={vi.fn()} onRemove={vi.fn()} onReacquire={vi.fn()}
|
||||
/></MemoryRouter>);
|
||||
expect(screen.getByText('当前 Beta 频道暂无可用版本;不会静默切回稳定版。')).toBeVisible();
|
||||
expect(screen.queryByRole('button', { name: '更新灵感笔记' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names signature, yanked/not-ready, and incompatible failures without hiding the old install', () => {
|
||||
render(<MemoryRouter><MyPluginsView
|
||||
library={{ ...library, items: [library.items[0]] }}
|
||||
|
||||
@@ -114,9 +114,33 @@ describe('plugin Marketplace store', () => {
|
||||
a.resolve(make('makelore.a', 'A older')); await aRequest;
|
||||
}
|
||||
expect(store.getState().library?.items[0].title).toBe('B newer');
|
||||
expect(store.getState().library?.items.map(({ pluginId }) => pluginId).sort()).toEqual([
|
||||
'makelore.a', 'makelore.b',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps independent Library and device mutations when their responses complete out of order', async () => {
|
||||
const acquire = deferred<MarketplaceLibraryProjection>();
|
||||
const install = deferred<MarketplaceInstallation>();
|
||||
const store = createPluginMarketplaceStore({
|
||||
acquire: vi.fn(() => acquire.promise),
|
||||
install: vi.fn(() => install.promise),
|
||||
});
|
||||
store.getState().activateAccount('account-a');
|
||||
const libraryMutation = store.getState().acquire('makelore.notes');
|
||||
const deviceMutation = store.getState().install('makelore.data');
|
||||
install.resolve({ status: 'installed', pluginId: 'makelore.data', version: '1.0.0' });
|
||||
await deviceMutation;
|
||||
acquire.resolve({
|
||||
library: library('Notes acquired'),
|
||||
installations: [],
|
||||
});
|
||||
await libraryMutation;
|
||||
expect(store.getState().library?.items[0]?.title).toBe('Notes acquired');
|
||||
expect(store.getState().installations['makelore.data']).toMatchObject({ status: 'installed' });
|
||||
});
|
||||
|
||||
it('calls only the selected state mutation', async () => {
|
||||
const acquire = vi.fn().mockResolvedValue(projection('Acquired'));
|
||||
const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', version: '1.0.0' });
|
||||
|
||||
Reference in New Issue
Block a user