@@ -6,7 +6,13 @@ import { fileURLToPath } from 'node:url';
66import { describe , expect , it , vi } from 'vitest' ;
77
88import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app' ;
9- import { computeUpdateStatus , loadPluginMarketplace } from '#/utils/plugin-marketplace' ;
9+ import {
10+ computeUpdateStatus ,
11+ loadPluginMarketplace ,
12+ withBuiltInEntries ,
13+ withMarketplaceLatestVersions ,
14+ type PluginMarketplaceEntry ,
15+ } from '#/utils/plugin-marketplace' ;
1016
1117const REPO_ROOT = join ( import . meta. dirname , '../../../..' ) ;
1218
@@ -575,4 +581,127 @@ describe('loadPluginMarketplace', () => {
575581 ) ;
576582 } ) ;
577583
584+ describe ( 'two-phase version lookup' , ( ) => {
585+ async function writeCatalog ( dir : string ) {
586+ const file = join ( dir , 'marketplace.json' ) ;
587+ await writeFile (
588+ file ,
589+ JSON . stringify ( {
590+ plugins : [
591+ { id : 'demo' , displayName : 'Demo' , source : 'https://github.com/owner/repo' } ,
592+ ] ,
593+ } ) ,
594+ 'utf8' ,
595+ ) ;
596+ return file ;
597+ }
598+
599+ it ( 'skipLatestVersions returns the catalog without querying GitHub' , async ( ) => {
600+ const fetchImpl = vi . fn ( async ( ) => {
601+ throw new Error ( 'should not be called' ) ;
602+ } ) as unknown as typeof fetch ;
603+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'pythinker-plugin-marketplace-' ) ) ;
604+ const file = await writeCatalog ( dir ) ;
605+
606+ const marketplace = await loadPluginMarketplace ( {
607+ workDir : dir ,
608+ source : file ,
609+ fetchImpl,
610+ skipLatestVersions : true ,
611+ } ) ;
612+
613+ expect ( marketplace . plugins [ 0 ] ?. version ) . toBeUndefined ( ) ;
614+ expect ( fetchImpl ) . not . toHaveBeenCalled ( ) ;
615+ } ) ;
616+
617+ it ( 'withMarketplaceLatestVersions fills versions from the latest release redirect' , async ( ) => {
618+ const fetchImpl = vi . fn ( async ( input : unknown ) => ( {
619+ ok : false ,
620+ status : 302 ,
621+ headers : new Headers ( {
622+ location : 'https://github.com/owner/repo/releases/tag/v1.2.3' ,
623+ } ) ,
624+ text : async ( ) => '' ,
625+ } ) ) as unknown as typeof fetch ;
626+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'pythinker-plugin-marketplace-' ) ) ;
627+ const file = await writeCatalog ( dir ) ;
628+ const marketplace = await loadPluginMarketplace ( {
629+ workDir : dir ,
630+ source : file ,
631+ skipLatestVersions : true ,
632+ } ) ;
633+
634+ const enriched = await withMarketplaceLatestVersions ( marketplace , fetchImpl ) ;
635+
636+ expect ( fetchImpl ) . toHaveBeenCalledWith (
637+ 'https://github.com/owner/repo/releases/latest' ,
638+ expect . objectContaining ( { redirect : 'manual' , signal : expect . any ( AbortSignal ) } ) ,
639+ ) ;
640+ expect ( enriched . plugins [ 0 ] ?. version ) . toBe ( '1.2.3' ) ;
641+ } ) ;
642+
643+ it ( 'withMarketplaceLatestVersions degrades to a missing version when the lookup aborts' , async ( ) => {
644+ const fetchImpl = vi . fn ( async ( _input : unknown , init ?: { signal ?: AbortSignal } ) => {
645+ // Simulate the lookup hitting the timeout: undici rejects with the
646+ // signal's reason once the AbortSignal fires.
647+ throw init ?. signal ?. aborted === true
648+ ? init . signal . reason
649+ : new DOMException ( 'This operation was aborted' , 'AbortError' ) ;
650+ } ) as unknown as typeof fetch ;
651+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'pythinker-plugin-marketplace-' ) ) ;
652+ const file = await writeCatalog ( dir ) ;
653+ const marketplace = await loadPluginMarketplace ( {
654+ workDir : dir ,
655+ source : file ,
656+ skipLatestVersions : true ,
657+ } ) ;
658+
659+ const enriched = await withMarketplaceLatestVersions ( marketplace , fetchImpl ) ;
660+
661+ expect ( enriched . plugins [ 0 ] ?. version ) . toBeUndefined ( ) ;
662+ expect ( enriched . plugins [ 0 ] ?. id ) . toBe ( 'demo' ) ;
663+ } ) ;
664+
665+ it ( 'carries a resolved catalog version onto a built-in row injected after enrichment' , async ( ) => {
666+ // Regression for the resolve-before-inject ordering: enriching the
667+ // built-in-masked marketplace cannot see the catalog entry's GitHub
668+ // source, so built-in rows would never get update badges.
669+ const fetchImpl = vi . fn ( async ( ) => ( {
670+ ok : false ,
671+ status : 302 ,
672+ headers : new Headers ( {
673+ location : 'https://github.com/owner/repo/releases/tag/v2.0.0' ,
674+ } ) ,
675+ text : async ( ) => '' ,
676+ } ) ) as unknown as typeof fetch ;
677+ const dir = await mkdtemp ( join ( tmpdir ( ) , 'pythinker-plugin-marketplace-' ) ) ;
678+ const file = join ( dir , 'marketplace.json' ) ;
679+ await writeFile (
680+ file ,
681+ JSON . stringify ( {
682+ plugins : [ { id : 'demo' , displayName : 'Demo' , source : 'https://github.com/owner/repo' } ] ,
683+ } ) ,
684+ 'utf8' ,
685+ ) ;
686+ const catalog = await loadPluginMarketplace ( {
687+ workDir : dir ,
688+ source : file ,
689+ skipLatestVersions : true ,
690+ } ) ;
691+ const builtIns : readonly PluginMarketplaceEntry [ ] = [
692+ { id : 'demo' , displayName : 'Demo Capability' , source : 'capability:demo' , builtIn : true } ,
693+ ] ;
694+
695+ const enriched = withBuiltInEntries (
696+ await withMarketplaceLatestVersions ( catalog , fetchImpl ) ,
697+ builtIns ,
698+ ) ;
699+
700+ expect ( enriched . plugins ) . toHaveLength ( 1 ) ;
701+ expect ( enriched . plugins [ 0 ] ) . toEqual (
702+ expect . objectContaining ( { id : 'demo' , builtIn : true , version : '2.0.0' } ) ,
703+ ) ;
704+ } ) ;
705+ } ) ;
706+
578707} ) ;
0 commit comments