From 9a4cb1f8e1fde4a8c3850bb3109a5b453be0da8f Mon Sep 17 00:00:00 2001 From: Timothy Lowrimore Date: Thu, 21 May 2026 14:50:53 -0600 Subject: [PATCH 1/2] refactor: use @heroku/sdk for apps:index command --- src/commands/apps/index.ts | 41 +++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/src/commands/apps/index.ts b/src/commands/apps/index.ts index 4e2a742572..cb8b00f841 100644 --- a/src/commands/apps/index.ts +++ b/src/commands/apps/index.ts @@ -1,7 +1,7 @@ import {Command, flags} from '@heroku-cli/command' import {SpaceCompletion} from '@heroku-cli/command/lib/completions.js' -import * as Heroku from '@heroku-cli/schema' import {color, hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' import {ux} from '@oclif/core/ux' import {lazyModuleLoader} from '../../lib/lazy-module-loader.js' @@ -37,20 +37,25 @@ export default class AppsIndex extends Command { let team = (!flags.personal && teamIdentifier) ? teamIdentifier : null const {all, json, space} = flags const internalRouting = flags['internal-routing'] + + const sdk = new HerokuSDK() + const {platform} = sdk + if (space) { - const teamResponse = await this.heroku.get(`/spaces/${space}`) - team = teamResponse.body.team.name + const spaceInfo = await platform.space.info(space) + team = spaceInfo.team?.name ?? null } - let path = '/users/~/apps' - if (team) path = `/teams/${team}/apps` - else if (all) path = '/apps' - const [appsResponse, userResponse] = await Promise.all([ - this.heroku.get(path), - this.heroku.get('/account'), + const [appsList, user] = await Promise.all([ + (async () => { + if (team) return platform.teamApp.listByTeam(team) + if (all) return platform.app.list() + return platform.app.listOwnedAndCollaborated('~') + })(), + platform.account.info(), ]) - let apps = appsResponse.body - const user = userResponse.body + + let apps = appsList as unknown as App[] apps = _.sortBy(apps, 'name') if (space) { @@ -82,11 +87,11 @@ function annotateAppName(app: App) { return name } -function listApps(apps: Heroku.App) { +function listApps(apps: App[]) { apps.forEach((app: App) => ux.stdout(regionizeAppName(app))) } -function print(apps: Heroku.App, user: Heroku.Account, space: string | undefined, team: null | string | undefined, _: any) { +function print(apps: App[], user: {email?: string}, space: string | undefined, team: null | string | undefined, _: any) { if (apps.length === 0) { if (space) ux.stdout(`There are no apps in space ${color.space(space)}.`) else if (team) ux.stdout(`There are no apps in team ${color.team(team)}.`) @@ -98,10 +103,10 @@ function print(apps: Heroku.App, user: Heroku.Account, space: string | undefined hux.styledHeader(`Apps in team ${color.team(team)}`) listApps(apps) } else { - apps = _.partition(apps, (app: App) => app.owner.email === user.email) - if (apps[0].length > 0) { + const [ownedApps, collabApps] = _.partition(apps, (app: App) => app.owner.email === user.email) + if (ownedApps.length > 0) { hux.styledHeader(`${color.user(user.email!)} Apps`) - listApps(apps[0]) + listApps(ownedApps) } const columns = { @@ -110,9 +115,9 @@ function print(apps: Heroku.App, user: Heroku.Account, space: string | undefined Email: {get: ({owner}: any) => color.user(owner.email)}, } - if (apps[1].length > 0) { + if (collabApps.length > 0) { ux.stdout() - hux.table(apps[1], columns, {title: 'Collaborated Apps\n', titleOptions: {bold: true}}) + hux.table(collabApps, columns, {title: 'Collaborated Apps\n', titleOptions: {bold: true}}) } } } From aa67757761d447c1210b54250508a17acc55dfab Mon Sep 17 00:00:00 2001 From: Timothy Lowrimore Date: Thu, 21 May 2026 15:04:41 -0600 Subject: [PATCH 2/2] test(apps): stub @heroku/sdk directly, drop nock --- test/unit/commands/apps/index.unit.test.ts | 172 +++++++++------------ 1 file changed, 72 insertions(+), 100 deletions(-) diff --git a/test/unit/commands/apps/index.unit.test.ts b/test/unit/commands/apps/index.unit.test.ts index 0735ae130c..49a9a5932b 100644 --- a/test/unit/commands/apps/index.unit.test.ts +++ b/test/unit/commands/apps/index.unit.test.ts @@ -1,10 +1,33 @@ import {runCommand} from '@heroku-cli/test-utils' +import {HerokuSDK} from '@heroku/sdk' import {expect} from 'chai' -import nock from 'nock' +import * as sinon from 'sinon' import Apps from '../../../../src/commands/apps/index.js' import removeAllWhitespace from '../../../helpers/utils/remove-whitespaces.js' +type FakePlatform = { + account: {info: sinon.SinonStub} + app: { + list: sinon.SinonStub + listOwnedAndCollaborated: sinon.SinonStub + } + space: {info: sinon.SinonStub} + teamApp: {listByTeam: sinon.SinonStub} +} + +function buildFakePlatform(): FakePlatform { + return { + account: {info: sinon.stub()}, + app: { + list: sinon.stub(), + listOwnedAndCollaborated: sinon.stub(), + }, + space: {info: sinon.stub()}, + teamApp: {listByTeam: sinon.stub()}, + } +} + describe('apps', function () { const example = { name: 'example', @@ -76,40 +99,32 @@ describe('apps', function () { space: {id: 'test-space-id', name: 'test-space'}, } - let euLockedApp = {} - let euInternalApp = {} - let euInternalLockedApp = {} - let api: nock.Scope + let fakePlatform: FakePlatform beforeEach(function () { - api = nock('https://api.heroku.com') + fakePlatform = buildFakePlatform() + sinon.stub(HerokuSDK.prototype, 'platform').get(() => fakePlatform) }) afterEach(function () { - api.done() - nock.cleanAll() + sinon.restore() }) describe('with no args', function () { it('displays a message when the user has no apps', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, []) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([]) const {stderr, stdout} = await runCommand(Apps, []) expect(stderr).to.equal('') expect(stdout).to.equal('You have no apps.\n') + expect(fakePlatform.app.listOwnedAndCollaborated.calledOnceWithExactly('~')).to.equal(true) }) it('list all user apps', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, collabApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, collabApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -126,11 +141,8 @@ describe('apps', function () { }) it('lists all apps', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/apps') - .reply(200, [example, collabApp, teamApp1]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.list.resolves([example, collabApp, teamApp1]) const {stderr, stdout} = await runCommand(Apps, ['--all']) @@ -144,14 +156,13 @@ describe('apps', function () { expect(actual).to.include(expectedPersonalApps) expect(actual).to.include(expectedCollaboratedAppsHeader) expect(actual).to.include(expectedCollaboratedApps) + expect(fakePlatform.app.list.calledOnce).to.equal(true) + expect(fakePlatform.app.listOwnedAndCollaborated.called).to.equal(false) }) it('shows as json', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, collabApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, collabApp]) const {stderr, stdout} = await runCommand(Apps, ['--json']) @@ -160,11 +171,8 @@ describe('apps', function () { }) it('shows region if not us', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -173,11 +181,8 @@ describe('apps', function () { }) it('shows locked app', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp, lockedApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp, lockedApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -186,13 +191,9 @@ describe('apps', function () { }) it('shows locked eu app', async function () { - euLockedApp = Object.assign(lockedApp, {region: {name: 'eu'}}) - - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp, euLockedApp]) + const euLockedApp = {...lockedApp, region: {name: 'eu'}} + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp, euLockedApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -201,11 +202,8 @@ describe('apps', function () { }) it('shows internal app', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp, internalApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp, internalApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -214,11 +212,8 @@ describe('apps', function () { }) it('shows internal locked app', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp, internalLockedApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp, internalLockedApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -227,13 +222,9 @@ describe('apps', function () { }) it('shows internal eu app', async function () { - euInternalApp = Object.assign(internalApp, {region: {name: 'eu'}}) - - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp, euInternalApp]) + const euInternalApp = {...internalApp, region: {name: 'eu'}} + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp, euInternalApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -242,13 +233,9 @@ describe('apps', function () { }) it('shows internal locked eu app', async function () { - euInternalLockedApp = Object.assign(internalLockedApp, {region: {name: 'eu'}}) - - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/users/~/apps') - .reply(200, [example, euApp, euInternalLockedApp]) + const euInternalLockedApp = {...internalLockedApp, region: {name: 'eu'}} + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.app.listOwnedAndCollaborated.resolves([example, euApp, euInternalLockedApp]) const {stderr, stdout} = await runCommand(Apps, []) @@ -259,24 +246,19 @@ describe('apps', function () { describe('with team', function () { it('displays a message when the team has no apps', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/teams/test-team/apps') - .reply(200, []) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.teamApp.listByTeam.resolves([]) const {stderr, stdout} = await runCommand(Apps, ['--team', 'test-team']) expect(stderr).to.equal('') expect(stdout).to.equal('There are no apps in team test-team.\n') + expect(fakePlatform.teamApp.listByTeam.calledOnceWithExactly('test-team')).to.equal(true) }) it('list all in a team', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/teams/test-team/apps') - .reply(200, [teamApp1, teamApp2]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.teamApp.listByTeam.resolves([teamApp1, teamApp2]) const {stderr, stdout} = await runCommand(Apps, ['--team', 'test-team']) @@ -287,28 +269,22 @@ describe('apps', function () { describe('with space', function () { it('displays a message when the space has no apps', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/spaces/test-space') - .reply(200, {team: {name: 'test-team'}}) - .get('/teams/test-team/apps') - .reply(200, []) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.space.info.resolves({team: {name: 'test-team'}}) + fakePlatform.teamApp.listByTeam.resolves([]) const {stderr, stdout} = await runCommand(Apps, ['--space', 'test-space']) expect(stderr).to.equal('') expect(stdout).to.equal('There are no apps in space ⬡ test-space.\n') + expect(fakePlatform.space.info.calledOnceWithExactly('test-space')).to.equal(true) + expect(fakePlatform.teamApp.listByTeam.calledOnceWithExactly('test-team')).to.equal(true) }) it('lists only apps in spaces by name', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/spaces/test-space') - .reply(200, {team: {name: 'test-team'}}) - .get('/teams/test-team/apps') - .reply(200, [teamSpaceApp1, teamSpaceApp2, teamApp1]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.space.info.resolves({team: {name: 'test-team'}}) + fakePlatform.teamApp.listByTeam.resolves([teamSpaceApp1, teamSpaceApp2, teamApp1]) const {stderr, stdout} = await runCommand(Apps, ['--space', 'test-space']) @@ -317,13 +293,9 @@ describe('apps', function () { }) it('lists only internal apps in spaces by name', async function () { - api - .get('/account') - .reply(200, {email: 'foo@bar.com'}) - .get('/spaces/test-space') - .reply(200, {team: {name: 'test-team'}}) - .get('/teams/test-team/apps') - .reply(200, [teamSpaceApp1, teamSpaceApp2, teamApp1, teamSpaceInternalApp]) + fakePlatform.account.info.resolves({email: 'foo@bar.com'}) + fakePlatform.space.info.resolves({team: {name: 'test-team'}}) + fakePlatform.teamApp.listByTeam.resolves([teamSpaceApp1, teamSpaceApp2, teamApp1, teamSpaceInternalApp]) const {stderr, stdout} = await runCommand(Apps, ['--space', 'test-space', '--internal-routing'])