1- import { describe , expect , test } from "bun:test" ;
1+ import { afterEach , describe , expect , test } from "bun:test" ;
2+ import { existsSync , mkdirSync , mkdtempSync , readFileSync , realpathSync , rmSync , writeFileSync } from "node:fs" ;
3+ import { tmpdir } from "node:os" ;
4+ import { join } from "node:path" ;
5+ import { invalidateCodexModelsCache , syncCatalogModels } from "../src/codex/catalog" ;
26import { refreshCodexModelCatalog } from "../src/codex/refresh" ;
37import type { OcxConfig } from "../src/types" ;
48
@@ -8,19 +12,73 @@ const config = {
812 providers : { } ,
913} as OcxConfig ;
1014
15+ const tempHomes : string [ ] = [ ] ;
16+
17+ function installTempHomes ( ) : { codexHome : string ; opencodexHome : string ; restore ( ) : void } {
18+ const previousCodexHome = process . env . CODEX_HOME ;
19+ const previousOpenCodexHome = process . env . OPENCODEX_HOME ;
20+ const codexHome = mkdtempSync ( join ( tmpdir ( ) , "ocx-refresh-codex-" ) ) ;
21+ const opencodexHome = mkdtempSync ( join ( tmpdir ( ) , "ocx-refresh-ocx-" ) ) ;
22+ tempHomes . push ( codexHome , opencodexHome ) ;
23+ process . env . CODEX_HOME = codexHome ;
24+ process . env . OPENCODEX_HOME = opencodexHome ;
25+
26+ return {
27+ codexHome,
28+ opencodexHome,
29+ restore ( ) {
30+ if ( previousCodexHome === undefined ) delete process . env . CODEX_HOME ;
31+ else process . env . CODEX_HOME = previousCodexHome ;
32+ if ( previousOpenCodexHome === undefined ) delete process . env . OPENCODEX_HOME ;
33+ else process . env . OPENCODEX_HOME = previousOpenCodexHome ;
34+ rmSync ( codexHome , { recursive : true , force : true } ) ;
35+ rmSync ( opencodexHome , { recursive : true , force : true } ) ;
36+ } ,
37+ } ;
38+ }
39+
40+ function nativeCatalogFixture ( slug = "gpt-5.5" ) : string {
41+ return JSON . stringify ( {
42+ models : [ {
43+ slug,
44+ display_name : slug ,
45+ description : "native" ,
46+ priority : 9 ,
47+ visibility : "list" ,
48+ base_instructions : "You are Codex, a coding agent based on GPT-5." ,
49+ supported_reasoning_levels : [ { effort : "medium" , description : "m" } ] ,
50+ } ] ,
51+ } , null , 2 ) + "\n" ;
52+ }
53+
54+ afterEach ( ( ) => {
55+ for ( const path of tempHomes . splice ( 0 ) ) {
56+ rmSync ( path , { recursive : true , force : true } ) ;
57+ }
58+ } ) ;
59+
1160describe ( "Codex catalog refresh" , ( ) => {
1261 test ( "writes an expired Codex models cache whenever the materialized catalog exists" , async ( ) => {
1362 let invalidated = 0 ;
1463 const result = await refreshCodexModelCatalog ( config , {
15- syncCatalogModels : async ( ) => ( { added : 0 , path : "/tmp/opencodex-catalog.json" , comboOmissions : [ ] } ) ,
16- invalidateCodexModelsCache : ( ) => { invalidated += 1 ; } ,
64+ syncCatalogModels : async ( ) => ( {
65+ added : 0 ,
66+ path : "/tmp/opencodex-catalog.json" ,
67+ catalogWritten : true ,
68+ comboOmissions : [ ] ,
69+ } ) ,
70+ invalidateCodexModelsCache : ( ) => {
71+ invalidated += 1 ;
72+ return true ;
73+ } ,
1774 existsSync : ( ) => true ,
1875 } ) ;
1976
2077 expect ( result ) . toEqual ( {
2178 added : 0 ,
2279 path : "/tmp/opencodex-catalog.json" ,
2380 catalogExists : true ,
81+ catalogWritten : true ,
2482 cacheSynced : true ,
2583 comboOmissions : [ ] ,
2684 } ) ;
@@ -30,14 +88,130 @@ describe("Codex catalog refresh", () => {
3088 test ( "does not touch the cache when no Codex catalog can be materialized" , async ( ) => {
3189 let invalidated = 0 ;
3290 const result = await refreshCodexModelCatalog ( config , {
33- syncCatalogModels : async ( ) => ( { added : 0 , path : "/tmp/missing-catalog.json" , comboOmissions : [ ] } ) ,
34- invalidateCodexModelsCache : ( ) => { invalidated += 1 ; } ,
91+ syncCatalogModels : async ( ) => ( {
92+ added : 0 ,
93+ path : "/tmp/missing-catalog.json" ,
94+ catalogWritten : false ,
95+ comboOmissions : [ ] ,
96+ } ) ,
97+ invalidateCodexModelsCache : ( ) => {
98+ invalidated += 1 ;
99+ return true ;
100+ } ,
35101 existsSync : ( ) => false ,
36102 } ) ;
37103
38104 expect ( result . catalogExists ) . toBe ( false ) ;
105+ expect ( result . catalogWritten ) . toBe ( false ) ;
39106 expect ( result . cacheSynced ) . toBe ( false ) ;
40107 expect ( result . comboOmissions ) . toEqual ( [ ] ) ;
41108 expect ( invalidated ) . toBe ( 0 ) ;
42109 } ) ;
110+
111+ test ( "reports cacheSynced false when invalidate cannot write" , async ( ) => {
112+ const result = await refreshCodexModelCatalog ( config , {
113+ syncCatalogModels : async ( ) => ( {
114+ added : 0 ,
115+ path : "/tmp/opencodex-catalog.json" ,
116+ catalogWritten : true ,
117+ comboOmissions : [ ] ,
118+ } ) ,
119+ invalidateCodexModelsCache : ( ) => false ,
120+ existsSync : ( ) => true ,
121+ } ) ;
122+
123+ expect ( result . catalogExists ) . toBe ( true ) ;
124+ expect ( result . catalogWritten ) . toBe ( true ) ;
125+ expect ( result . cacheSynced ) . toBe ( false ) ;
126+ expect ( result . comboOmissions ) . toEqual ( [ ] ) ;
127+ } ) ;
128+
129+ test ( "preserves catalogWritten false when the catalog path exists but sync did not write" , async ( ) => {
130+ const result = await refreshCodexModelCatalog ( config , {
131+ syncCatalogModels : async ( ) => ( {
132+ added : 0 ,
133+ path : "/tmp/broken-catalog.json" ,
134+ catalogWritten : false ,
135+ comboOmissions : [ ] ,
136+ } ) ,
137+ invalidateCodexModelsCache : ( ) => false ,
138+ existsSync : ( ) => true ,
139+ } ) ;
140+
141+ expect ( result . catalogExists ) . toBe ( true ) ;
142+ expect ( result . catalogWritten ) . toBe ( false ) ;
143+ expect ( result . cacheSynced ) . toBe ( false ) ;
144+ } ) ;
145+
146+ test ( "reports catalogWritten true after syncCatalogModels rewrites a real catalog file" , async ( ) => {
147+ const home = installTempHomes ( ) ;
148+ try {
149+ const catalogPath = join ( home . codexHome , "nested" , "catalog.json" ) ;
150+ mkdirSync ( join ( home . codexHome , "nested" ) , { recursive : true } ) ;
151+ writeFileSync ( join ( home . codexHome , "config.toml" ) , 'model_catalog_json = "nested/catalog.json"\n' , "utf8" ) ;
152+ writeFileSync ( catalogPath , nativeCatalogFixture ( "gpt-5.6-sol" ) , "utf8" ) ;
153+ const before = readFileSync ( catalogPath , "utf8" ) ;
154+
155+ const result = await syncCatalogModels ( config ) ;
156+ const after = readFileSync ( catalogPath , "utf8" ) ;
157+ const rewritten = JSON . parse ( after ) ;
158+
159+ expect ( result . path ) . toBe ( join ( realpathSync . native ( home . codexHome ) , "nested" , "catalog.json" ) ) ;
160+ expect ( result . catalogWritten ) . toBe ( true ) ;
161+ expect ( after ) . not . toBe ( before ) ;
162+ expect ( rewritten . models [ 0 ] . slug ) . toBe ( "gpt-5.6-sol" ) ;
163+ expect ( rewritten . models [ 0 ] . display_name ) . toBe ( "GPT-5.6-Sol" ) ;
164+ expect ( rewritten . models [ 0 ] . context_window ) . toBeGreaterThan ( 0 ) ;
165+ } finally {
166+ home . restore ( ) ;
167+ }
168+ } ) ;
169+
170+ test ( "invalidateCodexModelsCache reports real cache write success and failure cases" , ( ) => {
171+ const success = installTempHomes ( ) ;
172+ try {
173+ writeFileSync ( join ( success . codexHome , "config.toml" ) , 'model_catalog_json = "opencodex-catalog.json"\n' , "utf8" ) ;
174+ writeFileSync ( join ( success . codexHome , "opencodex-catalog.json" ) , nativeCatalogFixture ( "gpt-5.6-sol" ) , "utf8" ) ;
175+
176+ expect ( invalidateCodexModelsCache ( ) ) . toBe ( true ) ;
177+ const cache = JSON . parse ( readFileSync ( join ( success . codexHome , "models_cache.json" ) , "utf8" ) ) ;
178+ expect ( cache . fetched_at ) . toBe ( "2000-01-01T00:00:00Z" ) ;
179+ expect ( cache . client_version ) . toBe ( "0.0.0" ) ;
180+ expect ( cache . models [ 0 ] . slug ) . toBe ( "gpt-5.6-sol" ) ;
181+ } finally {
182+ success . restore ( ) ;
183+ }
184+
185+ const missingCatalog = installTempHomes ( ) ;
186+ try {
187+ writeFileSync ( join ( missingCatalog . codexHome , "config.toml" ) , 'model_catalog_json = "missing-catalog.json"\n' , "utf8" ) ;
188+
189+ expect ( invalidateCodexModelsCache ( ) ) . toBe ( false ) ;
190+ expect ( existsSync ( join ( missingCatalog . codexHome , "models_cache.json" ) ) ) . toBe ( false ) ;
191+ } finally {
192+ missingCatalog . restore ( ) ;
193+ }
194+
195+ const malformedCatalog = installTempHomes ( ) ;
196+ try {
197+ writeFileSync ( join ( malformedCatalog . codexHome , "config.toml" ) , 'model_catalog_json = "opencodex-catalog.json"\n' , "utf8" ) ;
198+ writeFileSync ( join ( malformedCatalog . codexHome , "opencodex-catalog.json" ) , "{not-json" , "utf8" ) ;
199+
200+ expect ( invalidateCodexModelsCache ( ) ) . toBe ( false ) ;
201+ expect ( existsSync ( join ( malformedCatalog . codexHome , "models_cache.json" ) ) ) . toBe ( false ) ;
202+ } finally {
203+ malformedCatalog . restore ( ) ;
204+ }
205+
206+ const unwritableCache = installTempHomes ( ) ;
207+ try {
208+ writeFileSync ( join ( unwritableCache . codexHome , "config.toml" ) , 'model_catalog_json = "opencodex-catalog.json"\n' , "utf8" ) ;
209+ writeFileSync ( join ( unwritableCache . codexHome , "opencodex-catalog.json" ) , nativeCatalogFixture ( ) , "utf8" ) ;
210+ mkdirSync ( join ( unwritableCache . codexHome , "models_cache.json" ) ) ;
211+
212+ expect ( invalidateCodexModelsCache ( ) ) . toBe ( false ) ;
213+ } finally {
214+ unwritableCache . restore ( ) ;
215+ }
216+ } ) ;
43217} ) ;
0 commit comments