feat(plugins): share hosted admission resolution
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# Task: Implement MLW-01 shared hosted admission resolver
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260901-web-search-mlw01-admission-b7d5f3a2
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260901-web-search-mlw01-admission-b7d5f3a2-web-search-mlw01-admission
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-web-search-mlw01-admission-b7d5f3a2
|
||||
- Base commit: c4db68767fcd1916e4441ab0e2c2504210f75e83
|
||||
- Owner: web-search-mlw01-implementer
|
||||
- Status: Ready for Integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Implement MLW-01 shared hosted admission resolution from the exact client
|
||||
frontier `c4db68767fcd1916e4441ab0e2c2504210f75e83`.
|
||||
- Add `electron/coding-plugins/hosted-admission.ts` as the single shared,
|
||||
provider-neutral resolver. It validates a trusted worker snapshot against
|
||||
the Package Store, MakeLore version, Marketplace channel resolution and the
|
||||
current account admission, returning only exact `releaseId` and
|
||||
`releaseAdmissionId` or a typed failure.
|
||||
- Refactor only the admission portion of
|
||||
`electron/coding-plugins/adapters/game-resource.ts` to consume the helper;
|
||||
preserve its existing provider, payload, confirmation, replay, and result
|
||||
behavior.
|
||||
- Add seam tests in `tests/unit/hosted-admission.test.ts` and the relevant
|
||||
admission regression coverage in
|
||||
`tests/unit/game-resource-plugin-adapter.test.ts`.
|
||||
- Do not implement Web Search client/adapter, composition, Renderer, Pi
|
||||
worker, Package Store/effective resolver, Server, or MLW-02/MLW-03 work.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Project Context Loaded: client AGENTS.md, the complete
|
||||
`maintain-project-docs` skill, TDD and implement-spec instructions, required
|
||||
project-memory entry points, the MLW-01 implementation ticket, and the
|
||||
reviewed Web Search design/implementation sections were read before coding.
|
||||
- Concurrent Task Gate: Passed. Task-context owner, isolated worktree, branch,
|
||||
owner, mode, and exact base match; the client coordinator and user root
|
||||
worktrees remain untouched.
|
||||
- Planning Gate: Passed. The prior broad Web Search feature/coordinator
|
||||
records were inspected read-only. Their old `0a86ec8...` base and broad
|
||||
coordination scope overlap by topic, but the current coordinator explicitly
|
||||
owns this isolated MLW-01 ticket from the frozen DTO frontier; no unresolved
|
||||
semantic conflict remains.
|
||||
- The helper accepts only code-owned plugin identity, trusted worker snapshot,
|
||||
Package Store, Marketplace client, and MakeLore version. It does not accept
|
||||
provider/config, payload, billing, or HTTP-route inputs and sends no business
|
||||
request of its own.
|
||||
- Use TDD red -> green vertical slices and public seams. Every source change
|
||||
stays within the five product/test paths above plus this task record. One
|
||||
source commit with this task record included, clean worktree, and
|
||||
`READY_FOR_INTEGRATION` handoff are required. No push, PR, deploy, publish,
|
||||
or real Provider call.
|
||||
- Project Context Loaded:
|
||||
- Task ID: `20260901-web-search-mlw01-admission-b7d5f3a2`
|
||||
- Mode: Feature
|
||||
- Branch: `codex/20260901-web-search-mlw01-admission-b7d5f3a2-web-search-mlw01-admission`
|
||||
- Worktree: `D:\Datas\OthersProjects\makelore-web-search-mlw01-admission-b7d5f3a2`
|
||||
- Base commit: `c4db68767fcd1916e4441ab0e2c2504210f75e83`
|
||||
- Other active local tasks: old Web Search feature/coordinator records and
|
||||
unrelated historical review records; no other task owns the files in this
|
||||
ticket.
|
||||
- Overlap assessment: topic overlap with the old Web Search coordinator is
|
||||
resolved by current ticket ownership and exact-base isolation; no code-path
|
||||
or semantic overlap is left within this ticket's owned files.
|
||||
- Planned slices: (1) resolver contract and mismatch tests; (2) minimal
|
||||
resolver implementation; (3) Game Resource seam migration and regression;
|
||||
(4) focused/adjacent/typecheck/lint/doc gates; (5) one source commit and
|
||||
clean handoff.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Implemented the provider-neutral `MarketplaceHostedAdmissionResolver` and
|
||||
migrated Game Resource's admission seam to use it. A worker must carry a
|
||||
frozen release; the resolver reads that exact installed release, resolves the
|
||||
current account admission through the selected channel, and returns only the
|
||||
exact release/admission IDs. Release, channel, action, digest, size, request,
|
||||
and account mismatches fail with typed bounded errors. No current-release
|
||||
fallback, provider/config, payload, billing, HTTP, Web Search, or renderer
|
||||
behavior was added.
|
||||
- The Game Resource adapter preserves its existing client and tool behavior,
|
||||
while missing or stale hosted admission now fails closed through the shared
|
||||
resolver. The only product/test paths changed are the five paths listed in
|
||||
Scope; this task record is the sole project-doc change.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red: the new resolver suite initially collected zero tests because the
|
||||
new module was absent (expected module-not-found); no product module existed
|
||||
before the red checkpoint.
|
||||
- TDD green: hosted-admission and Game Resource admission suites passed 12/12.
|
||||
- `pnpm run typecheck` passed.
|
||||
- Adjacent coding-plugin/Game Resource suite passed 74/74 across 6 files.
|
||||
- Full `pnpm test` passed: 212 files, 1,728 tests, 2 skipped; pressure suite
|
||||
passed 1/1. `pnpm run lint:check` passed with 0 errors and 5 pre-existing
|
||||
warnings outside this task (`Home` hook dependency and `Makelore` fast-refresh
|
||||
exports). Owned-file ESLint and compile checks passed.
|
||||
- Worktree diff/doc gates are being finalized before the single source commit;
|
||||
no real provider call, publish, deployment, or production activation was
|
||||
attempted.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Coordinator must cherry-pick the single source commit from this exact base,
|
||||
then run its repository-local integration and fixed-range review gates.
|
||||
- Real PostgreSQL, live OpenAI, official signing-key, provider-secret, and
|
||||
production-activation gates remain outside this client ticket and HOLD as
|
||||
specified by the parent Web Search rollout.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -4,6 +4,10 @@ import path from 'node:path';
|
||||
import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins';
|
||||
import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets';
|
||||
import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store';
|
||||
import {
|
||||
MarketplaceHostedAdmissionError,
|
||||
MarketplaceHostedAdmissionResolver,
|
||||
} from '../hosted-admission';
|
||||
import {
|
||||
GameResourceClient,
|
||||
GameResourceClientError,
|
||||
@@ -28,6 +32,7 @@ export interface GameResourcePluginAdapterOptions {
|
||||
readonly marketplace: MarketplacePackageClientPort;
|
||||
readonly packageStore: Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>;
|
||||
readonly makeloreVersion: string;
|
||||
readonly admissionResolver?: MarketplaceHostedAdmissionResolver;
|
||||
readonly gameAssets?: PiGameAssetTools;
|
||||
}
|
||||
|
||||
@@ -86,6 +91,9 @@ function clientFailure(error: unknown): AdapterInvocationResult {
|
||||
if (error instanceof GameResourceClientError) {
|
||||
return failure(error.code, error.message, error.status, error.retryable);
|
||||
}
|
||||
if (error instanceof MarketplaceHostedAdmissionError) {
|
||||
return failure(error.code, error.message, error.status, error.retryable);
|
||||
}
|
||||
return failure(
|
||||
'plugin_backend_unavailable',
|
||||
'Hosted game-resource service is temporarily unavailable',
|
||||
@@ -159,9 +167,15 @@ function templateConfig(value: unknown): Readonly<Record<string, unknown>> {
|
||||
export class GameResourcePluginAdapter implements CodingPluginAdapter {
|
||||
readonly pluginId = PLUGIN_ID;
|
||||
private readonly gameAssets: PiGameAssetTools;
|
||||
private readonly admissionResolver: MarketplaceHostedAdmissionResolver;
|
||||
|
||||
constructor(private readonly options: GameResourcePluginAdapterOptions) {
|
||||
this.gameAssets = options.gameAssets ?? new PiGameAssetTools();
|
||||
this.admissionResolver = options.admissionResolver ?? new MarketplaceHostedAdmissionResolver({
|
||||
marketplace: options.marketplace,
|
||||
packageStore: options.packageStore,
|
||||
makeloreVersion: options.makeloreVersion,
|
||||
});
|
||||
}
|
||||
|
||||
async inspect(): Promise<PluginBackendProjection> {
|
||||
@@ -287,28 +301,13 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter {
|
||||
releaseId: string;
|
||||
releaseAdmissionId: string;
|
||||
}> {
|
||||
const installed = context.pluginReleaseId
|
||||
? await this.options.packageStore.getInstalledRelease(PLUGIN_ID, context.pluginReleaseId)
|
||||
: await this.options.packageStore.getInstalled(PLUGIN_ID);
|
||||
if (!installed || !installed.channel) {
|
||||
throw new GameResourceClientError('plugin_release_unavailable', 409, false, 'Installed game-resource Release is unavailable');
|
||||
}
|
||||
const resolved = await this.options.marketplace.resolve({
|
||||
resolveRequestId: context.requestId,
|
||||
makeloreVersion: this.options.makeloreVersion,
|
||||
channel: installed.channel,
|
||||
installed: [{
|
||||
pluginId: installed.pluginId,
|
||||
releaseId: installed.releaseId,
|
||||
sha256: installed.sha256,
|
||||
}],
|
||||
return this.admissionResolver.resolve({
|
||||
pluginId: PLUGIN_ID,
|
||||
workerSnapshot: {
|
||||
requestId: context.requestId,
|
||||
...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }),
|
||||
},
|
||||
});
|
||||
const item = resolved.items.find(({ pluginId }) => pluginId === PLUGIN_ID);
|
||||
if (!item?.releaseId || !item.releaseAdmissionId || item.releaseId !== installed.releaseId
|
||||
|| (item.action !== 'keep' && item.action !== 'install')) {
|
||||
throw new GameResourceClientError('plugin_runtime_stale', 409, false, 'Game-resource worker Release is stale');
|
||||
}
|
||||
return { releaseId: item.releaseId, releaseAdmissionId: item.releaseAdmissionId };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
184
electron/coding-plugins/hosted-admission.ts
Normal file
184
electron/coding-plugins/hosted-admission.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { MarketplaceResolveItem } from './account-plugin-cache';
|
||||
import type { MarketplacePackageClientPort, PluginPackageStore } from './package-store';
|
||||
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
|
||||
const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
||||
const REQUEST_ID_PATTERN = /^[\x21-\x7e]{1,128}$/u;
|
||||
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
||||
|
||||
export interface HostedAdmissionWorkerSnapshot {
|
||||
/** Main-issued identity for the logical worker operation. */
|
||||
readonly requestId: string;
|
||||
/** Release frozen into this parent worker's effective snapshot. */
|
||||
readonly pluginReleaseId?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceHostedAdmissionInput {
|
||||
readonly pluginId: string;
|
||||
readonly workerSnapshot: HostedAdmissionWorkerSnapshot;
|
||||
}
|
||||
|
||||
export interface MarketplaceHostedAdmission {
|
||||
readonly releaseId: string;
|
||||
readonly releaseAdmissionId: string;
|
||||
}
|
||||
|
||||
export type MarketplaceHostedAdmissionErrorCode =
|
||||
| 'plugin_release_unavailable'
|
||||
| 'plugin_runtime_stale'
|
||||
| 'plugin_account_changed'
|
||||
| 'plugin_backend_unavailable';
|
||||
|
||||
export class MarketplaceHostedAdmissionError extends Error {
|
||||
constructor(
|
||||
readonly code: MarketplaceHostedAdmissionErrorCode,
|
||||
readonly status: number,
|
||||
readonly retryable: boolean,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'MarketplaceHostedAdmissionError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarketplaceHostedAdmissionResolverOptions {
|
||||
readonly packageStore: Pick<PluginPackageStore, 'getInstalledRelease'>;
|
||||
readonly marketplace: Pick<MarketplacePackageClientPort, 'resolve'>;
|
||||
readonly makeloreVersion: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorCode(value: unknown): unknown {
|
||||
return isRecord(value) ? value.code : undefined;
|
||||
}
|
||||
|
||||
function releaseId(value: unknown): string | null {
|
||||
return typeof value === 'string' && RELEASE_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function pluginId(value: unknown): string | null {
|
||||
return typeof value === 'string' && PLUGIN_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function requestId(value: unknown): string | null {
|
||||
return typeof value === 'string' && REQUEST_ID_PATTERN.test(value) ? value : null;
|
||||
}
|
||||
|
||||
function stale(message: string): MarketplaceHostedAdmissionError {
|
||||
return new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, message);
|
||||
}
|
||||
|
||||
function unavailable(message: string): MarketplaceHostedAdmissionError {
|
||||
return new MarketplaceHostedAdmissionError('plugin_release_unavailable', 409, false, message);
|
||||
}
|
||||
|
||||
function backendUnavailable(): MarketplaceHostedAdmissionError {
|
||||
return new MarketplaceHostedAdmissionError(
|
||||
'plugin_backend_unavailable',
|
||||
503,
|
||||
true,
|
||||
'Hosted Plugin admission service is temporarily unavailable',
|
||||
);
|
||||
}
|
||||
|
||||
function accountChanged(): MarketplaceHostedAdmissionError {
|
||||
return new MarketplaceHostedAdmissionError(
|
||||
'plugin_account_changed',
|
||||
409,
|
||||
false,
|
||||
'Marketplace account changed while resolving hosted Plugin admission',
|
||||
);
|
||||
}
|
||||
|
||||
function matchesFrozenRelease(
|
||||
item: MarketplaceResolveItem,
|
||||
installed: Awaited<ReturnType<PluginPackageStore['getInstalledRelease']>>,
|
||||
plugin: string,
|
||||
): item is MarketplaceResolveItem & {
|
||||
readonly releaseId: string;
|
||||
readonly releaseAdmissionId: string;
|
||||
} {
|
||||
if (!installed) return false;
|
||||
if (item.pluginId !== plugin || (item.action !== 'keep' && item.action !== 'install')) return false;
|
||||
if (releaseId(item.releaseId) !== installed.releaseId
|
||||
|| releaseId(item.releaseAdmissionId) === null
|
||||
|| item.releaseAdmissionId === undefined
|
||||
|| item.releaseAdmissionId === null) return false;
|
||||
if (item.version !== installed.version || item.sha256 !== installed.sha256) return false;
|
||||
if (item.sizeBytes !== installed.sizeBytes || item.channel !== installed.channel) return false;
|
||||
return item.sha256 !== null && item.sha256 !== undefined && SHA256_PATTERN.test(item.sha256);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current account admission for the exact Release frozen into a
|
||||
* parent worker. Marketplace identity and Release freshness are the only
|
||||
* concerns here; provider, payload, billing, and transport routes remain
|
||||
* outside this boundary.
|
||||
*/
|
||||
export class MarketplaceHostedAdmissionResolver {
|
||||
constructor(private readonly options: MarketplaceHostedAdmissionResolverOptions) {}
|
||||
|
||||
async resolve(input: MarketplaceHostedAdmissionInput): Promise<MarketplaceHostedAdmission> {
|
||||
const plugin = pluginId(input?.pluginId);
|
||||
const worker = input?.workerSnapshot;
|
||||
const request = requestId(worker?.requestId);
|
||||
const frozenRelease = releaseId(worker?.pluginReleaseId);
|
||||
if (!plugin || !request) throw unavailable('Hosted Plugin worker identity is unavailable');
|
||||
if (!frozenRelease) throw unavailable('Hosted Plugin worker Release is unavailable');
|
||||
|
||||
let installed: Awaited<ReturnType<PluginPackageStore['getInstalledRelease']>>;
|
||||
try {
|
||||
installed = await this.options.packageStore.getInstalledRelease(plugin, frozenRelease);
|
||||
} catch (error) {
|
||||
throw this.mapError(error);
|
||||
}
|
||||
if (!installed || installed.pluginId !== plugin || installed.releaseId !== frozenRelease
|
||||
|| installed.unavailableReason || !installed.channel) {
|
||||
throw unavailable('Installed hosted Plugin Release is unavailable');
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await this.options.marketplace.resolve({
|
||||
resolveRequestId: request,
|
||||
makeloreVersion: this.options.makeloreVersion,
|
||||
channel: installed.channel,
|
||||
installed: [{
|
||||
pluginId: installed.pluginId,
|
||||
releaseId: installed.releaseId,
|
||||
sha256: installed.sha256,
|
||||
}],
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.mapError(error);
|
||||
}
|
||||
|
||||
if (resolved.stale || resolved.resolveRequestId !== request) {
|
||||
throw stale('Hosted Plugin worker admission is stale');
|
||||
}
|
||||
const item = resolved.items.find(({ pluginId: candidate }) => candidate === plugin);
|
||||
if (!item || !matchesFrozenRelease(item, installed, plugin)) {
|
||||
throw stale('Hosted Plugin worker Release admission is stale');
|
||||
}
|
||||
return Object.freeze({
|
||||
releaseId: item.releaseId,
|
||||
releaseAdmissionId: item.releaseAdmissionId,
|
||||
});
|
||||
}
|
||||
|
||||
private mapError(error: unknown): MarketplaceHostedAdmissionError {
|
||||
if (error instanceof MarketplaceHostedAdmissionError) return error;
|
||||
const code = errorCode(error);
|
||||
if (code === 'marketplace_account_changed' || code === 'plugin_account_changed') return accountChanged();
|
||||
return backendUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
export function createMarketplaceHostedAdmissionResolver(
|
||||
options: MarketplaceHostedAdmissionResolverOptions,
|
||||
): MarketplaceHostedAdmissionResolver {
|
||||
return new MarketplaceHostedAdmissionResolver(options);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ async function fixture() {
|
||||
})),
|
||||
getInstalledRelease: vi.fn(async () => ({
|
||||
pluginId: 'makelore.game-resource', releaseId: RELEASE_ID, version: '1.0.0',
|
||||
sha256: 'a'.repeat(64), channel: 'stable',
|
||||
sha256: 'a'.repeat(64), sizeBytes: 1, channel: 'stable',
|
||||
})),
|
||||
} as unknown as Pick<PluginPackageStore, 'getInstalled' | 'getInstalledRelease'>,
|
||||
makeloreVersion: '2.0.0',
|
||||
@@ -181,4 +181,22 @@ describe('GameResourcePluginAdapter', () => {
|
||||
releaseId: RELEASE_ID, releaseAdmissionId: 'admission-a', kind: 'pixel',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed when a worker has no frozen release instead of using the current package', async () => {
|
||||
const { adapter, context, client, resolve } = await fixture();
|
||||
|
||||
await expect(adapter.invoke(
|
||||
{ ...context, pluginReleaseId: undefined },
|
||||
tool('game_resource_templates'),
|
||||
{ kind: 'pixel' },
|
||||
)).resolves.toMatchObject({
|
||||
success: false,
|
||||
status: 409,
|
||||
code: 'plugin_release_unavailable',
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(client.templates).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
138
tests/unit/hosted-admission.test.ts
Normal file
138
tests/unit/hosted-admission.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MarketplaceHostedAdmissionError,
|
||||
MarketplaceHostedAdmissionResolver,
|
||||
} from '../../electron/coding-plugins/hosted-admission';
|
||||
import type { MarketplacePackageClientPort, PluginPackageStore } from '../../electron/coding-plugins/package-store';
|
||||
|
||||
const PLUGIN_ID = 'makelore.game-resource';
|
||||
const RELEASE_ID = 'release-game-1';
|
||||
const REQUEST_ID = 'pi:run-a:resource-a';
|
||||
const DIGEST = 'a'.repeat(64);
|
||||
|
||||
function installed() {
|
||||
return {
|
||||
pluginId: PLUGIN_ID,
|
||||
releaseId: RELEASE_ID,
|
||||
version: '1.0.0',
|
||||
packageSchemaVersion: 2,
|
||||
contractVersion: 1,
|
||||
runtimeKind: 'platform_hosted' as const,
|
||||
sha256: DIGEST,
|
||||
sizeBytes: 1,
|
||||
installedAt: '2026-09-01T00:00:00.000Z',
|
||||
channel: 'stable' as const,
|
||||
};
|
||||
}
|
||||
|
||||
function resolved(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
resolveRequestId: REQUEST_ID,
|
||||
resolveRequestDigest: DIGEST,
|
||||
items: [{
|
||||
pluginId: PLUGIN_ID,
|
||||
action: 'keep' as const,
|
||||
releaseId: RELEASE_ID,
|
||||
version: '1.0.0',
|
||||
sha256: DIGEST,
|
||||
sizeBytes: 1,
|
||||
releaseAdmissionId: 'admission-game-1',
|
||||
expiresAt: '2100-01-01T00:00:00Z',
|
||||
channel: 'stable' as const,
|
||||
reason: null,
|
||||
...overrides,
|
||||
}],
|
||||
catalogGeneration: 1,
|
||||
etag: '"plugins-1-tp-free"',
|
||||
stale: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolverFixture() {
|
||||
const getInstalledRelease = vi.fn(async () => installed());
|
||||
const resolve = vi.fn(async () => resolved());
|
||||
const resolver = new MarketplaceHostedAdmissionResolver({
|
||||
packageStore: { getInstalledRelease } as unknown as Pick<PluginPackageStore, 'getInstalledRelease'>,
|
||||
marketplace: { resolve } as unknown as Pick<MarketplacePackageClientPort, 'resolve'>,
|
||||
makeloreVersion: '2.0.0',
|
||||
});
|
||||
return { resolver, getInstalledRelease, resolve };
|
||||
}
|
||||
|
||||
describe('MarketplaceHostedAdmissionResolver', () => {
|
||||
it('resolves the frozen package release through the selected channel and current account admission', async () => {
|
||||
const { resolver, getInstalledRelease, resolve } = resolverFixture();
|
||||
|
||||
await expect(resolver.resolve({
|
||||
pluginId: PLUGIN_ID,
|
||||
workerSnapshot: { requestId: REQUEST_ID, pluginReleaseId: RELEASE_ID },
|
||||
})).resolves.toEqual({
|
||||
releaseId: RELEASE_ID,
|
||||
releaseAdmissionId: 'admission-game-1',
|
||||
});
|
||||
|
||||
expect(getInstalledRelease).toHaveBeenCalledWith(PLUGIN_ID, RELEASE_ID);
|
||||
expect(resolve).toHaveBeenCalledWith({
|
||||
resolveRequestId: REQUEST_ID,
|
||||
makeloreVersion: '2.0.0',
|
||||
channel: 'stable',
|
||||
installed: [{ pluginId: PLUGIN_ID, releaseId: RELEASE_ID, sha256: DIGEST }],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not substitute the current package when the worker has no frozen release', async () => {
|
||||
const { resolver, getInstalledRelease, resolve } = resolverFixture();
|
||||
|
||||
await expect(resolver.resolve({
|
||||
pluginId: PLUGIN_ID,
|
||||
workerSnapshot: { requestId: REQUEST_ID },
|
||||
})).rejects.toMatchObject({
|
||||
code: 'plugin_release_unavailable',
|
||||
status: 409,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
expect(getInstalledRelease).not.toHaveBeenCalled();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['release mismatch', { releaseId: 'release-other' }],
|
||||
['channel mismatch', { channel: 'beta' }],
|
||||
['action mismatch', { action: 'update' }],
|
||||
['digest mismatch', { sha256: 'b'.repeat(64) }],
|
||||
])('returns one typed stale failure for %s', async (_label, item) => {
|
||||
const marketplace = {
|
||||
resolve: vi.fn(async () => resolved(item)),
|
||||
} as unknown as Pick<MarketplacePackageClientPort, 'resolve'>;
|
||||
const staleResolver = new MarketplaceHostedAdmissionResolver({
|
||||
packageStore: { getInstalledRelease: vi.fn(async () => installed()) } as unknown as Pick<PluginPackageStore, 'getInstalledRelease'>,
|
||||
marketplace,
|
||||
makeloreVersion: '2.0.0',
|
||||
});
|
||||
|
||||
const error = await staleResolver.resolve({
|
||||
pluginId: PLUGIN_ID,
|
||||
workerSnapshot: { requestId: REQUEST_ID, pluginReleaseId: RELEASE_ID },
|
||||
}).catch((value: unknown) => value);
|
||||
|
||||
expect(error).toBeInstanceOf(MarketplaceHostedAdmissionError);
|
||||
expect(error).toMatchObject({ code: 'plugin_runtime_stale', status: 409, retryable: false });
|
||||
});
|
||||
|
||||
it('projects an account change as a typed non-retryable worker failure', async () => {
|
||||
const { resolver, resolve } = resolverFixture();
|
||||
resolve.mockRejectedValueOnce({ code: 'marketplace_account_changed' });
|
||||
|
||||
await expect(resolver.resolve({
|
||||
pluginId: PLUGIN_ID,
|
||||
workerSnapshot: { requestId: REQUEST_ID, pluginReleaseId: RELEASE_ID },
|
||||
})).rejects.toMatchObject({
|
||||
code: 'plugin_account_changed',
|
||||
status: 409,
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user