diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..189603835a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +See [AGENTS.md](../AGENTS.md). diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..fded5dcf77 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md). diff --git a/package-lock.json b/package-lock.json index 5da6e04558..5af4c64786 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,9 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#main", + "@heroku/sdk": "file:../heroku-sdk", "@heroku/socksv5": "^0.0.9", + "@heroku/types": "github:heroku/heroku-types#main", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", "@oclif/plugin-commands": "^4.1.40", @@ -148,6 +149,31 @@ "node": ">=20" } }, + "../heroku-sdk": { + "name": "@heroku/sdk", + "version": "0.4.3", + "license": "Apache-2.0", + "dependencies": { + "@heroku/heroku-fetch": "github:heroku/heroku-fetch#main", + "@heroku/types": "github:heroku/heroku-types", + "debug": "^4.4.0" + }, + "devDependencies": { + "@heroku-cli/test-utils": "^0.3.0", + "@types/debug": "^4.1.12", + "@types/node": "^25.6.2", + "@vitest/coverage-v8": "^4.1.5", + "eslint": "^9.39.4", + "eslint-config-oclif": "^6.0.164", + "eslint-plugin-vitest": "0.5.4", + "tsx": "^4.21.0", + "typescript": "^6.0.2", + "vitest": "^4.1.4" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@actions/core": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", @@ -3017,24 +3043,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@heroku/heroku-fetch": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/heroku/heroku-fetch.git#bf6be077a9186c19d1327dad8c3709770324b2a2", - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.3.4", - "ky": "^2.0.2", - "undici": "^6.25.0" - }, - "engines": { - "node": ">=22" - }, - "optionalDependencies": { - "@heroku/heroku-cli-util": "^10.8.0", - "netrc-parser": "^3.1.6", - "open": "^10.0.3" - } - }, "node_modules/@heroku/http-call": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@heroku/http-call/-/http-call-5.5.1.tgz", @@ -3111,17 +3119,8 @@ } }, "node_modules/@heroku/sdk": { - "version": "0.4.3", - "resolved": "git+ssh://git@github.com/heroku/heroku-sdk.git#4a13a9fe2ee7e2a1a6d94b548926366bd95bd6a3", - "license": "Apache-2.0", - "dependencies": { - "@heroku/heroku-fetch": "github:heroku/heroku-fetch#main", - "@heroku/types": "github:heroku/heroku-types", - "debug": "^4.4.0" - }, - "engines": { - "node": ">=20" - } + "resolved": "../heroku-sdk", + "link": true }, "node_modules/@heroku/socksv5": { "version": "0.0.9", @@ -17241,18 +17240,6 @@ "node": ">=0.10.0" } }, - "node_modules/ky": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ky/-/ky-2.0.2.tgz", - "integrity": "sha512-/GmXpo9F9W+f8n4Ivr2iH+7h7wL7jLbLKWkMlpflcCRb6kGjBfTlASEXaZ9qUgNTn4VgS0P2pwxxzQ4EM6Ulgg==", - "license": "MIT", - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -24097,6 +24084,7 @@ "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 9756429ced..82e6d7713d 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "@heroku/heroku-cli-util": "^10.8.0", "@heroku/http-call": "^5.5.1", "@heroku/mcp-server": "^1.2.0", - "@heroku/sdk": "github:heroku/heroku-sdk#main", + "@heroku/sdk": "file:../heroku-sdk", "@heroku/socksv5": "^0.0.9", + "@heroku/types": "github:heroku/heroku-types#main", "@inquirer/prompts": "^7.0", "@oclif/core": "^4.8.4", "@oclif/plugin-commands": "^4.1.40", diff --git a/src/commands/addons/services.ts b/src/commands/addons/services.ts index 786d39ed27..80eebc35a5 100644 --- a/src/commands/addons/services.ts +++ b/src/commands/addons/services.ts @@ -18,7 +18,7 @@ export default class Services extends Command { hux.styledJSON(services) } else { /* eslint-disable perfectionist/sort-objects */ - hux.table(services as Array>, { + hux.table(services as unknown as Array>, { name: { header: 'Slug', }, diff --git a/src/commands/data/maintenances/run.ts b/src/commands/data/maintenances/run.ts index 2bcc4683cb..e5c9a57bc1 100644 --- a/src/commands/data/maintenances/run.ts +++ b/src/commands/data/maintenances/run.ts @@ -70,6 +70,7 @@ export default class DataMaintenancesRun extends BaseCommand { if (wait) { ux.action.start('Waiting for maintenance to complete') + // waitUntilMaintenanceComplete still uses legacy dataApi (polling loop not yet migrated to SDK) await waitUntilMaintenanceComplete(addon.id!, this.dataApi) ux.action.stop('maintenance completed') } diff --git a/src/commands/data/maintenances/schedule.ts b/src/commands/data/maintenances/schedule.ts index 188a84e8f4..0abf1ddd8c 100644 --- a/src/commands/data/maintenances/schedule.ts +++ b/src/commands/data/maintenances/schedule.ts @@ -73,7 +73,7 @@ export default class DataMaintenancesSchedule extends Command { const schedule: MaintenanceScheduleResult = await data.maintenance.schedule(addon.id!, {delay_weeks: delayWeeks}) ux.action.stop('maintenance scheduled') - const alreadyScheduled = !!schedule.previously_scheduled_for + const alreadyScheduled = Boolean(schedule.previously_scheduled_for) if (alreadyScheduled) { this.log(`Scheduled maintenance for ${color.addon(addon.name!)} changed from ${schedule.previously_scheduled_for} to ${schedule.scheduled_for}`) diff --git a/src/commands/data/maintenances/wait.ts b/src/commands/data/maintenances/wait.ts index 5e901e6270..087258720d 100644 --- a/src/commands/data/maintenances/wait.ts +++ b/src/commands/data/maintenances/wait.ts @@ -39,6 +39,7 @@ export default class DataMaintenancesWait extends BaseCommand { } ux.action.start(`Waiting for maintenance on ${color.addon(addon.name!)} to complete`) + // waitUntilMaintenanceComplete still uses legacy dataApi (polling loop not yet migrated to SDK) await waitUntilMaintenanceComplete(addon.id!, this.dataApi) ux.action.stop('maintenance completed') } diff --git a/src/commands/data/pg/migrate.ts b/src/commands/data/pg/migrate.ts index d4ed649b81..22283544d7 100644 --- a/src/commands/data/pg/migrate.ts +++ b/src/commands/data/pg/migrate.ts @@ -170,6 +170,7 @@ export default class DataPgMigrate extends BaseCommand { let currentStep = '__select_source' let sourceDatabaseId: string | undefined let targetDatabaseId: string | undefined + let targetDatabaseName: string | undefined while (currentStep !== '__exit') { switch (currentStep) { @@ -181,6 +182,7 @@ export default class DataPgMigrate extends BaseCommand { You'll receive an email when the preparation is complete or if there's an error. You have 24 hours to begin migration after the preparation is complete. Your source database will be unavailable during the migration. + Preparing the migration deletes all the data on the destination database ${color.datastore(targetDatabaseName!)}. `)) const {action} = await this.prompt<{action: string}>({ @@ -281,6 +283,7 @@ export default class DataPgMigrate extends BaseCommand { name: 'database', type: 'list', })).database + targetDatabaseName = this.advancedDatabases.find(db => db.id === targetDatabaseId)?.name if (targetDatabaseId === '__go_back') { currentStep = '__select_source' @@ -288,6 +291,7 @@ export default class DataPgMigrate extends BaseCommand { const addon = await this.createTargetDatabase(sourceDatabaseId!) if (addon) { targetDatabaseId = addon.id + targetDatabaseName = addon.name currentStep = '__confirm_migration' } else { currentStep = '__select_target' diff --git a/src/commands/pipelines/promote.ts b/src/commands/pipelines/promote.ts index 99c7ac8b13..6cbf9ea764 100644 --- a/src/commands/pipelines/promote.ts +++ b/src/commands/pipelines/promote.ts @@ -107,7 +107,7 @@ export default class Promote extends Command { { pipeline: {id: coupling.pipeline!.id!}, source: {app: {id: coupling.app!.id!}}, - targets: targetApps.map(app => ({app: {id: app.id}})), + targets: targetApps.map(app => ({app: {id: app.id!}})), }, {onReleaseStream}, ) diff --git a/src/commands/ps/index.ts b/src/commands/ps/index.ts index 58575259c4..698203e0a7 100644 --- a/src/commands/ps/index.ts +++ b/src/commands/ps/index.ts @@ -1,13 +1,14 @@ import {APIClient, Command, flags} from '@heroku-cli/command' import {color, hux} from '@heroku/heroku-cli-util' +import {HerokuSDK} from '@heroku/sdk' +import {appExtensions, privateToShield} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' +import type {AccountQuota} from '../../lib/types/account-quota.js' +import type {DynoExtended} from '../../lib/types/dyno-extended.js' + import {ago} from '../../lib/time.js' -import {AccountQuota} from '../../lib/types/account-quota.js' -import {AppProcessTier} from '../../lib/types/app-process-tier.js' -import {DynoExtended} from '../../lib/types/dyno-extended.js' -import {Account} from '../../lib/types/fir.js' import {huxTableNoWrapOptions} from '../../lib/utils/table-utils.js' const heredoc = tsheredoc.default @@ -40,24 +41,27 @@ export default class Index extends Command { const {flags, ...restParse} = await this.parse(Index) const {app, extended, json} = flags const types = restParse.argv as string[] - const suffix = extended ? '?extended=true' : '' - const promises = { - accountInfo: this.heroku.request('/account', { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - appInfo: this.heroku.request(`/apps/${app}`, { - headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - dynos: this.heroku.request(`/apps/${app}/dynos${suffix}`, { + + const {platform} = new HerokuSDK({extensions: [appExtensions]}) + + const dynosPromise = extended + ? this.heroku.request(`/apps/${app}/dynos?extended=true`, { headers: {Accept: 'application/vnd.heroku+json; version=3.sdk'}, - }), - } - const [{body: dynos}, {body: appInfo}, {body: accountInfo}] = await Promise.all([promises.dynos, promises.appInfo, promises.accountInfo]) - const shielded = appInfo.space && appInfo.space.shield + }).then(r => r.body) + : platform.dyno.list(app) as Promise + + const [dynos, shielded, appInfo, accountInfo] = await Promise.all([ + dynosPromise, + platform.app.isShielded(app), + platform.app.info(app), + platform.account.info(), + ]) + + const processTier = (appInfo as {process_tier?: string}).process_tier if (shielded) { for (const d of dynos) { - d.size = d.size.replace('Private-', 'Shield-') + d.size = privateToShield(d.size) } } @@ -79,7 +83,12 @@ export default class Index extends Command { else if (extended) printExtended(selectedDynos, flags['no-wrap']) else { - await printAccountQuota(this.heroku, appInfo, accountInfo) + await printAccountQuota(this.heroku, { + account: accountInfo, + appId: appInfo.id, + ownerId: appInfo.owner?.id, + processTier, + }) if (selectedDynos.length === 0) ux.stdout(`No dynos on ${color.app(app)}`) else @@ -141,17 +150,24 @@ function getProcessNumber(s: string) : number { return Number.parseInt(dynoNumber, 10) } -async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account: Account) { - if (app.process_tier !== 'eco') { +type QuotaOpts = { + account: {id?: string} + appId?: string + ownerId?: string + processTier?: string +} + +async function printAccountQuota(heroku: APIClient, opts: QuotaOpts) { + if (opts.processTier !== 'eco') { return } - if (app.owner.id !== account.id) { + if (opts.ownerId !== opts.account.id) { return } const {body: quota} = await heroku.request( - `/accounts/${account.id}/actions/get-quota`, + `/accounts/${opts.account.id}/actions/get-quota`, {headers: {Accept: 'application/vnd.heroku+json; version=3.account-quotas'}}, ).catch(() => ( {body: null} @@ -166,7 +182,7 @@ async function printAccountQuota(heroku: APIClient, app: AppProcessTier, account const remainingMinutes = remaining / 60 const hours = Math.floor(remainingMinutes / 60) const minutes = Math.floor(remainingMinutes % 60) - const appQuota = quota.apps.find(appQuota => appQuota.app_uuid === app.id) + const appQuota = quota.apps.find(appQuota => appQuota.app_uuid === opts.appId) const appQuotaUsed = appQuota ? appQuota.quota_used / 60 : 0 const appPercentage = appQuota ? Math.floor(appQuota.quota_used * 100 / quota.account_quota) : 0 const appHours = Math.floor(appQuotaUsed / 60) diff --git a/src/commands/ps/restart.ts b/src/commands/ps/restart.ts index e29d951ef2..563b8bc4d9 100644 --- a/src/commands/ps/restart.ts +++ b/src/commands/ps/restart.ts @@ -1,8 +1,9 @@ import {Command, flags} from '@heroku-cli/command' import {ProcessTypeCompletion} from '@heroku-cli/command/lib/completions.js' -import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' +import {dynoExtensions} from '@heroku/sdk/extensions/platform' import {Args, ux} from '@oclif/core' import tsheredoc from 'tsheredoc' @@ -44,32 +45,24 @@ export default class Restart extends Command { const {app} = flags const dyno = flags['dyno-name'] || args.dyno const type = flags['process-type'] - let msg = 'Restarting' - let restartUrl + + const {platform} = new HerokuSDK({extensions: [dynoExtensions]}) if (type) { - msg += ` all ${color.info(type)} dynos` - restartUrl = `/apps/${app}/formations/${encodeURIComponent(type)}` + ux.action.start(`Restarting all ${color.info(type)} dynos on ${color.app(app)}`) + await platform.dyno.restart(app, {type}) } else if (dyno) { if (args.dyno) { ux.warn(`DYNO is a deprecated argument. Use ${color.code('--dyno-name')} or ${color.code('--process-type')} instead.`) } - msg += ` dyno ${color.name(dyno)}` - restartUrl = `/apps/${app}/dynos/${encodeURIComponent(dyno)}` + ux.action.start(`Restarting dyno ${color.name(dyno)} on ${color.app(app)}`) + await platform.dyno.restart(app, {dyno}) } else { - msg += ' all dynos' - restartUrl = `/apps/${app}/dynos` + ux.action.start(`Restarting all dynos on ${color.app(app)}`) + await platform.dyno.restart(app) } - msg += ` on ${color.app(app)}` - - ux.action.start(msg) - await this.heroku.delete(restartUrl, { - headers: { - Accept: 'application/vnd.heroku+json; version=3.sdk', - }, - }) ux.action.stop() } } diff --git a/src/commands/ps/scale.ts b/src/commands/ps/scale.ts index 818ca703c0..5be30c9a62 100644 --- a/src/commands/ps/scale.ts +++ b/src/commands/ps/scale.ts @@ -1,6 +1,11 @@ +import type {ScaleDynosUpdate} from '@heroku/sdk/resources/platform/dyno' + import {Command, flags} from '@heroku-cli/command' -import * as Heroku from '@heroku-cli/schema' import * as color from '@heroku/heroku-cli-util/color' +import {HerokuSDK} from '@heroku/sdk' +import { + appExtensions, dynoExtensions, privateToShield, shieldToPrivate, +} from '@heroku/sdk/extensions/platform' import {ux} from '@oclif/core/ux' import tsheredoc from 'tsheredoc' @@ -42,54 +47,42 @@ export default class Scale extends Command { const argv = restParse.argv as string[] const {app} = flags - function parse(args: string[]) { + function parse(args: string[]): ScaleDynosUpdate[] { return _.compact(args.map(arg => { const change = arg.match(/^([\w-]+)([=+-]\d+)(?::([\w-]+))?$/) if (!change) return const quantity = change[2][0] === '=' ? change[2].slice(1) : change[2] - if (change[3]) - change[3] = change[3].replace('Shield-', 'Private-') - return {quantity, size: change[3], type: change[1]} + const size = change[3] ? shieldToPrivate(change[3]) : undefined + return {quantity, size, type: change[1]} })) } + const {platform} = new HerokuSDK({extensions: [appExtensions, dynoExtensions]}) const changes = parse(argv) if (changes.length === 0) { - const {body: formation} = await this.heroku.get(`/apps/${app}/formation`) - const {body: appProps} = await this.heroku.get(`/apps/${app}`) - const shielded = appProps.space && appProps.space.shield - if (shielded) { - for (const d of formation) { - if (d.size !== undefined) { - d.size = d.size.replace('Private-', 'Shield-') - } - } - } + const [formation, shielded] = await Promise.all([ + platform.formation.list(app), + platform.app.isShielded(app), + ]) if (formation.length === 0) { throw emptyFormationErr(app) } - ux.stdout(formation.map(d => `${d.type}=${d.quantity}:${d.size}`) + ux.stdout(formation.map(d => `${d.type}=${d.quantity}:${shielded && d.size !== undefined ? privateToShield(d.size) : d.size}`) .sort() .join(' ')) } else { ux.action.start('Scaling dynos') - const {body: appProps} = await this.heroku.get(`/apps/${app}`) - const {body: formation} = await this.heroku.patch(`/apps/${app}/formation`, {body: {updates: changes}}) - const shielded = appProps.space && appProps.space.shield - if (shielded) { - for (const d of formation) { - if (d.size !== undefined) { - d.size = d.size.replace('Private-', 'Shield-') - } - } - } + const [shielded, formation] = await Promise.all([ + platform.app.isShielded(app), + platform.dyno.scale(app, changes), + ]) const output = formation.filter(f => changes.find(c => c.type === f.type)) - .map(d => `${color.green(d.type || '')} at ${d.quantity}:${d.size}`) + .map(d => `${color.green(d.type || '')} at ${d.quantity}:${shielded && d.size !== undefined ? privateToShield(d.size) : d.size}`) ux.action.stop(`done, now running ${output.join(', ')}`) } } diff --git a/src/commands/run/inside.ts b/src/commands/run/inside.ts index 90b28c6e1a..74ec713c99 100644 --- a/src/commands/run/inside.ts +++ b/src/commands/run/inside.ts @@ -11,16 +11,18 @@ const debug = debugFactory('heroku:run:inside') const heredoc = tsheredoc.default export default class RunInside extends Command { + /* eslint-disable perfectionist/sort-objects */ static args = { - command: Args.string({ - description: 'command to run (Heroku automatically prepends \'launcher\' to the command)', - required: true, - }), dyno_name: Args.string({ description: 'name of the dyno to run command inside', required: true, }), + command: Args.string({ + description: 'command to run (Heroku automatically prepends \'launcher\' to the command)', + required: true, + }), } + /* eslint-enable perfectionist/sort-objects */ static description = 'run a command inside an existing dyno (for Fir-generation apps only)' static examples = [ heredoc` diff --git a/src/lib/pg/types.ts b/src/lib/pg/types.ts index ca12b35b93..93dbcc5c53 100644 --- a/src/lib/pg/types.ts +++ b/src/lib/pg/types.ts @@ -173,7 +173,6 @@ export type PgDatabaseTenant = { export type PgDatabase = PgDatabaseService & PgDatabaseTenant - // Updated according to https://github.com/heroku/shogun/blob/main/lib/shogun/serializers/link_serializer.rb export type Link = { created_at: string, diff --git a/src/lib/repl.ts b/src/lib/repl.ts index 2dadb905a6..61fa8b28d9 100644 --- a/src/lib/repl.ts +++ b/src/lib/repl.ts @@ -168,7 +168,7 @@ export class HerokuRepl { this.createInterface() this.start() // Force readline to refresh the current line - this.rl.write(null, {ctrl: true, name: 'u'}) + this.rl.write('', {ctrl: true, name: 'u'}) } } /** diff --git a/test/unit/commands/data/pg/migrate.unit.test.ts b/test/unit/commands/data/pg/migrate.unit.test.ts index afbafe5968..b37c28e29c 100644 --- a/test/unit/commands/data/pg/migrate.unit.test.ts +++ b/test/unit/commands/data/pg/migrate.unit.test.ts @@ -368,6 +368,7 @@ describe('data:pg:migrate', function () { // Verify the confirmation message is shown expect(stdout).to.contain('By continuing, we prepare the necessary steps for the migration.') + expect(stdout).to.contain('Preparing the migration deletes all the data on the destination database ⛁ postgresql-obscured-12345.') expect(stderr).to.equal('Configuring migration... done\n') // Verify the new migration is shown on the configured migrations table expect(stdout).to.match(/⛁ postgresql-convex-12345\s+⛁ postgresql-obscured-12345\s+Preparing/) diff --git a/test/unit/commands/ps/index.unit.test.ts b/test/unit/commands/ps/index.unit.test.ts index a10eed9b17..11a6cfa972 100644 --- a/test/unit/commands/ps/index.unit.test.ts +++ b/test/unit/commands/ps/index.unit.test.ts @@ -8,6 +8,7 @@ import strftime from 'strftime' import tsheredoc from 'tsheredoc' import Cmd from '../../../../src/commands/ps/index.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' import normalizeTableOutput from '../../../helpers/utils/normalize-table-output.js' const heredoc = tsheredoc.default @@ -15,68 +16,39 @@ const heredoc = tsheredoc.default const hourAgo = new Date(Date.now() - (60 * 60 * 1000)) const hourAgoStr = strftime('%Y/%m/%d %H:%M:%S %z', hourAgo) -function stubAccountQuota(code: number, body: Record) { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [{ - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }]) - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) +function stubQuotaApi(accountId: string, code: number, body: Record) { nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.account-quotas'}}) - .get('/accounts/1234/actions/get-quota') + .get(`/accounts/${accountId}/actions/get-quota`) .reply(code, body) } -function stubAppAndAccount() { - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) -} - describe('ps', function () { + let sdkMock: MockSDK + afterEach(function () { nock.cleanAll() restore() + sdkMock.restore() }) it('shows dyno list', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', - name: 'web.1', - size: 'Eco', - state: 'up', - type: 'web', - updated_at: hourAgo, - }, - { - command: 'bash', - name: 'run.1', - size: 'Eco', - state: 'up', - type: 'run', - updated_at: hourAgo, - }, - ]) - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - api.done() - expect(stdout).to.equal(heredoc` === run: one-off processes (1) @@ -91,28 +63,26 @@ describe('ps', function () { }) it('shows dyno list for Fir apps', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.4ed720fa31-ur8z1', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'npm start', name: 'web.4ed720fa31-5om2v', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'npm start ./worker.js', name: 'node-worker.4ed720fa31-w4llb', size: '2X-Compute', state: 'up', type: 'node-worker', updated_at: hourAgo, - }, - ]) - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.4ed720fa31-ur8z1', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'npm start', name: 'web.4ed720fa31-5om2v', size: '1X-Classic', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'npm start ./worker.js', name: 'node-worker.4ed720fa31-w4llb', size: '2X-Compute', state: 'up', type: 'node-worker', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - api.done() - expect(stdout).to.equal(heredoc` === node-worker (2X-Compute): npm start ./worker.js (1) @@ -128,28 +98,23 @@ describe('ps', function () { }) it('shows shield dynos in dyno list for apps in a shielded private space', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {space: {shield: true}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.1', size: 'Private-M', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'bash', name: 'run.1', size: 'Private-L', state: 'up', type: 'run', updated_at: hourAgo, - }, - ]) - - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Private-M', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'bash', name: 'run.1', size: 'Private-L', state: 'up', type: 'run', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic', space: {shield: true}}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(true)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - api.done() - expect(stdout).to.equal(heredoc` === run: one-off processes (1) @@ -164,18 +129,17 @@ describe('ps', function () { }) it('errors when no dynos found', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, - }, - { - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }, - ]) - - stubAppAndAccount() + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, + }, + { + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {error, stdout} = await runCommand(Cmd, [ 'foo', @@ -183,24 +147,18 @@ describe('ps', function () { 'myapp', ]) expect(ansis.strip(error!.message)).to.include('No foo dynos on ⬢ myapp') - - api.done() - expect(stdout).to.equal('') }) it('shows dyno list as json', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) - .get('/apps/myapp/dynos') - .reply(200, [ - { - command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, - }, - ]) + const listStub = stub().resolves([ + { + command: 'npm start', name: 'web.1', size: 'Eco', state: 'up', type: 'web', updated_at: hourAgo, + }, + ]) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -208,29 +166,21 @@ describe('ps', function () { '--json', ]) - api.done() expect(JSON.parse(stdout)[0].command).to.equal('npm start') expect(stderr).to.equal('') }) it('shows extended info', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.1', - port: 8000, - region: 'us', - route: 'da route', + az: 'us-east', execution_plane: 'execution_plane', fleet: 'fleet', instance: 'instance', ip: '10.0.0.1', port: 8000, region: 'us', route: 'da route', }, id: '100', name: 'web.1', @@ -242,14 +192,7 @@ describe('ps', function () { }, { command: 'bash', extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.2', - port: 8000, - region: 'us', - route: 'da route', + az: 'us-east', execution_plane: 'execution_plane', fleet: 'fleet', instance: 'instance', ip: '10.0.0.2', port: 8000, region: 'us', route: 'da route', }, id: '101', name: 'run.1', @@ -266,8 +209,6 @@ describe('ps', function () { '--extended', ]) - api.done() - expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ─────── ─────── ───────── ──────── ──── @@ -279,23 +220,16 @@ describe('ps', function () { }) it('passes no-wrap option through to extended table rendering', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', extended: { - az: 'us-east', - execution_plane: 'execution_plane', - fleet: 'fleet', - instance: 'instance', - ip: '10.0.0.1', - port: 8000, - region: 'us', - route: 'da route', + az: 'us-east', execution_plane: 'execution_plane', fleet: 'fleet', instance: 'instance', ip: '10.0.0.1', port: 8000, region: 'us', route: 'da route', }, id: '100', name: 'web.1', @@ -314,23 +248,16 @@ describe('ps', function () { }) it('shows extended info for Private Space app', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const infoStub = stub().resolves({name: 'myapp'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', extended: { - az: null, - execution_plane: null, - fleet: null, - instance: 'instance', - ip: '10.0.0.1', - port: null, - region: 'us', - route: null, + az: null, execution_plane: null, fleet: null, instance: 'instance', ip: '10.0.0.1', port: null, region: 'us', route: null, }, id: '100', name: 'web.1', @@ -342,14 +269,7 @@ describe('ps', function () { }, { command: 'bash', extended: { - az: null, - execution_plane: null, - fleet: null, - instance: 'instance', - ip: '10.0.0.1', - port: null, - region: 'us', - route: null, + az: null, execution_plane: null, fleet: null, instance: 'instance', ip: '10.0.0.1', port: null, region: 'us', route: null, }, id: '101', name: 'run.1', @@ -366,8 +286,6 @@ describe('ps', function () { '--extended', ]) - api.done() - expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ── ─────── ───────── ───── ──── @@ -378,11 +296,11 @@ describe('ps', function () { }) it('shows shield dynos in extended info if app is in a shielded private space', async function () { - const api = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - .get('/apps/myapp') - .reply(200, {space: {shield: true}}) + const infoStub = stub().resolves({space: {shield: true}}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(true)}}) + + nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) .get('/apps/myapp/dynos?extended=true') .reply(200, [{ command: 'npm start', @@ -416,8 +334,6 @@ describe('ps', function () { '--extended', ]) - api.done() - expect(normalizeTableOutput(stdout)).to.equal(normalizeTableOutput(` Id Process State Region Execution plane Fleet Instance Ip Port Az Release Command Route Size ─── ─────── ─────────────────────────────────────── ────── ─────────────── ───── ──────── ──────── ──── ─────── ─────── ───────── ──────── ──────── @@ -428,6 +344,14 @@ describe('ps', function () { }) it('shows eco quota remaining', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 1000, apps: [], quota_used: 1}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 0h 16m (99%) Eco dyno usage for this app: 0h 0m (0%) @@ -439,7 +363,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 1000, apps: [], quota_used: 1}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -451,6 +374,14 @@ describe('ps', function () { }) it('shows eco quota remaining in hours and minutes', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 3_600_000, apps: [], quota_used: 178_200}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 950h 30m (95%) Eco dyno usage for this app: 0h 0m (0%) @@ -462,7 +393,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 3_600_000, apps: [], quota_used: 178_200}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -474,6 +404,14 @@ describe('ps', function () { }) it('shows eco quota usage of eco apps', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 3_600_000, apps: [{app_uuid: '6789', quota_used: 178_200}], quota_used: 178_200}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 950h 30m (95%) Eco dyno usage for this app: 49h 30m (4%) @@ -485,7 +423,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 3_600_000, apps: [{app_uuid: '6789', quota_used: 178_200}], quota_used: 178_200}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -497,6 +434,14 @@ describe('ps', function () { }) it('shows eco quota remaining even when account_quota is zero', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {account_quota: 0, apps: [], quota_used: 0}) + const ecoExpression = heredoc` Eco dyno hours quota remaining this month: 0h 0m (0%) Eco dyno usage for this app: 0h 0m (0%) @@ -508,7 +453,6 @@ describe('ps', function () { run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {account_quota: 0, apps: [], quota_used: 0}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -520,13 +464,20 @@ describe('ps', function () { }) it('handles quota 404 properly', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 404, {id: 'not_found'}) + const ecoExpression = heredoc` === run: one-off processes (1) run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(404, {id: 'not_found'}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -538,13 +489,20 @@ describe('ps', function () { }) it('handles quota 200 not_found properly', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 200, {id: 'not_found'}) + const ecoExpression = heredoc` === run: one-off processes (1) run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(200, {id: 'not_found'}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -556,22 +514,13 @@ describe('ps', function () { }) it('does not print out for apps that are not owned', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, { - owner: {id: '5678'}, process_tier: 'eco', - }) - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.account-quotas'}}) - .get('/accounts/1234/actions/get-quota') - .reply(200, {account_quota: 1000, apps: [], quota_used: 1}) - const dynos = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [{ - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }]) + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({owner: {id: '5678'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + const ecoExpression = heredoc` === run: one-off processes (1) @@ -584,24 +533,18 @@ describe('ps', function () { 'myapp', ]) - dynos.done() - expect(stdout).to.equal(ecoExpression) expect(stderr).to.equal('') }) it('does not print out for non-eco apps', async function () { - nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/account') - .reply(200, {id: '1234'}) - nock('https://api.heroku.com', {reqheaders: {Accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp') - .reply(200, {owner: {id: 1234}, process_tier: 'eco'}) - const dynos = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, [{ - command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, - }]) + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + const ecoExpression = heredoc` === run: one-off processes (1) @@ -614,20 +557,25 @@ describe('ps', function () { 'myapp', ]) - dynos.done() - expect(stdout).to.equal(ecoExpression) expect(stderr).to.equal('') }) it('traps errors properly', async function () { + const listStub = stub().resolves([{ + command: 'bash', name: 'run.1', size: 'Eco', state: 'up', type: 'run', updated_at: hourAgo, + }]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'eco'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) + stubQuotaApi('1234', 503, {id: 'server_error'}) + const ecoExpression = heredoc` === run: one-off processes (1) run.1 (Eco): up ${hourAgoStr} (~ 1h ago): bash ` - stubAccountQuota(503, {id: 'server_error'}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -639,18 +587,16 @@ describe('ps', function () { }) it('logs to stdout and exits zero when no dynos', async function () { - const dynos = nock('https://api.heroku.com', {reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}}) - .get('/apps/myapp/dynos') - .reply(200, []) - stubAppAndAccount() + const listStub = stub().resolves([]) + const infoStub = stub().resolves({id: '6789', owner: {id: '1234'}, process_tier: 'basic'}) + const accountStub = stub().resolves({id: '1234'}) + sdkMock = mockSDKPlatform({account: {info: accountStub}, app: {info: infoStub, isShielded: stub().resolves(false)}, dyno: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', 'myapp', ]) - dynos.done() - expect(stdout).to.equal('No dynos on ⬢ myapp\n') expect(stderr).to.equal('') }) diff --git a/test/unit/commands/ps/restart.unit.test.ts b/test/unit/commands/ps/restart.unit.test.ts index 34eb9a9841..cfa6c1a7d8 100644 --- a/test/unit/commands/ps/restart.unit.test.ts +++ b/test/unit/commands/ps/restart.unit.test.ts @@ -1,27 +1,33 @@ import {expectOutput, runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/ps/restart.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('ps:restart', function () { + let sdkMock: MockSDK + + afterEach(function () { + sdkMock.restore() + }) + it('restarts all dynos', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/dynos') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', 'myapp', ]) expectOutput(stderr, 'Restarting all dynos on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp')).to.be.true }) it('restarts web dynos', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/formations/web') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', @@ -30,12 +36,12 @@ describe('ps:restart', function () { 'web', ]) expectOutput(stderr, 'Restarting all web dynos on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp', {type: 'web'})).to.be.true }) it('restarts a specific dyno', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/dynos/web.1') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', @@ -44,12 +50,12 @@ describe('ps:restart', function () { 'web.1', ]) expectOutput(stderr, 'Restarting dyno web.1 on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp', {dyno: 'web.1'})).to.be.true }) it('emits a warning when passing dyno as an arg', async function () { - nock('https://api.heroku.com') - .delete('/apps/myapp/dynos/web.1') - .reply(202) + const restartStub = stub().resolves() + sdkMock = mockSDKPlatform({dyno: {restart: restartStub}}) const {stderr} = await runCommand(Cmd, [ '--app', @@ -58,5 +64,6 @@ describe('ps:restart', function () { ]) expect(ansis.strip(stderr)).to.include('DYNO is a deprecated argument.') expect(stderr).to.include('Restarting dyno web.1 on ⬢ myapp... done') + expect(restartStub.calledOnceWith('myapp', {dyno: 'web.1'})).to.be.true }) }) diff --git a/test/unit/commands/ps/scale.unit.test.ts b/test/unit/commands/ps/scale.unit.test.ts index 117243fe8c..05c1f43dd8 100644 --- a/test/unit/commands/ps/scale.unit.test.ts +++ b/test/unit/commands/ps/scale.unit.test.ts @@ -1,28 +1,22 @@ import {runCommand} from '@heroku-cli/test-utils' import ansis from 'ansis' import {expect} from 'chai' -import nock from 'nock' +import {stub} from 'sinon' import Cmd from '../../../../src/commands/ps/scale.js' +import {type MockSDK, mockSDKPlatform} from '../../../helpers/mock-sdk.js' describe('ps:scale', function () { - let api: nock.Scope - - beforeEach(function () { - api = nock('https://api.heroku.com') - }) + let sdkMock: MockSDK afterEach(function () { - api.done() - nock.cleanAll() + sdkMock.restore() }) it('shows formation with no args', async function () { - api - .get('/apps/myapp/formation') - .reply(200, [{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const listStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, formation: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -31,14 +25,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('web=1:Free worker=2:Free\n') expect(stderr).to.equal('') + expect(listStub.calledOnceWith('myapp')).to.be.true }) it('shows formation with shield dynos for apps in a shielded private space', async function () { - api - .get('/apps/myapp/formation') - .reply(200, [{quantity: 1, size: 'Private-L', type: 'web'}, {quantity: 2, size: 'Private-M', type: 'worker'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp', space: {shield: true}}) + const listStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}, {quantity: 2, size: 'Private-M', type: 'worker'}]) + const isShieldedStub = stub().resolves(true) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, formation: {list: listStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -50,11 +43,9 @@ describe('ps:scale', function () { }) it('errors with no process types', async function () { - api - .get('/apps/myapp/formation') - .reply(200, []) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const listStub = stub().resolves([]) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, formation: {list: listStub}}) const {error, stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -67,11 +58,9 @@ describe('ps:scale', function () { }) it('scales web=1 worker=2', async function () { - api - .patch('/apps/myapp/formation', {updates: [{quantity: '1', type: 'web'}, {quantity: '2', type: 'worker'}]}) - .reply(200, [{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const scaleStub = stub().resolves([{quantity: 1, size: 'Free', type: 'web'}, {quantity: 2, size: 'Free', type: 'worker'}]) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, dyno: {scale: scaleStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -82,14 +71,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 1:Free, worker at 2:Free\n') + expect(scaleStub.calledOnceWith('myapp', [{quantity: '1', size: undefined, type: 'web'}, {quantity: '2', size: undefined, type: 'worker'}])).to.be.true }) it('scales up a shield dyno if the app is in a shielded private space', async function () { - api - .patch('/apps/myapp/formation', {updates: [{quantity: '1', size: 'Private-L', type: 'web'}]}) - .reply(200, [{quantity: 1, size: 'Private-L', type: 'web'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp', space: {shield: true}}) + const scaleStub = stub().resolves([{quantity: 1, size: 'Private-L', type: 'web'}]) + const isShieldedStub = stub().resolves(true) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, dyno: {scale: scaleStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -99,14 +87,13 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 1:Shield-L\n') + expect(scaleStub.calledOnceWith('myapp', [{quantity: '1', size: 'Private-L', type: 'web'}])).to.be.true }) it('scales web-1', async function () { - api - .patch('/apps/myapp/formation', {updates: [{quantity: '+1', type: 'web'}]}) - .reply(200, [{quantity: 2, size: 'Free', type: 'web'}]) - .get('/apps/myapp') - .reply(200, {name: 'myapp'}) + const scaleStub = stub().resolves([{quantity: 2, size: 'Free', type: 'web'}]) + const isShieldedStub = stub().resolves(false) + sdkMock = mockSDKPlatform({app: {isShielded: isShieldedStub}, dyno: {scale: scaleStub}}) const {stderr, stdout} = await runCommand(Cmd, [ '--app', @@ -116,5 +103,6 @@ describe('ps:scale', function () { expect(stdout).to.equal('') expect(stderr).to.contain('Scaling dynos... done, now running web at 2:Free\n') + expect(scaleStub.calledOnceWith('myapp', [{quantity: '+1', size: undefined, type: 'web'}])).to.be.true }) }) diff --git a/test/unit/lib/repl.unit.test.ts b/test/unit/lib/repl.unit.test.ts index c1bef82bdb..41016cd189 100644 --- a/test/unit/lib/repl.unit.test.ts +++ b/test/unit/lib/repl.unit.test.ts @@ -172,6 +172,41 @@ describe('HerokuRepl', function () { }) }) + describe('processLine', function () { + it('passes an empty string to rl.write when clearing the line after command execution', async function () { + const writeSpy = stub() + const mockRl = { + close: stub(), + history: [], + off: stub(), + on: stub(), + once: stub(), + prompt: stub(), + setPrompt: stub(), + write: writeSpy, + } + + stub(HerokuRepl.prototype, 'createInterface' as any).callsFake(function (this: any) { + this.rl = mockRl + }) + + config = { + commands: [], + findCommand: stub().returns({flags: {}, id: 'apps:info'}), + root: '/fake/root', + runCommand: stub().resolves(), + } as any + + repl = new HerokuRepl(config) + + await (repl as any).processLine('apps:info') + + const writeCall = writeSpy.args.find((args: any[]) => args[1]?.ctrl === true && args[1]?.name === 'u') + expect(writeCall, 'rl.write should be called for ctrl+u line clear').to.exist + expect(writeCall![0]).to.equal('') + }) + }) + describe('readline interface creation', function () { it('should create readline interface with correct configuration', function () { stub(HerokuRepl.prototype, 'fsExistsSync' as any).returns(false)