@@ -5,141 +5,175 @@ jest.mock('node:child_process', () => ({
55 spawnSync : jest . fn ( ) ,
66} ) ) ;
77
8+ jest . mock ( 'node:fs' , ( ) => ( {
9+ existsSync : jest . fn ( ) ,
10+ readFileSync : jest . fn ( ) ,
11+ rmSync : jest . fn ( ) ,
12+ } ) ) ;
13+
814jest . mock ( '../../../../utils/analytics' , ( ) => ( {
9- analytics : {
10- captureException : jest . fn ( ) ,
11- } ,
15+ analytics : { captureException : jest . fn ( ) } ,
1216} ) ) ;
1317
1418describe ( 'CodexMCPClient' , ( ) => {
1519 const { execSync, spawnSync } = require ( 'node:child_process' ) ;
20+ const fs = require ( 'node:fs' ) ;
1621 const analytics = require ( '../../../../utils/analytics' ) . analytics ;
1722
1823 const spawnSyncMock = spawnSync as jest . Mock ;
1924 const execSyncMock = execSync as jest . Mock ;
25+ const readFileSyncMock = fs . readFileSync as jest . Mock ;
26+
27+ const CODEX_PATH = '/usr/local/bin/codex' ;
2028
2129 beforeEach ( ( ) => {
2230 jest . clearAllMocks ( ) ;
31+ // Default: codex found via command -v
32+ execSyncMock . mockReturnValue ( Buffer . from ( CODEX_PATH + '\n' ) ) ;
2333 } ) ;
2434
2535 describe ( 'isClientSupported' , ( ) => {
26- it ( 'returns true when codex binary is available' , async ( ) => {
27- execSyncMock . mockReturnValue ( undefined ) ;
28-
36+ it ( 'returns true when codex is in PATH' , async ( ) => {
2937 const client = new CodexMCPClient ( ) ;
3038 await expect ( client . isClientSupported ( ) ) . resolves . toBe ( true ) ;
31- expect ( execSyncMock ) . toHaveBeenCalledWith ( 'codex --version ' , {
32- stdio : 'ignore ' ,
39+ expect ( execSyncMock ) . toHaveBeenCalledWith ( 'command -v codex ' , {
40+ stdio : 'pipe ' ,
3341 } ) ;
3442 } ) ;
3543
36- it ( 'returns false when codex binary is missing ' , async ( ) => {
44+ it ( 'returns false when codex is not in PATH ' , async ( ) => {
3745 execSyncMock . mockImplementation ( ( ) => {
3846 throw new Error ( 'not found' ) ;
3947 } ) ;
40-
4148 const client = new CodexMCPClient ( ) ;
4249 await expect ( client . isClientSupported ( ) ) . resolves . toBe ( false ) ;
4350 } ) ;
4451 } ) ;
4552
46- describe ( 'isServerInstalled' , ( ) => {
47- it ( 'returns true when posthog server exists' , async ( ) => {
48- spawnSyncMock . mockReturnValue ( {
49- status : 0 ,
50- stdout : JSON . stringify ( [ { name : 'posthog' } , { name : 'other' } ] ) ,
51- } ) ;
53+ describe ( 'isPluginInstalled' , ( ) => {
54+ it ( 'returns true when posthog marketplace section exists in config.toml' , async ( ) => {
55+ readFileSyncMock . mockReturnValue (
56+ '[marketplaces.posthog]\nsource_type = "git"\n' ,
57+ ) ;
58+ const client = new CodexMCPClient ( ) ;
59+ await expect ( client . isPluginInstalled ( ) ) . resolves . toBe ( true ) ;
60+ } ) ;
5261
62+ it ( 'returns false when posthog is absent from config.toml' , async ( ) => {
63+ readFileSyncMock . mockReturnValue (
64+ '[marketplaces.openai-bundled]\nsource_type = "local"\n' ,
65+ ) ;
5366 const client = new CodexMCPClient ( ) ;
54- await expect ( client . isServerInstalled ( ) ) . resolves . toBe ( true ) ;
67+ await expect ( client . isPluginInstalled ( ) ) . resolves . toBe ( false ) ;
5568 } ) ;
5669
57- it ( 'returns false when command fails' , async ( ) => {
58- spawnSyncMock . mockReturnValue ( { status : 1 , stdout : '' } ) ;
70+ it ( 'returns false when config.toml cannot be read' , async ( ) => {
71+ readFileSyncMock . mockImplementation ( ( ) => {
72+ throw new Error ( 'ENOENT' ) ;
73+ } ) ;
74+ const client = new CodexMCPClient ( ) ;
75+ await expect ( client . isPluginInstalled ( ) ) . resolves . toBe ( false ) ;
76+ } ) ;
77+ } ) ;
5978
79+ describe ( 'isServerInstalled' , ( ) => {
80+ it ( 'delegates to isPluginInstalled' , async ( ) => {
81+ readFileSyncMock . mockReturnValue (
82+ '[marketplaces.posthog]\nsource_type = "git"\n' ,
83+ ) ;
6084 const client = new CodexMCPClient ( ) ;
61- await expect ( client . isServerInstalled ( ) ) . resolves . toBe ( false ) ;
85+ await expect ( client . isServerInstalled ( ) ) . resolves . toBe ( true ) ;
6286 } ) ;
6387 } ) ;
6488
6589 describe ( 'addServer' , ( ) => {
66- it ( 'invokes codex mcp add with --url and --bearer-token-env-var' , async ( ) => {
67- spawnSyncMock . mockReturnValue ( { status : 0 } ) ;
68-
90+ it ( 'delegates to installPlugin — returns success when plugin installs' , async ( ) => {
91+ spawnSyncMock . mockReturnValue ( { status : 0 , stderr : '' } ) ;
6992 const client = new CodexMCPClient ( ) ;
70- const result = await client . addServer ( 'phx_example' ) ;
93+ await expect ( client . addServer ( ) ) . resolves . toEqual ( { success : true } ) ;
94+ } ) ;
7195
72- expect ( result ) . toEqual ( { success : true } ) ;
73- expect ( spawnSyncMock ) . toHaveBeenCalledWith (
74- 'codex' ,
75- [
76- 'mcp' ,
77- 'add' ,
78- 'posthog' ,
79- '--url' ,
80- 'https://mcp.posthog.com/mcp' ,
81- '--bearer-token-env-var' ,
82- 'POSTHOG_API_KEY' ,
83- ] ,
84- expect . objectContaining ( {
85- stdio : 'ignore' ,
86- env : expect . objectContaining ( {
87- POSTHOG_API_KEY : 'phx_example' ,
88- } ) ,
89- } ) ,
90- ) ;
96+ it ( 'delegates to installPlugin — returns failure when plugin fails' , async ( ) => {
97+ spawnSyncMock . mockReturnValue ( { status : 1 , stderr : 'network timeout' } ) ;
98+ const client = new CodexMCPClient ( ) ;
99+ await expect ( client . addServer ( ) ) . resolves . toEqual ( { success : false } ) ;
91100 } ) ;
101+ } ) ;
92102
93- it ( 'omits auth in OAuth mode' , async ( ) => {
103+ describe ( 'removeServer' , ( ) => {
104+ it ( 'invokes the resolved binary with mcp remove and returns success' , async ( ) => {
94105 spawnSyncMock . mockReturnValue ( { status : 0 } ) ;
95-
96106 const client = new CodexMCPClient ( ) ;
97- const result = await client . addServer ( undefined ) ;
98-
99- expect ( result ) . toEqual ( { success : true } ) ;
107+ await expect ( client . removeServer ( ) ) . resolves . toEqual ( { success : true } ) ;
100108 expect ( spawnSyncMock ) . toHaveBeenCalledWith (
101- 'codex' ,
102- [ 'mcp' , 'add ' , 'posthog' , '--url' , 'https://mcp.posthog.com/mcp '] ,
103- expect . objectContaining ( { stdio : 'ignore' } ) ,
109+ CODEX_PATH ,
110+ [ 'mcp' , 'remove ' , 'posthog' ] ,
111+ { stdio : 'ignore' } ,
104112 ) ;
105113 } ) ;
106114
107115 it ( 'returns false and captures exception on failure' , async ( ) => {
108116 spawnSyncMock . mockReturnValue ( { status : 1 } ) ;
109-
110117 const client = new CodexMCPClient ( ) ;
111- const result = await client . addServer ( 'phx_example' ) ;
112-
113- expect ( result ) . toEqual ( { success : false } ) ;
118+ await expect ( client . removeServer ( ) ) . resolves . toEqual ( { success : false } ) ;
114119 expect ( analytics . captureException ) . toHaveBeenCalled ( ) ;
115120 } ) ;
116121 } ) ;
117122
118- describe ( 'removeServer' , ( ) => {
119- it ( 'invokes codex mcp remove and returns success' , async ( ) => {
120- spawnSyncMock . mockReturnValue ( { status : 0 } ) ;
123+ describe ( 'supportsPlugin' , ( ) => {
124+ it ( 'returns true when codex is in PATH' , ( ) => {
125+ const client = new CodexMCPClient ( ) ;
126+ expect ( client . supportsPlugin ( ) ) . toBe ( true ) ;
127+ } ) ;
121128
129+ it ( 'returns false when codex binary is not found' , ( ) => {
130+ execSyncMock . mockImplementation ( ( ) => {
131+ throw new Error ( 'not found' ) ;
132+ } ) ;
122133 const client = new CodexMCPClient ( ) ;
123- const result = await client . removeServer ( ) ;
134+ expect ( client . supportsPlugin ( ) ) . toBe ( false ) ;
135+ } ) ;
136+ } ) ;
124137
125- expect ( result ) . toEqual ( { success : true } ) ;
138+ describe ( 'installPlugin' , ( ) => {
139+ it ( 'returns success on exit 0 using resolved binary path' , async ( ) => {
140+ spawnSyncMock . mockReturnValue ( { status : 0 , stderr : '' } ) ;
141+ const client = new CodexMCPClient ( ) ;
142+ await expect ( client . installPlugin ( ) ) . resolves . toEqual ( { success : true } ) ;
126143 expect ( spawnSyncMock ) . toHaveBeenCalledWith (
127- 'codex' ,
128- [ 'mcp' , 'remove' , 'posthog' ] ,
129- {
130- stdio : 'ignore' ,
131- } ,
144+ CODEX_PATH ,
145+ [ 'plugin' , 'marketplace' , 'add' , 'PostHog/ai-plugin' ] ,
146+ { encoding : 'utf-8' } ,
132147 ) ;
133148 } ) ;
134149
135- it ( 'returns false and captures exception on failure' , async ( ) => {
136- spawnSyncMock . mockReturnValue ( { status : 1 } ) ;
137-
150+ it ( 'clears stale cache and retries when marketplace is already added from a different source' , async ( ) => {
151+ const { rmSync } = require ( 'node:fs' ) ;
152+ spawnSyncMock
153+ . mockReturnValueOnce ( {
154+ status : 1 ,
155+ stderr :
156+ "Error: marketplace 'posthog' is already added from a different source" ,
157+ } )
158+ . mockReturnValueOnce ( { status : 0 , stderr : '' } ) ;
138159 const client = new CodexMCPClient ( ) ;
139- const result = await client . removeServer ( ) ;
160+ await expect ( client . installPlugin ( ) ) . resolves . toEqual ( { success : true } ) ;
161+ expect ( rmSync ) . toHaveBeenCalledWith (
162+ expect . stringContaining ( 'marketplaces/posthog' ) ,
163+ { recursive : true , force : true } ,
164+ ) ;
165+ expect ( spawnSyncMock ) . toHaveBeenCalledTimes ( 2 ) ;
166+ } ) ;
140167
141- expect ( result ) . toEqual ( { success : false } ) ;
142- expect ( analytics . captureException ) . toHaveBeenCalled ( ) ;
168+ it ( 'returns failure and captures exception on unexpected error' , async ( ) => {
169+ spawnSyncMock . mockReturnValue ( { status : 1 , stderr : 'network timeout' } ) ;
170+ const client = new CodexMCPClient ( ) ;
171+ await expect ( client . installPlugin ( ) ) . resolves . toEqual ( { success : false } ) ;
172+ expect ( analytics . captureException ) . toHaveBeenCalledWith (
173+ expect . objectContaining ( {
174+ message : expect . stringContaining ( 'network timeout' ) ,
175+ } ) ,
176+ ) ;
143177 } ) ;
144178 } ) ;
145179} ) ;
0 commit comments