@@ -3,9 +3,14 @@ import {APIClient} from '@heroku-cli/command'
33import { Config } from '@oclif/core'
44import { CLIError } from '@oclif/core/lib/errors'
55import { expect } from 'chai'
6+ import { EventEmitter } from 'node:events'
67import * as nock from 'nock'
8+ import * as proxyquire from 'proxyquire'
9+ import * as sinon from 'sinon'
10+ import { stdout } from 'stdout-stderr'
11+ import heredoc from 'tsheredoc'
712import logDisplayer from '../../../../src/lib/run/log-displayer'
8- import { cedarApp } from '../../../fixtures/apps/fixtures'
13+ import { cedarApp , firApp } from '../../../fixtures/apps/fixtures'
914
1015describe ( 'logDisplayer' , function ( ) {
1116 let api : nock . Scope
@@ -140,4 +145,149 @@ describe('logDisplayer', function () {
140145 } )
141146 } )
142147 } )
148+
149+ context ( 'with a Fir app' , function ( ) {
150+ let api : nock . Scope
151+ let mockEventSourceInstance : EventEmitter
152+ let logDisplayerWithMock : typeof logDisplayer
153+
154+ // Mock EventSource class that extends EventEmitter
155+ class MockEventSource extends EventEmitter {
156+ url : string
157+ close : ( ) => void
158+ static lastInstance : EventEmitter | null = null
159+
160+ constructor ( url : string , _options ?: { proxy ?: string ; headers ?: Record < string , string > } ) {
161+ super ( )
162+ this . url = url
163+ this . close = ( ) => {
164+ this . removeAllListeners ( )
165+ }
166+
167+ // Store instance for test control
168+ MockEventSource . lastInstance = this
169+ mockEventSourceInstance = MockEventSource . lastInstance
170+ }
171+
172+ // EventSource uses addEventListener, not just on()
173+ addEventListener ( event : string , handler : ( e : any ) => void ) {
174+ this . on ( event , handler )
175+ }
176+ }
177+
178+ beforeEach ( function ( ) {
179+ nock . cleanAll ( )
180+ api = nock ( 'https://api.heroku.com' , {
181+ reqheaders : { Accept : 'application/vnd.heroku+json; version=3.sdk' } ,
182+ } ) . get ( '/apps/my-fir-app' )
183+ . reply ( 200 , firApp )
184+
185+ sinon . stub ( heroku , 'post' ) . resolves ( {
186+ body : { logplex_url : 'https://logs.heroku.com/stream?tail=true&token=s3kr3t' } ,
187+ } as any )
188+
189+ logDisplayerWithMock = proxyquire ( '../../../../src/lib/run/log-displayer' , {
190+ '@heroku/eventsource' : MockEventSource ,
191+ } ) . default
192+ } )
193+
194+ afterEach ( function ( ) {
195+ api . done ( )
196+ sinon . restore ( )
197+ mockEventSourceInstance = null as any
198+ } )
199+
200+ context ( 'when the log server returns a 403 error before connection' , function ( ) {
201+ it ( 'shows the IP address access error and exits' , async function ( ) {
202+ const promise = logDisplayerWithMock ( heroku , {
203+ app : 'my-fir-app' ,
204+ tail : true ,
205+ } )
206+
207+ const waitForInstance = ( ) => {
208+ if ( mockEventSourceInstance ) {
209+ mockEventSourceInstance . emit ( 'error' , { status : 403 , message : null } )
210+ } else {
211+ setImmediate ( waitForInstance )
212+ }
213+ }
214+
215+ setImmediate ( waitForInstance )
216+
217+ try {
218+ await promise
219+ } catch ( error : unknown ) {
220+ const { message, oclif} = error as CLIError
221+ expect ( message ) . to . equal ( "You can't access this space from your IP address. Contact your team admin." )
222+ expect ( oclif . exit ) . to . eq ( 1 )
223+ }
224+ } )
225+ } )
226+
227+ context ( 'when the log server returns a 403 error after connection' , function ( ) {
228+ it ( 'shows the stream access expired error and exits' , async function ( ) {
229+ stdout . start ( )
230+
231+ const promise = logDisplayerWithMock ( heroku , {
232+ app : 'my-fir-app' ,
233+ tail : true ,
234+ } )
235+
236+ const waitAndEmit = ( ) => {
237+ if ( mockEventSourceInstance ) {
238+ mockEventSourceInstance . emit ( 'message' , { data : '2024-10-17T22:23:22.209776+00:00 app[web.1]: log line 1' } )
239+ mockEventSourceInstance . emit ( 'message' , { data : '2024-10-17T22:23:23.032789+00:00 app[web.1]: log line 2' } )
240+ setImmediate ( ( ) => {
241+ mockEventSourceInstance . emit ( 'error' , { status : 403 , message : null } )
242+ } )
243+ } else {
244+ setImmediate ( waitAndEmit )
245+ }
246+ }
247+
248+ setImmediate ( waitAndEmit )
249+
250+ try {
251+ await promise
252+ } catch ( error : unknown ) {
253+ stdout . stop ( )
254+ const { message, oclif} = error as CLIError
255+ expect ( message ) . to . equal ( 'Log stream access expired. Please try again.' )
256+ expect ( oclif . exit ) . to . eq ( 1 )
257+ }
258+
259+ expect ( stdout . output ) . to . eq ( heredoc `
260+ 2024-10-17T22:23:22.209776+00:00 app[web.1]: log line 1
261+ 2024-10-17T22:23:23.032789+00:00 app[web.1]: log line 2
262+ ` )
263+ } )
264+ } )
265+
266+ context ( 'when the log server returns a 404 error' , function ( ) {
267+ it ( 'shows the stream access expired error and exits' , async function ( ) {
268+ const promise = logDisplayerWithMock ( heroku , {
269+ app : 'my-fir-app' ,
270+ tail : true ,
271+ } )
272+
273+ const waitForInstance = ( ) => {
274+ if ( mockEventSourceInstance ) {
275+ mockEventSourceInstance . emit ( 'error' , { status : 404 , message : null } )
276+ } else {
277+ setImmediate ( waitForInstance )
278+ }
279+ }
280+
281+ setImmediate ( waitForInstance )
282+
283+ try {
284+ await promise
285+ } catch ( error : unknown ) {
286+ const { message, oclif} = error as CLIError
287+ expect ( message ) . to . equal ( 'Log stream access expired. Please try again.' )
288+ expect ( oclif . exit ) . to . eq ( 1 )
289+ }
290+ } )
291+ } )
292+ } )
143293} )
0 commit comments