fix(marketplace): close client review findings

This commit is contained in:
2026-08-29 10:31:38 +08:00
parent 57962591de
commit 3df794c2e7
14 changed files with 840 additions and 68 deletions

View File

@@ -139,6 +139,53 @@ describe('coding plugin bounded product service', () => {
expect(result.items).not.toContainEqual(expect.objectContaining({ id: 'makelore.removed' }));
});
it('projects a client-incompatible installed package as unavailable', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-incompatible-'));
roots.push(root);
await createCodingProjectMetadata(root, { now: '2026-08-28T00:00:00.000Z' });
await createCodingProjectAgent(root, {
id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder',
model: null, modelResolution: 'required', skillIds: ['notes'],
responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] },
});
const definition = {
...DATA_SERVICE_PLUGIN_DEFINITION,
id: 'makelore.notes', displayName: 'Notes', description: 'Notes',
requiresBackend: false, runtimeKind: 'skill_only' as const,
acquisitionMode: 'user_acquired' as const, releaseId: 'release-notes',
provenance: { source: 'marketplace' as const, packageRoot: 'C:/packages/release-notes' },
adapterId: '', operations: [], tools: [],
skills: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md', grants: [] }],
surfaces: {},
};
const service = createCodingProjectPluginService({
projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) },
projectPlugins: {
getEnabledPluginIds: vi.fn().mockResolvedValue([definition.id]), setEnabled: vi.fn(),
},
policyClient: {
refresh: vi.fn(),
getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }),
},
adapters: [], definitions: [definition],
effectiveResolver: {
resolve: vi.fn().mockResolvedValue({
accountSessionId: 'account-a\u00001', projectId: 'local-a',
pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [],
toolDefinitions: [], runtimePolicies: [],
unavailableReasons: [{
pluginId: definition.id, code: 'client_incompatible',
message: 'Plugin Release is incompatible with this MakeLore client',
}],
}),
} as never,
});
await expect(service.list('local-a')).resolves.toMatchObject({
items: [{ id: definition.id, enabled: true, state: 'unavailable' }],
});
});
it('projects the exact three capabilities and fourteen mixed-policy operations', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-'));
roots.push(root);

View File

@@ -5,7 +5,10 @@ import {
createEffectivePluginResolver,
type EffectivePluginResolver,
} from '../../electron/coding-plugins/effective-resolver';
import type { CodingPluginDefinition } from '../../shared/coding-plugins';
import {
DATA_SERVICE_PLUGIN_DEFINITION,
type CodingPluginDefinition,
} from '../../shared/coding-plugins';
const skillOnlyDefinition: CodingPluginDefinition = {
id: 'makelore.notes',
@@ -205,6 +208,41 @@ describe('effective plugin resolver', () => {
expect(result.unavailableReasons).toEqual([]);
});
it('reserves every bundled Skill owner, including Data Service, before accepting Marketplace packages', async () => {
const marketplaceDataServiceCollision: CodingPluginDefinition = {
...skillOnlyDefinition,
id: 'makelore.data-service-shadow',
releaseId: 'data-service-shadow-1',
provenance: { source: 'marketplace', packageRoot: 'C:/packages/data-service-shadow-1' },
skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md', grants: [] }],
};
const result = await createEffectivePluginResolver({
definitions: [DATA_SERVICE_PLUGIN_DEFINITION, marketplaceDataServiceCollision],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => ({
...library(),
items: [{ ...library().items[0], pluginId: marketplaceDataServiceCollision.id }],
})),
getInstalled: vi.fn(async (pluginId: string) => pluginId === marketplaceDataServiceCollision.id
? installed(marketplaceDataServiceCollision)
: null),
getEnabledPluginIds: vi.fn(async () => [
DATA_SERVICE_PLUGIN_DEFINITION.id,
marketplaceDataServiceCollision.id,
]),
}).resolve({
projectId: 'project-a',
projectPath: 'C:/project-a',
assignedSkillIds: ['data-service'],
role: 'parent',
});
expect(result.pluginReleaseIds).toEqual([]);
expect(result.unavailableReasons).not.toEqual([
expect.objectContaining({ pluginId: marketplaceDataServiceCollision.id }),
]);
});
it('requires current Library, installed Release, project selection, and assignment', async () => {
const effective = resolver();
await expect(resolve(effective)).resolves.toMatchObject({

View File

@@ -86,7 +86,7 @@ function makeResolveResult(input: ResolveRequest, itemOverrides: Record<string,
sha256: SHA256,
sizeBytes: 1,
releaseAdmissionId: ADMISSION_ID,
expiresAt: '2026-08-29T00:00:00Z',
expiresAt: '2100-01-01T00:00:00Z',
channel: input.channel,
reason: null,
...itemOverrides,
@@ -97,17 +97,20 @@ function makeResolveResult(input: ResolveRequest, itemOverrides: Record<string,
};
}
function buildSkillOnlyArchive(): Buffer {
function buildSkillOnlyArchive(
overrides: Readonly<Record<string, Buffer | string>> = {},
): Buffer {
const zip = new AdmZip();
zip.addFile('plugin.json', Buffer.from(JSON.stringify({
const files = new Map<string, Buffer | string>([
['plugin.json', JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: PLUGIN_ID,
version: '1.0.0',
description: 'Example Skill',
author: { name: 'MakeLore' },
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
})));
zip.addFile('com.makelore/capability.json', Buffer.from(JSON.stringify({
})],
['com.makelore/capability.json', JSON.stringify({
schemaVersion: 2,
pluginId: PLUGIN_ID,
contractVersion: 1,
@@ -115,8 +118,13 @@ function buildSkillOnlyArchive(): Buffer {
runtime: { kind: 'skill_only' },
skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }],
tools: [],
})));
zip.addFile('skills/example-skill/SKILL.md', Buffer.from('# Example\n'));
})],
['skills/example-skill/SKILL.md', '# Example\n'],
]);
for (const [filePath, content] of Object.entries(overrides)) files.set(filePath, content);
for (const [filePath, content] of files) {
zip.addFile(filePath, Buffer.isBuffer(content) ? content : Buffer.from(content));
}
return zip.toBuffer();
}
@@ -156,7 +164,7 @@ function signedGrant(
sha256,
signingKeyId: options.signingKeyId ?? 'test-key',
descriptorSignature: signature,
expiresAt: '2026-08-29T00:00:00Z',
expiresAt: '2100-01-01T00:00:00Z',
contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`,
},
publicKey: publicKey.export({ type: 'spki', format: 'der' }) as Buffer,
@@ -749,12 +757,87 @@ describe('PluginPackageStore', () => {
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.removeUnused(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('explicitly uninstalls every unprotected release without selecting by install time', 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: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-current',
});
await expect(store.readInstalledIndex()).resolves.toEqual([]);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('"current":{}');
});
it('removes an unprotected current Release while retaining only an active-worker Release', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const protectedRelease = signedGrant(archive, { releaseId: 'release-worker' });
const selectedRelease = signedGrant(archive, {
releaseId: 'release-current', signingKeyId: 'test-key-current',
});
let current = protectedRelease;
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', protectedRelease.publicKey],
['test-key-current', selectedRelease.publicKey],
]),
clientVersion: '1.0.0',
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
current = selectedRelease;
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
store.registerActiveWorker('release-worker');
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
});
await expect(store.readInstalledIndex()).resolves.toEqual([
expect.objectContaining({ releaseId: 'release-worker' }),
]);
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
});
it('preserves the old release across download, signature, and extraction failures', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const oldArchive = buildSkillOnlyArchive();
@@ -886,6 +969,82 @@ describe('PluginPackageStore', () => {
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('captures the account before queued package mutations execute', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
let binding: AccountBinding | null = ACCOUNT_A;
const firstResolve = deferred<ResolveSnapshot>();
const resolve = vi.fn(async (input: ResolveRequest) => (
resolve.mock.calls.length === 1
? firstResolve.promise
: makeResolveResult(input, { sha256: grant.sha256, sizeBytes: grant.sizeBytes })
));
const marketplace: MarketplaceClient = {
resolve,
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => binding,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => binding,
});
const inFlight = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await vi.waitFor(() => expect(resolve).toHaveBeenCalledTimes(1));
const queued = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
binding = ACCOUNT_B;
firstResolve.resolve(makeResolveResult({ makeloreVersion: '1.0.0', channel: 'stable' }));
await expect(inFlight).rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(queued).rejects.toMatchObject({ code: 'plugin_account_changed' });
expect(resolve).toHaveBeenCalledTimes(1);
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('does not let a queued uninstall switch accounts while waiting', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
let binding: AccountBinding | null = ACCOUNT_A;
const resolve = vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
}));
const marketplace: MarketplaceClient = {
resolve,
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => binding,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => binding,
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
const firstResolve = deferred<ResolveSnapshot>();
resolve.mockImplementationOnce(async () => firstResolve.promise);
const inFlight = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
await vi.waitFor(() => expect(resolve).toHaveBeenCalledTimes(2));
const queued = store.uninstall(PLUGIN_ID);
binding = ACCOUNT_B;
firstResolve.resolve(makeResolveResult({ makeloreVersion: '1.0.0', channel: 'stable' }, {
action: 'keep',
}));
await expect(inFlight).rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(queued).rejects.toMatchObject({ code: 'plugin_account_changed' });
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID });
});
it('rejects archive traversal before materializing a package', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const zip = new AdmZip();
@@ -912,6 +1071,68 @@ describe('PluginPackageStore', () => {
await expect(store.readInstalledIndex()).resolves.toEqual([]);
});
it('enforces the canonical archive contract before installing a package', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const oversizedManifest = JSON.stringify({
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
name: PLUGIN_ID,
version: '1.0.0',
description: 'x'.repeat(256 * 1024),
author: { name: 'MakeLore' },
extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } },
});
const cases = [
{
name: 'unsupported asset extension',
archive: buildSkillOnlyArchive({ 'skills/example-skill/assets/run.exe': Buffer.from('not executable') }),
},
{
name: 'non-UTF-8 Skill text',
archive: buildSkillOnlyArchive({ 'skills/example-skill/SKILL.md': Buffer.from([0xff, 0xfe, 0xfd]) }),
},
{
name: 'non-UTF-8 capability manifest',
archive: buildSkillOnlyArchive({ 'com.makelore/capability.json': Buffer.from([0xff, 0xfe, 0xfd]) }),
},
{
name: 'asset outside a declared Skill',
archive: buildSkillOnlyArchive({ 'skills/other-skill/notes.md': '# Hidden\n' }),
},
{
name: 'oversized manifest',
archive: buildSkillOnlyArchive({ 'plugin.json': oversizedManifest }),
},
{
name: 'oversized Skill',
archive: buildSkillOnlyArchive({ 'skills/example-skill/SKILL.md': Buffer.alloc(256 * 1024 + 1, 0x61) }),
},
];
for (const [index, scenario] of cases.entries()) {
const { grant, publicKey } = signedGrant(scenario.archive, { releaseId: `release-invalid-${index}` });
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
releaseId: grant.releaseId,
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => scenario.archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
getAccountBinding: () => ACCOUNT_A,
});
await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }), scenario.name)
.rejects.toMatchObject({ code: 'plugin_artifact_invalid' });
await expect(store.readInstalledIndex()).resolves.toEqual([]);
}
});
it('requires explicit beta selection and rejects content paths outside the server route', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(response({
release_admission_id: ADMISSION_ID,

View File

@@ -4,6 +4,7 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { finished } from 'node:stream/promises';
import { afterEach, describe, expect, it } from 'vitest';
import {
@@ -15,6 +16,7 @@ import {
verifyBundledCodingPluginResources,
defaultProductExecutable,
readPackagedMarketplaceTrustSource,
verifyPackagedMarketplaceClientArtifact,
validatePiArtifactMetadata,
verifyMarketplaceClientArtifact,
} from '../../scripts/lib/pi-product-artifact.mjs';
@@ -24,6 +26,11 @@ const roots: string[] = [];
const PI_PACKAGE = '@earendil-works/pi-coding-agent';
const { createPackage } = createRequire(import.meta.url)('@electron/asar');
async function createAsarFixture(source: string, archive: string) {
const output = await createPackage(source, archive);
await finished(output);
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
@@ -149,7 +156,7 @@ describe('final Pi product artifact verification', () => {
);
await writeFile(path.join(source, 'resources', 'opencode-runtime', 'legacy.js'), 'export {};');
const archive = path.join(root, 'app.asar');
await createPackage(source, archive);
await createAsarFixture(source, archive);
const inventory = collectForbiddenAsarPaths(archive);
expect(inventory.entryCount).toBeGreaterThan(2);
@@ -240,10 +247,11 @@ describe('final Pi product artifact verification', () => {
await writeFile(path.join(source, 'dist-electron', 'main.js'), [
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
'const unrelatedConfiguration = process.env.NIANCODE_E2E;',
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
'export const marketplace = true;',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await createAsarFixture(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toBe(
'CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({})',
);
@@ -260,7 +268,7 @@ describe('final Pi product artifact verification', () => {
'function createTrust() { return Object.freeze({ get: load, sourceMarker: "makelore.plugin-trust.code-owned.v1" }); }',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await createAsarFixture(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toContain(
'makelore.plugin-trust.code-owned.v1',
);
@@ -289,10 +297,59 @@ describe('final Pi product artifact verification', () => {
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createPackage(source, appAsar);
await createAsarFixture(source, appAsar);
await expect(readPackagedMarketplaceTrustSource(appAsar)).rejects.toThrow('trust source');
});
it('binds Marketplace route, Renderer, and effective markers to the package main graph', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-contract-asar-'));
roots.push(root);
const source = path.join(root, 'source');
await mkdir(path.join(source, 'dist-electron'), { recursive: true });
await mkdir(path.join(source, 'dist', 'assets'), { 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({});',
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
'loadFile("../dist/index.html");',
].join('\n'));
await writeFile(path.join(source, 'dist', 'index.html'), [
'<script type="module" src="./assets/index.js"></script>',
].join('\n'));
await writeFile(path.join(source, 'dist', 'assets', 'index.js'), 'import("./project-plugins.js");');
await writeFile(
path.join(source, 'dist', 'assets', 'project-plugins.js'),
'import{marketplace}from"./plugin-marketplace.js";export{marketplace};',
);
await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [
'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createAsarFixture(source, appAsar);
await expect(verifyPackagedMarketplaceClientArtifact(appAsar)).resolves.toMatchObject({ result: 'pass' });
const staleSource = path.join(root, 'stale-source');
await mkdir(path.join(staleSource, 'dist-electron'), { recursive: true });
await writeFile(path.join(staleSource, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' }));
await writeFile(path.join(staleSource, 'dist-electron', 'main.js'), [
'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});',
'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";',
].join('\n'));
await writeFile(path.join(staleSource, 'dist-electron', 'stale-marketplace.js'), [
'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件',
].join('\n'));
const staleAsar = path.join(root, 'stale.asar');
await createAsarFixture(staleSource, staleAsar);
await expect(verifyPackagedMarketplaceClientArtifact(staleAsar))
.rejects.toThrow('Marketplace contract markers');
});
it('rejects a packaged plugin tree that drops an SDK asset or catalog marker', async () => {
const fixture = await bundledResourceFixture();
await rm(path.join(

View File

@@ -154,6 +154,25 @@ describe('My Plugins', () => {
expect(screen.getByText('设备版本 1.5.0')).toBeVisible();
});
it('does not present a no-version unavailable projection as an installed device package', () => {
render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }}
installations={{
'makelore.notes': {
status: 'unavailable', pluginId: 'makelore.notes', reason: 'plugin_incompatible_client',
},
}}
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('尚未下载到设备')).toBeVisible();
expect(screen.getByRole('button', { name: '下载灵感笔记' })).toBeVisible();
expect(screen.queryByRole('button', { name: '删除设备上的灵感笔记' })).not.toBeInTheDocument();
expect(screen.queryByText('设备版本已是最新')).not.toBeInTheDocument();
expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible();
});
it('honestly groups stable Main failure codes without claiming hidden authority', () => {
const { rerender } = render(<MemoryRouter><MyPluginsView
library={{ ...library, items: [library.items[0]] }} installations={{}} state="ready"

View File

@@ -141,6 +141,31 @@ describe('plugin Marketplace store', () => {
expect(store.getState().installations['makelore.data']).toMatchObject({ status: 'installed' });
});
it('merges same-plugin Library and device mutations in either completion order', async () => {
for (const order of ['library-then-device', 'device-then-library'] as const) {
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.notes');
const installed = { status: 'installed' as const, pluginId: 'makelore.notes', version: '1.0.0' };
const acquired = { library: library('Notes acquired'), installations: [] };
if (order === 'library-then-device') {
acquire.resolve(acquired); await libraryMutation;
install.resolve(installed); await deviceMutation;
} else {
install.resolve(installed); await deviceMutation;
acquire.resolve(acquired); await libraryMutation;
}
expect(store.getState().library?.items[0]?.title).toBe('Notes acquired');
expect(store.getState().installations['makelore.notes']).toMatchObject(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' });