fix(marketplace): close release lifecycle gaps
This commit is contained in:
@@ -796,6 +796,78 @@ describe('PluginPackageStore', () => {
|
||||
await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('persists explicit Stable and Beta intent when resolve keeps the same Release', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive);
|
||||
let action: 'install' | 'keep' = 'install';
|
||||
const issueDownload = vi.fn(async () => grant);
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
action, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
|
||||
})),
|
||||
issueDownload,
|
||||
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', publicKey]]),
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
|
||||
action = 'keep';
|
||||
|
||||
await expect(store.resolveAndInstall({
|
||||
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'beta', explicitBeta: true,
|
||||
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'beta' });
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'beta' });
|
||||
|
||||
await expect(store.resolveAndInstall({
|
||||
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
|
||||
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'stable' });
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'stable' });
|
||||
expect(issueDownload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('persists explicit channel intent when resolve reuses a cached immutable Release', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive);
|
||||
let action: 'install' | 'update' = 'install';
|
||||
const issueDownload = vi.fn(async () => grant);
|
||||
const marketplace: MarketplaceClient = {
|
||||
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
|
||||
action, sha256: grant.sha256, sizeBytes: grant.sizeBytes,
|
||||
})),
|
||||
issueDownload,
|
||||
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', publicKey]]),
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' });
|
||||
action = 'update';
|
||||
|
||||
await expect(store.resolveAndInstall({
|
||||
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'beta', explicitBeta: true,
|
||||
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'beta' });
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'beta' });
|
||||
|
||||
await expect(store.resolveAndInstall({
|
||||
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
|
||||
})).resolves.toMatchObject({ status: 'kept', releaseId: RELEASE_ID, channel: 'stable' });
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID, channel: 'stable' });
|
||||
expect(issueDownload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -907,7 +979,7 @@ describe('PluginPackageStore', () => {
|
||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('makes an active current Release unavailable to new workers before deferred cleanup', async () => {
|
||||
it('finishes an explicit uninstall when the last active worker releases its frozen Release', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const active = signedGrant(archive, { releaseId: 'release-active' });
|
||||
@@ -937,11 +1009,83 @@ describe('PluginPackageStore', () => {
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
|
||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
|
||||
store.releaseActiveWorker('release-active');
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
|
||||
status: 'removed', pluginId: PLUGIN_ID, reason: 'none',
|
||||
});
|
||||
await store.releaseActiveWorker('release-active');
|
||||
await expect(store.readInstalledIndex()).resolves.toEqual([]);
|
||||
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-active'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('keeps deferred explicit cleanup blocked by another local account snapshot', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive, { releaseId: 'release-active' });
|
||||
const accountCache = new AccountPluginCache();
|
||||
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 () => archive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
accountCache,
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
clientVersion: '1.0.0',
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
accountCache.setResolve(ACCOUNT_B, 'account-b-reference', makeResolveResult({
|
||||
makeloreVersion: '1.0.0', resolveRequestId: 'account-b-reference',
|
||||
}, { releaseId: grant.releaseId, sha256: grant.sha256, sizeBytes: grant.sizeBytes }));
|
||||
store.registerActiveWorker('release-active');
|
||||
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({
|
||||
status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference',
|
||||
});
|
||||
await store.releaseActiveWorker('release-active');
|
||||
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull();
|
||||
await expect(store.readInstalledIndex()).resolves.toEqual([
|
||||
expect.objectContaining({ pluginId: PLUGIN_ID, releaseId: 'release-active' }),
|
||||
]);
|
||||
await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-active')))
|
||||
.resolves.toMatchObject({ isDirectory: expect.any(Function) });
|
||||
});
|
||||
|
||||
it('cancels deferred cleanup when the user explicitly installs the same Release again', async () => {
|
||||
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
|
||||
const archive = buildSkillOnlyArchive();
|
||||
const { grant, publicKey } = signedGrant(archive, { releaseId: 'release-active' });
|
||||
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 () => archive,
|
||||
getCurrentAccountBinding: () => ACCOUNT_A,
|
||||
} as MarketplaceClient;
|
||||
const store = new PluginPackageStore({
|
||||
rootDir: temporaryRoot,
|
||||
marketplace,
|
||||
getAccountBinding: () => ACCOUNT_A,
|
||||
keyStore: new Map([['test-key', publicKey]]),
|
||||
clientVersion: '1.0.0',
|
||||
});
|
||||
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
|
||||
store.registerActiveWorker('release-active');
|
||||
await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({
|
||||
status: 'kept', reason: 'active_worker_reference',
|
||||
});
|
||||
|
||||
await expect(store.resolveAndInstall({
|
||||
pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable',
|
||||
})).resolves.toMatchObject({ status: 'kept', releaseId: 'release-active' });
|
||||
await store.releaseActiveWorker('release-active');
|
||||
|
||||
await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-active' });
|
||||
await expect(store.readInstalledIndex()).resolves.toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves the old release across download, signature, and extraction failures', async () => {
|
||||
|
||||
@@ -35,6 +35,12 @@ import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
|
||||
const roots: string[] = [];
|
||||
const NOW = '2026-08-22T16:00:00.000Z';
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
class OpenerFakeProcess implements PiWorkerProcessAdapter {
|
||||
readonly generation = 1;
|
||||
readonly proofResponseDelays: Array<{ commandType: string; delayMs: number }> = [];
|
||||
@@ -151,9 +157,13 @@ describe('managed Pi worker opener', () => {
|
||||
effectiveSnapshot,
|
||||
})),
|
||||
} as unknown as CodingCapabilityRegistry;
|
||||
const firstReleaseCleanup = deferred<void>();
|
||||
const releaseCleanups: Array<ReturnType<typeof vi.fn>> = [];
|
||||
const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => {
|
||||
const cleanup = vi.fn();
|
||||
const waitForFirst = releaseCleanups.length === 0;
|
||||
const cleanup = vi.fn(async () => {
|
||||
if (waitForFirst) await firstReleaseCleanup.promise;
|
||||
});
|
||||
releaseCleanups.push(cleanup);
|
||||
return cleanup;
|
||||
});
|
||||
@@ -252,7 +262,15 @@ describe('managed Pi worker opener', () => {
|
||||
expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]);
|
||||
expect(registerActivePluginReleases).toHaveBeenCalledTimes(3);
|
||||
expect(registerActivePluginReleases).toHaveBeenNthCalledWith(1, ['plugin-release-a']);
|
||||
await first.worker.stop('test_injection');
|
||||
let firstStopSettled = false;
|
||||
const firstStop = first.worker.stop('test_injection').then((result) => {
|
||||
firstStopSettled = true;
|
||||
return result;
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseCleanups[0]).toHaveBeenCalledOnce());
|
||||
expect(firstStopSettled).toBe(false);
|
||||
firstReleaseCleanup.resolve();
|
||||
await firstStop;
|
||||
await reopened.worker.stop('test_injection');
|
||||
await restarted.worker.stop('test_injection');
|
||||
expect(releaseCleanups).toHaveLength(3);
|
||||
|
||||
@@ -112,6 +112,23 @@ describe('My Plugins', () => {
|
||||
expect(screen.getByText('稳定版 2.0.0')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows system-included plugins as supplied with MakeLore without device package actions', () => {
|
||||
const systemIncluded = library.items[3]!;
|
||||
render(<MemoryRouter><MyPluginsView
|
||||
library={{ ...library, items: [systemIncluded] }} installations={{}} 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('随 MakeLore 提供')).toBeVisible();
|
||||
expect(screen.queryByText('尚未下载到设备')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '下载开发数据服务' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '更新开发数据服务' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '删除设备上的开发数据服务' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '启用到项目' })).toBeVisible();
|
||||
expect(screen.getByRole('link', { name: '分配给伙伴' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('updates an installed Beta package only through the explicit Beta action', () => {
|
||||
const onUpdate = vi.fn();
|
||||
const onInstallBeta = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user