diff --git a/src/classes/Manager.ts b/src/classes/Manager.ts index 1cd41b2..7ea4dcf 100644 --- a/src/classes/Manager.ts +++ b/src/classes/Manager.ts @@ -39,6 +39,19 @@ export class Manager extends Base { if (isNewPackage) this.packages.set(pkg.slug, pkgExisting); } + // Same as addPackage(), but ingests via Package.addVersionSummary() - see sync(). + addPackageSummary(pkg: Package) { + let pkgExisting = this.packages.get(pkg.slug); + const isNewPackage: boolean = !pkgExisting; + if (!pkgExisting) { + pkgExisting = new Package(pkg.slug); + } + for (const [version, pkgVersion] of pkg.versions) { + pkgExisting.addVersionSummary(version, pkgVersion); + } + if (isNewPackage) this.packages.set(pkg.slug, pkgExisting); + } + filter(method: (pkgVersion: PackageVersion, pkg: Package) => boolean): Package[] { const results: Package[] = []; for (const [, pkg] of this.packages) { @@ -152,7 +165,12 @@ export class Manager extends Base { // Add one version at a time (rather than the whole package via addPackage() in one // call) so a single malformed version - from a registry this manager doesn't // control - can't abort every other version/package still left to sync. - this.addPackage(new Package(slug, { [version]: json[type][slug].versions[version] })); + // The registry root only summarizes each package's latest version, and omits + // `url`/`sha256` from each file (see specification.md "Listing endpoints vs package + // endpoints") - addPackageSummary() ingests that shape without rejecting it for + // fields it never claimed to include. Anything that needs to actually download a file + // (install(), etc.) resolves the full version separately via fetchPackageVersion(). + this.addPackageSummary(new Package(slug, { [version]: json[type][slug].versions[version] })); } catch (err) { this.syncErrors.push(`${slug}@${version}: ${(err as Error).message}`); } @@ -161,6 +179,56 @@ export class Manager extends Base { } } + // Fetches the full per-version payload (every file's `url`/`sha256` included) directly from the + // org/package/version-level endpoint - used whenever a cached Package (populated by sync()'s + // trimmed summary) doesn't have everything an operation needs, e.g. an older version that isn't + // the latest, or the latest version's download data. Tries each configured registry in order, + // the same way sync() combines them; returns undefined if none of them have it. + protected async fetchPackageVersion(slug: string, version: string): Promise { + const registries: ConfigRegistry[] = this.config.get('registries') as ConfigRegistry[]; + for (const registry of registries) { + try { + const url = `${registryUrl(registry).replace(/\/$/, '')}/${this.type}/${slug}/${version}`; + return (await apiJson(url)) as PackageVersion; + } catch { + continue; + } + } + return undefined; + } + + // A sync()-cached summary version has every file's compatibility fields (architectures, + // systems, contains, type, size) but not `url`/`sha256` - so it's enough for listing/filtering, + // but not for an actual download. Every file having `url` is the reliable tell that this is the + // full payload, not the summary. + private isDownloadable(pkgVersion?: PackageVersion): pkgVersion is PackageVersion { + return ( + !!pkgVersion && !!pkgVersion.files && pkgVersion.files.length > 0 && pkgVersion.files.every(file => !!file.url) + ); + } + + // Resolves a package/version pair to installable data, transparently upgrading a sync()-cached + // summary to the full payload (with `url`/`sha256`) on demand. Returns undefined if the + // package/version genuinely doesn't exist in the registry. + async resolvePackageVersion( + slug: string, + version?: string, + ): Promise<{ pkg: Package; pkgVersion: PackageVersion; versionNum: string } | undefined> { + const pkg = this.getPackage(slug); + if (!pkg) return undefined; + const versionNum = version || pkg.latestVersion(); + let pkgVersion = pkg.getVersion(versionNum); + if (!this.isDownloadable(pkgVersion)) { + const fetched = await this.fetchPackageVersion(slug, versionNum); + if (fetched) { + pkg.addVersion(versionNum, fetched); + pkgVersion = fetched; + } + } + if (!pkgVersion) return undefined; + return { pkg, pkgVersion, versionNum }; + } + toJSON() { const data: RegistryPackages = {}; for (const [slug, pkg] of this.packages.entries()) { diff --git a/src/classes/ManagerLocal.ts b/src/classes/ManagerLocal.ts index faac424..b137c1f 100644 --- a/src/classes/ManagerLocal.ts +++ b/src/classes/ManagerLocal.ts @@ -258,12 +258,16 @@ export class ManagerLocal extends Manager { // elevated command payload built below. if (!isValidSlug(slug)) throw new Error(`Invalid package slug: ${slug}`); if (version && !isValidVersion(version)) throw new Error(`Invalid package version: ${version}`); - // Get package information from registry. + // Get package information from registry. resolvePackageVersion() transparently fetches the + // full per-version payload (every file's url/sha256 included) whenever sync()'s cached + // summary doesn't have it - either because this isn't the latest version, or because each + // file's url/sha256 was omitted from the registry root/list endpoints (see specification.md + // "Listing endpoints vs package endpoints"). const pkg: Package | undefined = this.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); - const versionNum: string = version || pkg.latestVersion(); - const pkgVersion: PackageVersion | undefined = pkg?.getVersion(versionNum); - if (!pkgVersion) throw new Error(`Package ${slug} version ${versionNum} not found in registry`); + const resolved = await this.resolvePackageVersion(slug, version); + if (!resolved) throw new Error(`Package ${slug} version ${version || pkg.latestVersion()} not found in registry`); + const { pkgVersion, versionNum } = resolved; if (this.isPackageInstalled(slug, versionNum)) { this.log(`Package ${slug} version ${versionNum} already installed`); pkgVersion.installed = true; @@ -506,9 +510,11 @@ export class ManagerLocal extends Manager { manager.scan(); const pkg: Package | undefined = manager.getPackage(slug); if (!pkg) throw new Error(`Package ${slug} not found in registry`); - const versionNum: string = version || pkg.latestVersion(); - const pkgVersion: PackageVersion | undefined = pkg?.getVersion(versionNum); - if (!pkgVersion) throw new Error(`Package ${slug} version ${versionNum} not found in registry`); + // resolvePackageVersion() fetches the full per-version payload on demand when sync()'s cached + // summary doesn't have it - see install()'s comment for why that can happen. + const resolved = await manager.resolvePackageVersion(slug, version); + if (!resolved) throw new Error(`Package ${slug} version ${version || pkg.latestVersion()} not found in registry`); + const { versionNum } = resolved; // Get local package file. const pkgFile = packageLoadFile(filePath) as any; if (pkgFile[type] && pkgFile[type][slug] && pkgFile[type][slug] === versionNum) { diff --git a/src/classes/Package.ts b/src/classes/Package.ts index 02c83bb..a222380 100644 --- a/src/classes/Package.ts +++ b/src/classes/Package.ts @@ -1,6 +1,6 @@ import * as semver from 'semver'; import { PackageVersionReport, PackageVersion, PackageVersions } from '../types/Package.js'; -import { packageErrors, packageIsVerified, packageRecommendations } from '../helpers/package.js'; +import { packageErrors, packageIsVerified, packageRecommendations, packageSummaryErrors } from '../helpers/package.js'; import { isValidSlug } from '../helpers/utils.js'; import { Base } from './Base.js'; @@ -19,23 +19,35 @@ export class Package extends Base { this.version = this.latestVersion(); } - addVersion(num: string, version: PackageVersion) { - // For now allow package versions to be overwritten. - // if (this.versions.has(num)) return this.log(`Package ${version.name} version ${num} already exists`); - const errors = packageErrors(version); + private setVersion(num: string, version: PackageVersion, errors: PackageVersionReport['errors']) { const recs = packageRecommendations(version); const report: PackageVersionReport = { - ...(errors.length > 0 && { errors }), + ...(errors && errors.length > 0 && { errors }), ...(recs.length > 0 && { recs }), }; if (Object.keys(report).length > 0) this.reports.set(num, report); - if (errors.length > 0) + if (errors && errors.length > 0) throw new Error(`Package ${version.name} version ${num} has validation errors: ${JSON.stringify(errors)}`); version.verified = packageIsVerified(this.slug, version); this.versions.set(num, version); this.version = this.latestVersion(); } + addVersion(num: string, version: PackageVersion) { + // For now allow package versions to be overwritten. + // if (this.versions.has(num)) return this.log(`Package ${version.name} version ${num} already exists`); + this.setVersion(num, version, packageErrors(version)); + } + + // Same as addVersion(), but validates against PackageVersionSummaryValidator instead - each + // file's `url`/`sha256` are optional, matching the trimmed payload the registry root/list + // endpoints actually serve (see specification.md "Listing endpoints vs package endpoints"). + // Used by Manager.sync(); any caller that needs to actually download a file resolves the full + // version separately (see Manager.fetchPackageVersion()) and stores it via addVersion() instead. + addVersionSummary(num: string, version: PackageVersion) { + this.setVersion(num, version, packageSummaryErrors(version)); + } + removeVersion(num: string) { if (!this.versions.has(num)) return; this.versions.delete(num); diff --git a/src/helpers/package.ts b/src/helpers/package.ts index 2638716..127bc69 100644 --- a/src/helpers/package.ts +++ b/src/helpers/package.ts @@ -22,6 +22,9 @@ export function packageCompatibleFiles( sys: SystemType[], excludedFormats?: FileFormat[], ) { + // Defensive - every real PackageVersion has `files`, but a malformed one shouldn't crash a + // filter/listing pass. + if (!pkg.files) return []; return pkg.files.filter((file: FileInterface) => { const archMatches = file.architectures.filter(architecture => { return arch.includes(architecture); @@ -29,8 +32,12 @@ export function packageCompatibleFiles( const sysMatches = file.systems.filter(system => { return sys.includes(system.type); }); - const formatAllowed = - excludedFormats && excludedFormats.includes(pathGetExt(file.url).toLowerCase() as FileFormat) ? false : true; + // `url` is omitted from each file at the registry root/list endpoints (see + // specification.md "Listing endpoints vs package endpoints") - format can't be determined + // from it there, so don't exclude on a format we can't check. install() always resolves the + // full version (with `url`) before this matters for real exclusion decisions. + const fileFormat = file.url ? (pathGetExt(file.url).toLowerCase() as FileFormat) : undefined; + const formatAllowed = !(fileFormat && excludedFormats && excludedFormats.includes(fileFormat)); return archMatches.length && sysMatches.length && formatAllowed; }); } @@ -39,6 +46,16 @@ export function packageErrors(pkgVersion: PackageVersion) { return PackageVersionValidator.safeParse(pkgVersion).error?.issues || []; } +// The registry root and type-list endpoints (see specification.md "Listing endpoints vs package +// endpoints") only summarize each package's latest version, and omit `url`/`sha256` from each +// file to keep those documents small - used by Manager.sync() so that summary ingestion doesn't +// reject every package over fields it never claimed to include. Org/package/version endpoints +// still return every file in full, validated via packageErrors() when that data is actually +// resolved (see Manager.fetchPackageVersion()). +export function packageSummaryErrors(pkgVersion: PackageVersion) { + return PackageVersionSummaryValidator.safeParse(pkgVersion).error?.issues || []; +} + export function packageFileMap(pkgVersion: PackageVersion) { return pkgVersion.files.reduce((result: PackageFileMap, file) => { file.systems.forEach(system => { @@ -100,6 +117,16 @@ export const PackageVersionValidator = z.object({ url: z.string().min(8).max(256).startsWith('https://'), }); +// Same shape as PackageFileValidator, but `url`/`sha256` are optional - see +// packageSummaryErrors(). +export const PackageFileSummaryValidator = PackageFileValidator.partial({ url: true, sha256: true }); + +// Same shape as PackageVersionValidator, but each file is validated against +// PackageFileSummaryValidator instead - see packageSummaryErrors(). +export const PackageVersionSummaryValidator = PackageVersionValidator.extend({ + files: z.array(PackageFileSummaryValidator).min(1).max(256), +}); + export const SemverValidator = z .string() .regex( @@ -143,6 +170,12 @@ export function packageRecommendations(pkgVersion: PackageVersion) { file.systems.forEach(system => { supportedSystems[system.type] = true; }); + + // `url` is omitted from each file at the registry root/list endpoints (see + // specification.md "Listing endpoints vs package endpoints") - every recommendation below + // is derived from the url itself, so there's nothing to check without it. + if (!file.url) return; + const ext: string = pathGetExt(file.url).toLowerCase(); supportedFileFormats[ext] = true; packageRecommendationsUrl(file, recs, 'url', 'github'); @@ -243,8 +276,14 @@ export function packageYamlToJs(pkgYaml: string) { } export function packageIsVerified(slug: string, pkgVersion: PackageVersion) { + if (!pkgVersion.files || pkgVersion.files.length === 0) return false; const org: string = slug.split('/')[0]; return pkgVersion.files.every(file => { + // `url` is omitted from each file at the registry root/list endpoints (see + // specification.md "Listing endpoints vs package endpoints") - nothing to verify against yet, + // so report unverified rather than throwing. install() re-resolves the full version (with + // `url`) before this ever matters for a real decision. + if (!file.url) return false; const url: string = file.url.toLowerCase(); const root: string = url.startsWith('https://github.com/') ? 'https://github.com/' + org + '/' : `https://${org}.`; return url.startsWith(root); diff --git a/tests/classes/Manager.test.ts b/tests/classes/Manager.test.ts index 1e6af96..99e6465 100644 --- a/tests/classes/Manager.test.ts +++ b/tests/classes/Manager.test.ts @@ -16,7 +16,7 @@ import { SystemType } from '../../src/types/SystemType'; import { Architecture } from '../../src/types/Architecture'; import { PackageVersion } from '../../src/types/Package'; import { packageCompatibleFiles } from '../../src/helpers/package'; -import { mockRegistrySync, omitDownloads } from '../testUtils'; +import { mockRegistrySync, omitDownloads, toSummaryVersion } from '../testUtils'; import * as apiHelpers from '../../src/helpers/api'; afterEach(() => { @@ -134,6 +134,19 @@ test('Manager list packages incompatible', () => { expect(manager.listPackages(undefined, Architecture.X64, SystemType.Linux)).toEqual([pkgNoWin]); }); +test('Manager list packages by architecture/system works on a sync()-cached summary (url/sha256 omitted)', () => { + // Unlike install(), listing/filtering doesn't need a fetchPackageVersion() round trip - every + // compatibility field (architectures, systems, contains, type, size) is already present on the + // registry root/list endpoints, only url/sha256 are missing (see specification.md "Listing + // endpoints vs package endpoints"). + const manager = new Manager(RegistryType.Plugins); + const pkg = new Package(PLUGIN_PACKAGE.slug); + pkg.addVersionSummary(PLUGIN_PACKAGE.version, toSummaryVersion(PLUGIN)); + manager.addPackageSummary(pkg); + expect(manager.listPackages(undefined, Architecture.X64, SystemType.Linux)).toEqual([pkg]); + expect(manager.listPackages(undefined, Architecture.Arm32, SystemType.Linux)).toEqual([]); +}); + test('Manager filter packages', () => { const manager = new Manager(RegistryType.Plugins); const pkg = new Package(PLUGIN_PACKAGE.slug); @@ -281,3 +294,90 @@ test('Manager sync with existing package', async () => { const pkgReturned = manager.getPackage(PLUGIN_PACKAGE.slug); expect(pkgReturned?.getVersion(PLUGIN_PACKAGE.version)?.name).toEqual('Surge XT'); }); + +// The registry root/list endpoints only summarize each package's latest version, and omit +// `url`/`sha256` from each file (see specification.md "Listing endpoints vs package endpoints") - +// the following tests cover sync() ingesting that trimmed shape, and resolvePackageVersion() +// transparently fetching the full version (with every file's url/sha256) on demand. +test('Manager sync ingests a trimmed summary (files present, url/sha256 omitted) without rejecting the package', async () => { + const pluginSummary = toSummaryVersion(PLUGIN); + vi.spyOn(apiHelpers, 'apiJson').mockResolvedValue({ + name: 'Mock Registry', + url: 'https://example.invalid/mock', + version: '1.0.0', + [RegistryType.Plugins]: { + [PLUGIN_PACKAGE.slug]: { + slug: PLUGIN_PACKAGE.slug, + version: PLUGIN_PACKAGE.version, + versions: { [PLUGIN_PACKAGE.version]: pluginSummary }, + }, + }, + }); + + const manager = new Manager(RegistryType.Plugins, { + registries: [{ name: 'Mock Registry', url: 'https://example.invalid/mock' }], + }); + await manager.sync(); + + expect(manager.getSyncErrors()).toEqual([]); + const pkgVersion = manager.getPackage(PLUGIN_PACKAGE.slug)?.getVersion(PLUGIN_PACKAGE.version); + expect(pkgVersion?.files).toHaveLength(PLUGIN.files.length); + expect(pkgVersion?.files.every(file => !file.url && !file.sha256)).toEqual(true); + expect(pkgVersion?.name).toEqual(PLUGIN.name); +}); + +test('Manager resolvePackageVersion fetches the full version when the cached summary is missing url/sha256', async () => { + const pluginSummary = toSummaryVersion(PLUGIN); + const versionUrl = `https://example.invalid/mock/${RegistryType.Plugins}/${PLUGIN_PACKAGE.slug}/${PLUGIN_PACKAGE.version}`; + const apiJsonSpy = vi.spyOn(apiHelpers, 'apiJson').mockImplementation(async (url: string) => { + if (url === versionUrl) return PLUGIN; + return { + name: 'Mock Registry', + url: 'https://example.invalid/mock', + version: '1.0.0', + [RegistryType.Plugins]: { + [PLUGIN_PACKAGE.slug]: { + slug: PLUGIN_PACKAGE.slug, + version: PLUGIN_PACKAGE.version, + versions: { [PLUGIN_PACKAGE.version]: pluginSummary }, + }, + }, + }; + }); + + const manager = new Manager(RegistryType.Plugins, { + registries: [{ name: 'Mock Registry', url: 'https://example.invalid/mock' }], + }); + await manager.sync(); + const resolved = await manager.resolvePackageVersion(PLUGIN_PACKAGE.slug, PLUGIN_PACKAGE.version); + + expect(resolved?.pkgVersion.files).toEqual(PLUGIN.files); + expect(apiJsonSpy).toHaveBeenCalledWith(versionUrl); +}); + +test('Manager resolvePackageVersion returns undefined for an unknown package', async () => { + const manager = new Manager(RegistryType.Plugins); + expect(await manager.resolvePackageVersion('nonexistent-org/nonexistent-plugin')).toBeUndefined(); +}); + +test('Manager resolvePackageVersion returns undefined when no registry has the version', async () => { + const apiJsonSpy = mockRegistrySync(REGISTRY_PLUGIN_VER); + const manager = new Manager(RegistryType.Plugins, { + registries: [{ name: 'Mock Registry', url: 'https://example.invalid/mock' }], + }); + await manager.sync(); + apiJsonSpy.mockRejectedValue(new Error('not found')); + + expect(await manager.resolvePackageVersion(PLUGIN_PACKAGE.slug, '99.99.99')).toBeUndefined(); +}); + +test('Manager resolvePackageVersion does not refetch when the cached version already has files', async () => { + const apiJsonSpy = mockRegistrySync(REGISTRY_PLUGIN_VER); + const manager = new Manager(RegistryType.Plugins); + await manager.sync(); + apiJsonSpy.mockClear(); + + const resolved = await manager.resolvePackageVersion(PLUGIN_PACKAGE.slug, PLUGIN_PACKAGE.version); + expect(resolved?.pkgVersion.files).toEqual(PLUGIN.files); + expect(apiJsonSpy).not.toHaveBeenCalled(); +}); diff --git a/tests/classes/ManagerLocal.test.ts b/tests/classes/ManagerLocal.test.ts index fb45a56..f781ef6 100644 --- a/tests/classes/ManagerLocal.test.ts +++ b/tests/classes/ManagerLocal.test.ts @@ -32,7 +32,7 @@ import { PackageVersion } from '../../src/types/Package'; import { Architecture } from '../../src/types/Architecture'; import { SystemType } from '../../src/types/SystemType'; import { FileType } from '../../src/types/FileType'; -import { mockRegistrySync, omitDownloads } from '../testUtils'; +import { mockRegistrySync, omitDownloads, toSummaryVersion } from '../testUtils'; import * as apiHelpers from '../../src/helpers/api'; const APP_DIR: string = 'test'; @@ -176,6 +176,40 @@ test('Plugin sync, install, rescan, uninstall', async () => { expect(omitDownloads(pkgReturned2)).toEqual(omitDownloads(PLUGIN)); }); +test('Install fetches the full version when sync only cached a trimmed summary (url/sha256 omitted)', async () => { + // Mirrors what the registry root/list endpoints actually serve (see specification.md + // "Listing endpoints vs package endpoints") - only the latest version, each file missing + // url/sha256. install() must fall back to the org/package/version endpoint for that data. + const pluginSummary = toSummaryVersion(PLUGIN); + const versionUrl = + 'https://open-audio-stack.github.io/open-audio-stack-registry/plugins/surge-synthesizer/surge/1.3.1'; + vi.spyOn(apiHelpers, 'apiJson').mockImplementation(async (url: string) => { + if (url === versionUrl) return PLUGIN; + return { + name: 'Open Audio Registry', + url: 'https://open-audio-stack.github.io/open-audio-stack-registry', + version: '1.0.0', + plugins: { + [PLUGIN_PACKAGE.slug]: { + slug: PLUGIN_PACKAGE.slug, + version: PLUGIN_PACKAGE.version, + versions: { [PLUGIN_PACKAGE.version]: pluginSummary }, + }, + }, + }; + }); + + const manager = new ManagerLocal(RegistryType.Plugins, CONFIG); + await manager.sync(); + const cachedFiles = manager.getPackage(PLUGIN_PACKAGE.slug)?.getVersion(PLUGIN_PACKAGE.version)?.files; + expect(cachedFiles?.every(file => !file.url)).toEqual(true); + + const pkgReturned: PackageVersion | void = await manager.install(PLUGIN_PACKAGE.slug, PLUGIN_PACKAGE.version); + expect(omitDownloads(pkgReturned)).toEqual(omitDownloads(PLUGIN_INSTALLED)); + + await manager.uninstall(PLUGIN_PACKAGE.slug, PLUGIN_PACKAGE.version); +}); + test('Preset sync, install, rescan, uninstall', async () => { mockRegistrySync(REGISTRY_PACKAGE_TYPES); const manager = new ManagerLocal(RegistryType.Presets, CONFIG); diff --git a/tests/classes/Package.test.ts b/tests/classes/Package.test.ts index 3050abe..32378de 100644 --- a/tests/classes/Package.test.ts +++ b/tests/classes/Package.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'vitest'; import { Package } from '../../src/classes/Package'; import { PLUGIN, PLUGIN_PACKAGE, PLUGIN_PACKAGE_EMPTY } from '../data/Plugin'; import { PackageInterface, PackageVersion } from '../../src/types/Package'; +import { toSummaryVersion } from '../testUtils'; test('Package new', () => { const pkg = new Package(PLUGIN_PACKAGE.slug); @@ -30,6 +31,30 @@ test('Package add invalid version', () => { expect(pkg.toJSON()).toEqual(PLUGIN_PACKAGE_EMPTY); }); +test('Package add version summary accepts a version whose files are missing url/sha256', () => { + const pluginSummary = toSummaryVersion(PLUGIN); + const pkg = new Package(PLUGIN_PACKAGE.slug); + expect(() => pkg.addVersionSummary(PLUGIN_PACKAGE.version, pluginSummary)).not.toThrow(); + expect(pkg.getVersion(PLUGIN_PACKAGE.version)).toEqual({ ...pluginSummary, verified: false }); +}); + +test('Package add version summary still rejects other missing required fields', () => { + const pluginSummary = toSummaryVersion(PLUGIN); + // @ts-expect-error this is intentionally bad data. + delete pluginSummary.image; + const pkg = new Package(PLUGIN_PACKAGE.slug); + expect(() => pkg.addVersionSummary(PLUGIN_PACKAGE.version, pluginSummary)).toThrow(); + expect(pkg.toJSON()).toEqual(PLUGIN_PACKAGE_EMPTY); +}); + +test('Package add version upgrades a summary to the full version', () => { + const pluginSummary = toSummaryVersion(PLUGIN); + const pkg = new Package(PLUGIN_PACKAGE.slug); + pkg.addVersionSummary(PLUGIN_PACKAGE.version, pluginSummary); + pkg.addVersion(PLUGIN_PACKAGE.version, PLUGIN); + expect(pkg.getVersion(PLUGIN_PACKAGE.version)).toEqual(PLUGIN); +}); + test('Package remove version', () => { const pkg = new Package(PLUGIN_PACKAGE.slug); pkg.addVersion(PLUGIN_PACKAGE.version, PLUGIN); diff --git a/tests/helpers/package.test.ts b/tests/helpers/package.test.ts index bb1abd1..e1663f6 100644 --- a/tests/helpers/package.test.ts +++ b/tests/helpers/package.test.ts @@ -4,7 +4,9 @@ import { packageDownloadsTotal, packageIsVerified, packageRecommendations, + packageSummaryErrors, packageVersionLatest, + PackageVersionSummaryValidator, PackageVersionValidator, } from '../../src/helpers/package.js'; import { PLUGIN, PLUGIN_PACKAGE_MULTIPLE } from '../data/Plugin'; @@ -12,6 +14,7 @@ import { PackageVersion } from '../../src/types/Package'; import { Architecture } from '../../src/types/Architecture.js'; import { SystemType } from '../../src/types/SystemType.js'; import { FileFormat } from '../../src/types/FileFormat.js'; +import { toSummaryVersion } from '../testUtils'; test('Package version latest', () => { expect(packageVersionLatest(PLUGIN_PACKAGE_MULTIPLE)).toEqual('1.3.2'); @@ -157,6 +160,55 @@ test('Package is not verified when any file url does not match the org', () => { expect(packageIsVerified('surge-synthesizer/surge', pluginWithForeignFile)).toEqual(false); }); +test('Package is not verified when url is missing per file (summary payload)', () => { + const summaryPlugin = toSummaryVersion(PLUGIN); + expect(() => packageIsVerified('surge-synthesizer/surge', summaryPlugin)).not.toThrow(); + expect(packageIsVerified('surge-synthesizer/surge', summaryPlugin)).toEqual(false); +}); + +test('Package compatible files still matches on architecture/system when url is missing (summary payload)', () => { + const summaryPlugin = toSummaryVersion(PLUGIN); + // PLUGIN has two Linux/x64 files (the .deb and the .rpm) - both should still match without + // needing url, since compatibility here is architecture/system only (no excludedFormats). + expect(() => packageCompatibleFiles(summaryPlugin, [Architecture.X64], [SystemType.Linux])).not.toThrow(); + expect(packageCompatibleFiles(summaryPlugin, [Architecture.X64], [SystemType.Linux])).toHaveLength(2); +}); + +test('Package compatible files does not exclude a format it cannot determine (url missing)', () => { + const summaryPlugin = toSummaryVersion(PLUGIN); + // excludedFormats relies on the file's url extension - without it, format can't be checked, so + // this must not exclude the Linux files (unlike the full-data equivalent test above, which does + // exclude the .rpm). + const result = packageCompatibleFiles( + summaryPlugin, + [Architecture.X64], + [SystemType.Linux], + [FileFormat.RedHatPackage], + ); + expect(result).toHaveLength(2); +}); + +test('Package summary validator accepts a version whose files are missing url/sha256', () => { + const summaryPlugin = toSummaryVersion(PLUGIN); + expect(PackageVersionSummaryValidator.safeParse(summaryPlugin).success).toEqual(true); + expect(packageSummaryErrors(summaryPlugin)).toEqual([]); +}); + +test('Package summary validator still rejects other missing required fields', () => { + const summaryPlugin = toSummaryVersion(PLUGIN); + // @ts-expect-error this is intentionally bad data. + delete summaryPlugin.image; + expect(packageSummaryErrors(summaryPlugin)).toEqual([ + { + code: 'invalid_type', + expected: 'string', + message: 'Required', + path: ['image'], + received: 'undefined', + }, + ]); +}); + test('Package compatible files respects exclusions when alternatives exist', () => { const bothFormatsPackage: PackageVersion = { ...PLUGIN, diff --git a/tests/testUtils.ts b/tests/testUtils.ts index b912baa..c23a9e0 100644 --- a/tests/testUtils.ts +++ b/tests/testUtils.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; import * as apiHelpers from '../src/helpers/api.js'; +import { PackageVersion } from '../src/types/Package.js'; import { RegistryInterface } from '../src/types/Registry.js'; // Deep-clones `value`, dropping any key in `keys` at any depth. Used to compare fixtures against @@ -45,3 +46,17 @@ export function omitDownloads(value: T): T { export function mockRegistrySync(registry: RegistryInterface) { return vi.spyOn(apiHelpers, 'apiJson').mockImplementation(async () => structuredClone(registry)); } + +// The registry root/list endpoints only omit `url`/`sha256` from each file - not `files` itself, +// and not the version's own top-level `url` (a different field, the plugin's website) - so +// omitKeysDeep() (which would strip every key named `url` anywhere, including that one) isn't the +// right tool here. This mirrors what Manager.sync() actually caches (see specification.md +// "Listing endpoints vs package endpoints"). +export function toSummaryVersion(pkgVersion: PackageVersion): PackageVersion { + const summary = structuredClone(pkgVersion); + summary.files.forEach((file: any) => { + delete file.url; + delete file.sha256; + }); + return summary; +}