From eefca3d028c5154a29d63c8292d301da186c6bb0 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 26 Aug 2024 16:55:11 +0300 Subject: [PATCH 01/15] feat: builds namespace --- package.json | 2 + src/commands/builds/index.ts | 9 +++ src/commands/builds/info.ts | 91 +++++++++++++++++++++++++ src/commands/builds/log.ts | 38 +++++++++++ src/lib/commands/pretty-print-bytes.ts | 11 +++ src/lib/commands/pretty-print-status.ts | 35 ++++++++++ src/lib/utils.ts | 5 ++ yarn.lock | 16 +++++ 8 files changed, 207 insertions(+) create mode 100644 src/commands/builds/index.ts create mode 100644 src/commands/builds/info.ts create mode 100644 src/commands/builds/log.ts create mode 100644 src/lib/commands/pretty-print-bytes.ts create mode 100644 src/lib/commands/pretty-print-status.ts diff --git a/package.json b/package.json index dae4eaf4..d9f5638f 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,8 @@ "@oclif/core": "~4.0.17", "@oclif/plugin-help": "~6.2.8", "@root/walk": "~1.1.0", + "@sapphire/duration": "^1.1.2", + "@sapphire/timestamp": "^1.0.3", "adm-zip": "~0.5.15", "ajv": "~8.17.1", "apify-client": "~2.9.4", diff --git a/src/commands/builds/index.ts b/src/commands/builds/index.ts new file mode 100644 index 00000000..e66fa0b6 --- /dev/null +++ b/src/commands/builds/index.ts @@ -0,0 +1,9 @@ +import { ApifyCommand } from '../../lib/apify_command.js'; + +export class ActorIndexCommand extends ApifyCommand { + static override description = 'Commands are designed to be used with Actor Builds.'; + + async run() { + await this.printHelp(); + } +} diff --git a/src/commands/builds/info.ts b/src/commands/builds/info.ts new file mode 100644 index 00000000..b1370542 --- /dev/null +++ b/src/commands/builds/info.ts @@ -0,0 +1,91 @@ +import { Args } from '@oclif/core'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; +import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { DurationFormatter, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; + +export class BuildInfoCommand extends ApifyCommand { + static override description = 'Prints information about a specific build'; + + static override args = { + buildId: Args.string({ + required: true, + description: 'The build id to get information about', + }), + }; + + async run() { + const { buildId } = this.args; + + const apifyClient = await getLoggedClientOrThrow(); + + const build = await apifyClient.build(buildId).get(); + + if (!build) { + error({ message: `Build with ID "${buildId}" was not found on your account` }); + return; + } + + const actor = await apifyClient.actor(build.actId).get(); + + let buildTag: string | undefined; + + if (actor?.taggedBuilds) { + for (const [tag, buildData] of Object.entries(actor.taggedBuilds)) { + if (buildData.buildId === build.id) { + buildTag = tag; + break; + } + } + } + + const exitCode = Reflect.get(build, 'exitCode') as number | undefined; + + const message: string[] = [ + // + `${chalk.yellow('Actor')}: ${actor?.name ?? 'Unknown Actor'} (${chalk.gray(build.actId)})`, + '', + `${chalk.yellow('Build Information')} (ID: ${chalk.gray(build.id)})`, + ` ${chalk.yellow('Build Number')}: ${build.buildNumber}${buildTag ? ` (tagged as ${chalk.yellow(buildTag)})` : ''}`, + // exitCode is also not typed... + ` ${chalk.yellow('Status')}: ${prettyPrintStatus(build.status)}${typeof exitCode !== 'undefined' ? ` (exit code: ${chalk.gray(exitCode)})` : ''}`, + ` ${chalk.yellow('Started')}: ${TimestampFormatter.display(build.startedAt)}`, + ]; + + if (build.finishedAt) { + message.push( + ` ${chalk.yellow('Finished')}: ${TimestampFormatter.display(build.finishedAt)} (took ${chalk.gray(DurationFormatter.format(build.stats?.durationMillis ?? 0))})`, + ); + } else { + const diff = Date.now() - build.startedAt.getTime(); + message.push( + ` ${chalk.yellow('Finished')}: ${chalk.gray(`Running for ${DurationFormatter.format(diff)}`)}`, + ); + } + + if (build.stats?.computeUnits) { + // Platform shows 3 decimal places, so shall we + message.push(` ${chalk.yellow('Compute Units')}: ${build.stats.computeUnits.toFixed(3)}`); + } + + // Untyped field again 😢 + const dockerImageSize = Reflect.get(build.stats ?? {}, 'imageSizeBytes') as number | undefined; + + if (dockerImageSize) { + message.push(` ${chalk.yellow('Docker Image Size')}: ${prettyPrintBytes(dockerImageSize)}`); + } + + message.push(` ${chalk.yellow('Origin')}: ${build.meta.origin ?? 'UNKNOWN'}`); + + message.push(''); + + const url = `https://console.apify.com/actors/${build.actId}/builds/${build.buildNumber}`; + + message.push(`${chalk.blue('View in Apify Console')}: ${url}`); + + simpleLog({ message: message.join('\n') }); + } +} diff --git a/src/commands/builds/log.ts b/src/commands/builds/log.ts new file mode 100644 index 00000000..7a1d6e75 --- /dev/null +++ b/src/commands/builds/log.ts @@ -0,0 +1,38 @@ +import { Args } from '@oclif/core'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { error, info } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js'; + +export class BuildLogCommand extends ApifyCommand { + static override description = 'Prints the log of a specific build'; + + static override args = { + buildId: Args.string({ + required: true, + description: 'The build id to get the log from', + }), + }; + + async run() { + const { buildId } = this.args; + + const apifyClient = await getLoggedClientOrThrow(); + + const build = await apifyClient.build(buildId).get(); + + if (!build) { + error({ message: `Build with ID "${buildId}" was not found on your account` }); + return; + } + + info({ message: `Log for build with ID "${buildId}":\n` }); + + try { + await outputJobLog(build); + } catch (err) { + // This should never happen... + error({ message: `Failed to get log for build with ID "${buildId}": ${(err as Error).message}` }); + } + } +} diff --git a/src/lib/commands/pretty-print-bytes.ts b/src/lib/commands/pretty-print-bytes.ts new file mode 100644 index 00000000..dc3a875d --- /dev/null +++ b/src/lib/commands/pretty-print-bytes.ts @@ -0,0 +1,11 @@ +export function prettyPrintBytes(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + + if (bytes === 0) { + return '0 Byte'; + } + + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return `${(bytes / 1024 ** i).toFixed(2)} ${sizes[i]}`; +} diff --git a/src/lib/commands/pretty-print-status.ts b/src/lib/commands/pretty-print-status.ts new file mode 100644 index 00000000..f44ac4b0 --- /dev/null +++ b/src/lib/commands/pretty-print-status.ts @@ -0,0 +1,35 @@ +// TIMED-OUT -> Timed Out + +import type { ACTOR_JOB_STATUSES, ACTOR_JOB_TERMINAL_STATUSES } from '@apify/consts'; +import chalk from 'chalk'; + +// ABORTED -> Aborted +export function prettyPrintStatus( + status: (typeof ACTOR_JOB_STATUSES)[keyof typeof ACTOR_JOB_STATUSES] | (typeof ACTOR_JOB_TERMINAL_STATUSES)[number], +) { + switch (status) { + case 'READY': + return chalk.green('Ready'); + case 'RUNNING': + return chalk.blue('Running'); + case 'SUCCEEDED': + return chalk.green('Succeeded'); + case 'FAILED': + return chalk.red('Failed'); + case 'ABORTING': + return chalk.yellow('Aborting'); + case 'ABORTED': + return chalk.red('Aborted'); + case 'TIMING-OUT': + return chalk.yellow('Timing Out'); + case 'TIMED-OUT': + return chalk.red('Timed Out'); + default: + return chalk.gray( + (status as string) + .split('-') + .map((part) => part[0].toUpperCase() + part.slice(1).toLowerCase()) + .join(' '), + ); + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 8b3ce264..ee702ddf 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -24,6 +24,8 @@ import { LOCAL_STORAGE_SUBDIRS, SOURCE_FILE_FORMATS, } from '@apify/consts'; +import { DurationFormatter as SapphireDurationFormatter } from '@sapphire/duration'; +import { Timestamp } from '@sapphire/timestamp'; import AdmZip from 'adm-zip'; import _Ajv from 'ajv'; import { type ActorRun, ApifyClient, type ApifyClientOptions, type Build } from 'apify-client'; @@ -740,3 +742,6 @@ export const ensureApifyDirectory = (file: string) => { mkdirSync(path, { recursive: true }); }; + +export const TimestampFormatter = new Timestamp('YYYY-MM-DD [at] HH:mm:ss'); +export const DurationFormatter = new SapphireDurationFormatter(); diff --git a/yarn.lock b/yarn.lock index 8e655a92..8c346262 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2022,6 +2022,13 @@ __metadata: languageName: node linkType: hard +"@sapphire/duration@npm:^1.1.2": + version: 1.1.2 + resolution: "@sapphire/duration@npm:1.1.2" + checksum: 10c0/d9f2b74502bec1801a4d4ed62c10ca8e2bead8631309e4dfe00bcb4d439e64fa74e090d6e4a26f10924e86d241253fedbcd8674d0bd8a1853f0b923d2105c9fb + languageName: node + linkType: hard + "@sapphire/result@npm:^2.6.6": version: 2.6.6 resolution: "@sapphire/result@npm:2.6.6" @@ -2039,6 +2046,13 @@ __metadata: languageName: node linkType: hard +"@sapphire/timestamp@npm:^1.0.3": + version: 1.0.3 + resolution: "@sapphire/timestamp@npm:1.0.3" + checksum: 10c0/97d8b9196674b1272b87366d386bd7077bfc14f0227e79bce36d4d13231e2f14eb6a517b043117916dd8fee2486d421b01494d462f7f01b6d951c2b50042bc7f + languageName: node + linkType: hard + "@sec-ant/readable-stream@npm:^0.4.1": version: 0.4.1 resolution: "@sec-ant/readable-stream@npm:0.4.1" @@ -3568,7 +3582,9 @@ __metadata: "@oclif/plugin-help": "npm:~6.2.8" "@oclif/test": "npm:^4.0.8" "@root/walk": "npm:~1.1.0" + "@sapphire/duration": "npm:^1.1.2" "@sapphire/result": "npm:^2.6.6" + "@sapphire/timestamp": "npm:^1.0.3" "@types/adm-zip": "npm:^0.5.5" "@types/archiver": "npm:^6.0.2" "@types/chai": "npm:^4.3.17" From ad5f9324de2618a76d93d0906f2fd6eaa879eae7 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 26 Aug 2024 19:46:35 +0300 Subject: [PATCH 02/15] feat: builds ls --- package.json | 2 + src/commands/builds/info.ts | 7 ++ src/commands/builds/ls.ts | 143 ++++++++++++++++++++++ src/lib/apify_command.ts | 3 + src/lib/commands/resolve-actor-context.ts | 71 +++++++++++ src/lib/utils.ts | 27 ++++ yarn.lock | 25 ++++ 7 files changed, 278 insertions(+) create mode 100644 src/commands/builds/ls.ts create mode 100644 src/lib/commands/resolve-actor-context.ts diff --git a/package.json b/package.json index d9f5638f..6f1c9e31 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "archiver": "~7.0.1", "axios": "~1.7.3", "chalk": "~5.3.0", + "cli-table": "^0.3.11", "computer-name": "~0.1.0", "configparser": "~0.3.10", "cors": "~2.8.5", @@ -111,6 +112,7 @@ "@types/adm-zip": "^0.5.5", "@types/archiver": "^6.0.2", "@types/chai": "^4.3.17", + "@types/cli-table": "^0", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "@types/inquirer": "^9.0.7", diff --git a/src/commands/builds/info.ts b/src/commands/builds/info.ts index b1370542..484e2b4a 100644 --- a/src/commands/builds/info.ts +++ b/src/commands/builds/info.ts @@ -29,6 +29,11 @@ export class BuildInfoCommand extends ApifyCommand { return; } + // JSON output -> return the object (which is handled by oclif) + if (this.flags.json) { + return build; + } + const actor = await apifyClient.actor(build.actId).get(); let buildTag: string | undefined; @@ -87,5 +92,7 @@ export class BuildInfoCommand extends ApifyCommand { message.push(`${chalk.blue('View in Apify Console')}: ${url}`); simpleLog({ message: message.join('\n') }); + + return undefined; } } diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts new file mode 100644 index 00000000..1f99e506 --- /dev/null +++ b/src/commands/builds/ls.ts @@ -0,0 +1,143 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import Table from 'cli-table'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; +import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { DurationFormatter, getLoggedClientOrThrow, objectGroupBy, TimestampFormatter } from '../../lib/utils.js'; + +const tableFactory = () => + new Table<[string, string, string, string, string]>({ + head: ['Number', 'ID', 'Status', 'Started At', 'Finished At'], + style: { + head: ['cyan', 'cyan', 'cyan', 'cyan', 'cyan'], + }, + }); + +export class BuildLsCommand extends ApifyCommand { + static override description = 'Lists all builds of the actor.'; + + static override flags = { + actor: Flags.string({ + description: + 'Optional Actor ID or Name to list builds for. By default, it will use the Actor from the current directory', + }), + offset: Flags.integer({ + description: 'Number of builds that will be skipped.', + default: 0, + }), + limit: Flags.integer({ + description: 'Number of builds that will be listed.', + default: 10, + }), + desc: Flags.boolean({ + description: 'Sort builds in descending order.', + default: false, + }), + }; + + async run() { + const { actor, desc, limit, offset, json } = this.flags; + + const client = await getLoggedClientOrThrow(); + + // TODO: technically speaking, we don't *need* an actor id to list builds. But it makes more sense to have a table of builds for a specific actor. + const ctx = await resolveActorContext({ providedActorNameOrId: actor, client }); + + if (!ctx) { + error({ + message: + 'Unable to detect what Actor to list the builds for. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor="', + }); + + return; + } + + const builds = await client.actor(ctx.id).builds().list({ desc, limit, offset }); + + // TODO: maybe return the full response object instead of just a list of builds? + if (json) { + return builds.items; + } + + const actorInfo = (await client.actor(ctx.id).get())!; + + simpleLog({ + message: `${chalk.reset('Showing')} ${chalk.yellow(builds.items.length)} out of ${chalk.yellow(builds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, + }); + + const result = objectGroupBy(builds.items, (item) => { + const versionNumber = Reflect.get(item, 'buildNumber') as string; + + const [major, minor] = versionNumber.split('.'); + + return `${major}.${minor}`; + }); + + const versionToTag = Object.entries(actorInfo.taggedBuilds ?? {}).reduce( + (acc, [tag, data]) => { + acc[data.buildNumber] = tag; + + return acc; + }, + {} as Record, + ); + + for (const [actorVersion, buildsForVersion] of Object.entries(result).sort((a, b) => + a[0].localeCompare(b[0]), + )) { + if (!buildsForVersion?.length) { + simpleLog({ + message: `No builds for version ${actorVersion}`, + }); + + continue; + } + + const latestBuildGetsTaggedAs = actorInfo.versions.find((v) => v.versionNumber === actorVersion)?.buildTag; + + const table = tableFactory(); + + for (const build of buildsForVersion) { + const buildNumber = Reflect.get(build, 'buildNumber') as string; + + const hasTag = versionToTag[buildNumber]; + + const tableRow: [string, string, string, string, string] = [ + `${buildNumber}${hasTag ? ` (${chalk.yellow(hasTag)})` : ''}`, + chalk.gray(build.id), + prettyPrintStatus(build.status), + TimestampFormatter.display(build.startedAt), + '', + ]; + + if (build.finishedAt) { + tableRow[4] = TimestampFormatter.display(build.finishedAt); + } else { + const diff = Date.now() - build.startedAt.getTime(); + tableRow[4] = chalk.gray(`Running for ${DurationFormatter.format(diff)}`); + } + + table.push(tableRow); + } + + simpleLog({ + message: chalk.reset( + `Builds for Actor Version ${chalk.yellow(actorVersion)}${latestBuildGetsTaggedAs ? ` (latest build gets tagged with ${chalk.yellow(latestBuildGetsTaggedAs)})` : ''}`, + ), + }); + + simpleLog({ + message: table.toString(), + }); + + simpleLog({ + message: '', + }); + } + + return undefined; + } +} diff --git a/src/lib/apify_command.ts b/src/lib/apify_command.ts index fd323fd2..b0059c28 100644 --- a/src/lib/apify_command.ts +++ b/src/lib/apify_command.ts @@ -15,6 +15,8 @@ export type ApifyArgs = Interfaces.InferredArgs extends Command { + static override enableJsonFlag = true; + protected telemetryData!: Record; protected flags!: ApifyFlags; @@ -27,6 +29,7 @@ export abstract class ApifyCommand extends Command { flags: this.ctor.flags, args: this.ctor.args, strict: this.ctor.strict, + enableJsonFlag: this.ctor.enableJsonFlag, }); this.flags = argsToCamelCase(flags) as ApifyFlags; diff --git a/src/lib/commands/resolve-actor-context.ts b/src/lib/commands/resolve-actor-context.ts new file mode 100644 index 00000000..d7fe1000 --- /dev/null +++ b/src/lib/commands/resolve-actor-context.ts @@ -0,0 +1,71 @@ +import process from 'node:process'; + +import type { ApifyClient } from 'apify-client'; + +import { getLocalConfig, getLocalUserInfo } from '../utils.js'; + +/** + * Tries to resolve what actor the command ran points to. If an actor id is provided via command line, attempt to resolve it, + * thus assuming the actor is the one the command should be ran on. If no actor id is provided, try to resolve the actor from the local + * configuration file. If none of these are successful, return null, signaling an unknown context, and thus no way to continue. + * @param providedActorNameOrId Actor name or id provided via command line + */ +export async function resolveActorContext({ + providedActorNameOrId, + client, +}: { providedActorNameOrId: string | undefined; client: ApifyClient }) { + const userInfo = await getLocalUserInfo(); + const usernameOrId = userInfo.username || (userInfo.id as string); + const localConfig = getLocalConfig(process.cwd()) || {}; + + // Full ID + if (providedActorNameOrId?.includes('/')) { + const actor = await client.actor(providedActorNameOrId).get(); + if (!actor) { + return null; + } + + return { + userFriendlyId: `${actor.username}/${actor.name}`, + id: actor.id, + }; + } + + // Try fetching Actor directly by name/id + if (providedActorNameOrId) { + const actorById = await client.actor(providedActorNameOrId).get(); + + if (actorById) { + return { + userFriendlyId: `${actorById.username}/${actorById.name}`, + id: actorById.id, + }; + } + + const actorByName = await client.actor(`${usernameOrId}/${providedActorNameOrId.toLowerCase()}`).get(); + + if (actorByName) { + return { + userFriendlyId: `${actorByName.username}/${actorByName.name}`, + id: actorByName.id, + }; + } + + return null; + } + + if (localConfig.name) { + const actor = await client.actor(`${usernameOrId}/${localConfig.name}`).get(); + + if (!actor) { + return null; + } + + return { + userFriendlyId: `${actor.username}/${actor.name}`, + id: actor.id, + }; + } + + return null; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ee702ddf..aacf4658 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -745,3 +745,30 @@ export const ensureApifyDirectory = (file: string) => { export const TimestampFormatter = new Timestamp('YYYY-MM-DD [at] HH:mm:ss'); export const DurationFormatter = new SapphireDurationFormatter(); + +/** + * A "polyfill" for Object.groupBy + */ +export function objectGroupBy( + items: Iterable, + keySelector: (item: T, index: number) => K, +): Partial> { + if ('groupBy' in Object) { + return Object.groupBy(items, keySelector); + } + + const result: Partial> = {}; + + let i = 0; + + for (const item of items) { + const key = keySelector(item, i++); + if (!result[key]) { + result[key] = []; + } + + result[key].push(item); + } + + return result; +} diff --git a/yarn.lock b/yarn.lock index 8c346262..3481356e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2777,6 +2777,13 @@ __metadata: languageName: node linkType: hard +"@types/cli-table@npm:^0": + version: 0.3.4 + resolution: "@types/cli-table@npm:0.3.4" + checksum: 10c0/dc266f9b8daae5efab0741546f6edccb96ece167a80ebf0c389d811969ea42f3f340575cc38923abdebae8fed1eec322c6769db1e0e4280bd5360daee7cef0ca + languageName: node + linkType: hard + "@types/connect@npm:*": version: 3.4.38 resolution: "@types/connect@npm:3.4.38" @@ -3588,6 +3595,7 @@ __metadata: "@types/adm-zip": "npm:^0.5.5" "@types/archiver": "npm:^6.0.2" "@types/chai": "npm:^4.3.17" + "@types/cli-table": "npm:^0" "@types/cors": "npm:^2.8.17" "@types/express": "npm:^4.17.21" "@types/inquirer": "npm:^9.0.7" @@ -3609,6 +3617,7 @@ __metadata: axios: "npm:~1.7.3" chai: "npm:^4.4.1" chalk: "npm:~5.3.0" + cli-table: "npm:^0.3.11" computer-name: "npm:~0.1.0" configparser: "npm:~0.3.10" cors: "npm:~2.8.5" @@ -4509,6 +4518,15 @@ __metadata: languageName: node linkType: hard +"cli-table@npm:^0.3.11": + version: 0.3.11 + resolution: "cli-table@npm:0.3.11" + dependencies: + colors: "npm:1.0.3" + checksum: 10c0/6e31da4e19e942bf01749ff78d7988b01e0101955ce2b1e413eecdc115d4bb9271396464761491256a7d3feeedb5f37ae505f4314c4f8044b5d0f4b579c18f29 + languageName: node + linkType: hard + "cli-truncate@npm:^4.0.0": version: 4.0.0 resolution: "cli-truncate@npm:4.0.0" @@ -4608,6 +4626,13 @@ __metadata: languageName: node linkType: hard +"colors@npm:1.0.3": + version: 1.0.3 + resolution: "colors@npm:1.0.3" + checksum: 10c0/f9e40dd8b3e1a65378a7ced3fced15ddfd60aaf38e99a7521a7fdb25056b15e092f651cd0f5aa1e9b04fa8ce3616d094e07fc6c2bb261e24098db1ddd3d09a1d + languageName: node + linkType: hard + "combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" From 8f03c931c58f42e3dd92baf104d494cff9cfe380 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 26 Aug 2024 19:47:22 +0300 Subject: [PATCH 03/15] chore: return full json not just items --- src/commands/builds/ls.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index 1f99e506..787cdf6a 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -57,9 +57,8 @@ export class BuildLsCommand extends ApifyCommand { const builds = await client.actor(ctx.id).builds().list({ desc, limit, offset }); - // TODO: maybe return the full response object instead of just a list of builds? if (json) { - return builds.items; + return builds; } const actorInfo = (await client.actor(ctx.id).get())!; From aae36700f6598e237fcf1130848d3bb105c3a958 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 26 Aug 2024 19:50:49 +0300 Subject: [PATCH 04/15] chore: only mark these cmds as supporting json for now --- src/commands/builds/info.ts | 2 ++ src/commands/builds/ls.ts | 2 ++ src/lib/apify_command.ts | 2 -- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/builds/info.ts b/src/commands/builds/info.ts index 484e2b4a..d847439c 100644 --- a/src/commands/builds/info.ts +++ b/src/commands/builds/info.ts @@ -17,6 +17,8 @@ export class BuildInfoCommand extends ApifyCommand { }), }; + static override enableJsonFlag = true; + async run() { const { buildId } = this.args; diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index 787cdf6a..8d971117 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -38,6 +38,8 @@ export class BuildLsCommand extends ApifyCommand { }), }; + static override enableJsonFlag = true; + async run() { const { actor, desc, limit, offset, json } = this.flags; diff --git a/src/lib/apify_command.ts b/src/lib/apify_command.ts index b0059c28..341ce9da 100644 --- a/src/lib/apify_command.ts +++ b/src/lib/apify_command.ts @@ -15,8 +15,6 @@ export type ApifyArgs = Interfaces.InferredArgs extends Command { - static override enableJsonFlag = true; - protected telemetryData!: Record; protected flags!: ApifyFlags; From 6921eaad4be2236653fc1ab4b263f6d7511e42da Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 26 Aug 2024 19:53:18 +0300 Subject: [PATCH 05/15] chore: include actor username too --- src/commands/builds/info.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/builds/info.ts b/src/commands/builds/info.ts index d847439c..7d31f4da 100644 --- a/src/commands/builds/info.ts +++ b/src/commands/builds/info.ts @@ -53,7 +53,7 @@ export class BuildInfoCommand extends ApifyCommand { const message: string[] = [ // - `${chalk.yellow('Actor')}: ${actor?.name ?? 'Unknown Actor'} (${chalk.gray(build.actId)})`, + `${chalk.yellow('Actor')}: ${actor?.username ? `${actor.username}/` : ''}${actor?.name ?? 'unknown-actor'} (${chalk.gray(build.actId)})`, '', `${chalk.yellow('Build Information')} (ID: ${chalk.gray(build.id)})`, ` ${chalk.yellow('Build Number')}: ${build.buildNumber}${buildTag ? ` (tagged as ${chalk.yellow(buildTag)})` : ''}`, From b7bd90671360cc0118d29b3e2dab074060be4168 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 26 Aug 2024 21:18:31 +0300 Subject: [PATCH 06/15] feat: builds create --- src/commands/builds/create.ts | 129 ++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/commands/builds/create.ts diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts new file mode 100644 index 00000000..79704b0f --- /dev/null +++ b/src/commands/builds/create.ts @@ -0,0 +1,129 @@ +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; + +import { ApifyCommand } from '../../lib/apify_command.js'; +import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; +import { error, simpleLog } from '../../lib/outputs.js'; +import { getLoggedClientOrThrow, objectGroupBy, TimestampFormatter } from '../../lib/utils.js'; + +export class BuildsCreateCommand extends ApifyCommand { + static override description = 'Creates a new build of the Actor.'; + + static override flags = { + actor: Flags.string({ + description: + 'Optional Actor ID or Name to trigger a build for. By default, it will use the Actor from the current directory', + }), + tag: Flags.string({ + description: 'Build tag to be applied to the successful Actor build. By default, this is "latest"', + }), + version: Flags.string({ + description: + 'Optional Actor Version to build. By default, this will be inferred from the tag, but this flag is required when multiple versions have the same tag.', + required: false, + }), + }; + + static override enableJsonFlag = true; + + async run() { + const { actor, tag, version, json } = this.flags; + + const client = await getLoggedClientOrThrow(); + + const ctx = await resolveActorContext({ providedActorNameOrId: actor, client }); + + if (!ctx) { + error({ + message: + 'Unable to detect what Actor to list the builds for. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor="', + }); + + return; + } + + const actorInfo = (await client.actor(ctx.id).get())!; + + const versionsByBuildTag = objectGroupBy( + actorInfo.versions, + (actorVersion) => actorVersion.buildTag ?? 'latest', + ); + + const taggedVersions = versionsByBuildTag[tag ?? 'latest']; + const specificVersionExists = actorInfo.versions.find((v) => v.versionNumber === version); + + let selectedVersion: string | undefined; + let actualTag = tag; + + // --version takes precedence over tagged versions (but if --tag is also specified, it will be checked again) + if (specificVersionExists) { + // The API doesn't really care if the tag you use for a version is correct or not, just that the version exists. This means you CAN have two separate versions with the same tag + // but only the latest one that gets built will have the tag. + // The *console* lets you pick a version to build. Multiple versions can have the same default tag, ergo what was written above. + // The API *does* also let you tag any existing version with whatever you want. This is where we diverge, and follow semi-console-like behavior. Effectively, this one if check prevents you from doing + // "--version 0.1 --tag not_actually_the_tag", even if that is technically perfectly valid. Future reader of this code, if this is not wanted, nuke the if check. + + // This ensures that a --tag and --version match the version and tag the platform knows about + // but only when --tag is provided + if (tag && (!taggedVersions || !taggedVersions.some((v) => v.versionNumber === version))) { + error({ + message: `The Actor Version "${version}" does not have the tag "${tag}".`, + }); + + return; + } + + selectedVersion = version!; + actualTag = specificVersionExists.buildTag ?? 'latest'; + } else if (taggedVersions) { + selectedVersion = taggedVersions[0].versionNumber!; + actualTag = tag ?? 'latest'; + + if (taggedVersions.length > 1) { + if (!version) { + error({ + message: `Multiple Actor versions with the tag "${tag}" found. Please specify the version number using the "--version" flag.\n Available versions for this tag: ${taggedVersions.map((v) => chalk.yellow(v.versionNumber)).join(', ')}`, + }); + + return; + } + + // On second run, it will call the upper if check + } + } + + if (!selectedVersion) { + error({ + message: `No Actor versions with the tag "${tag}" found. You can push a new version with this tag by using "apify push --build-tag=${tag}"`, + }); + + return; + } + + const build = await client.actor(ctx.id).build(selectedVersion, { tag }); + + if (json) { + return build; + } + + const message: string[] = [ + `${chalk.yellow('Actor')}: ${actorInfo?.username ? `${actorInfo.username}/` : ''}${actorInfo?.name ?? 'unknown-actor'} (${chalk.gray(build.actId)})`, + ` ${chalk.yellow('Version')}: ${selectedVersion} (tagged with ${chalk.yellow(actualTag)})`, + '', + `${chalk.greenBright('Build Started')} (ID: ${chalk.gray(build.id)})`, + ` ${chalk.yellow('Build Number')}: ${build.buildNumber} (will get tagged once finished)`, + ` ${chalk.yellow('Started')}: ${TimestampFormatter.display(build.startedAt)}`, + '', + ]; + + const url = `https://console.apify.com/actors/${build.actId}/builds/${build.buildNumber}`; + + message.push(`${chalk.blue('View in Apify Console')}: ${url}`); + + simpleLog({ + message: message.join('\n'), + }); + + return undefined; + } +} From 12aed77892fe3438d55b1eecf7bd9117dbc56a66 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Tue, 27 Aug 2024 13:07:23 +0300 Subject: [PATCH 07/15] chore: more enhancements to build ls --- src/commands/builds/ls.ts | 80 ++++++++++++++++++++++++++++----------- src/lib/utils.ts | 26 ++++++++++++- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index 8d971117..d535a36e 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -6,15 +6,31 @@ import { ApifyCommand } from '../../lib/apify_command.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; import { error, simpleLog } from '../../lib/outputs.js'; -import { DurationFormatter, getLoggedClientOrThrow, objectGroupBy, TimestampFormatter } from '../../lib/utils.js'; +import { getLoggedClientOrThrow, objectGroupBy, ShortDurationFormatter } from '../../lib/utils.js'; -const tableFactory = () => - new Table<[string, string, string, string, string]>({ - head: ['Number', 'ID', 'Status', 'Started At', 'Finished At'], +const tableFactory = (compact = false) => { + const options: Record = { + head: ['Number', 'ID', 'Status', 'Took'], style: { - head: ['cyan', 'cyan', 'cyan', 'cyan', 'cyan'], + head: ['cyan', 'cyan', 'cyan', 'cyan'], + compact, }, - }); + }; + + if (compact) { + options.chars = { + 'mid': '', + 'left-mid': '', + 'mid-mid': '', + 'right-mid': '', + middle: ' ', + 'top-mid': '─', + 'bottom-mid': '─', + }; + } + + return new Table<[string, string, string, string]>(options); +}; export class BuildLsCommand extends ApifyCommand { static override description = 'Lists all builds of the actor.'; @@ -36,12 +52,17 @@ export class BuildLsCommand extends ApifyCommand { description: 'Sort builds in descending order.', default: false, }), + compact: Flags.boolean({ + description: 'Display a compact table.', + default: false, + char: 'c', + }), }; static override enableJsonFlag = true; async run() { - const { actor, desc, limit, offset, json } = this.flags; + const { actor, desc, limit, offset, compact, json } = this.flags; const client = await getLoggedClientOrThrow(); @@ -59,16 +80,6 @@ export class BuildLsCommand extends ApifyCommand { const builds = await client.actor(ctx.id).builds().list({ desc, limit, offset }); - if (json) { - return builds; - } - - const actorInfo = (await client.actor(ctx.id).get())!; - - simpleLog({ - message: `${chalk.reset('Showing')} ${chalk.yellow(builds.items.length)} out of ${chalk.yellow(builds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, - }); - const result = objectGroupBy(builds.items, (item) => { const versionNumber = Reflect.get(item, 'buildNumber') as string; @@ -77,6 +88,8 @@ export class BuildLsCommand extends ApifyCommand { return `${major}.${minor}`; }); + const actorInfo = (await client.actor(ctx.id).get())!; + const versionToTag = Object.entries(actorInfo.taggedBuilds ?? {}).reduce( (acc, [tag, data]) => { acc[data.buildNumber] = tag; @@ -86,6 +99,25 @@ export class BuildLsCommand extends ApifyCommand { {} as Record, ); + if (json) { + // Hydrate the builds with their tags + for (const build of builds.items) { + const buildNumber = Reflect.get(build, 'buildNumber') as string; + + const hasTag = versionToTag[buildNumber]; + + if (hasTag) { + Reflect.set(build, 'buildTag', hasTag); + } + } + + return builds; + } + + simpleLog({ + message: `${chalk.reset('Showing')} ${chalk.yellow(builds.items.length)} out of ${chalk.yellow(builds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, + }); + for (const [actorVersion, buildsForVersion] of Object.entries(result).sort((a, b) => a[0].localeCompare(b[0]), )) { @@ -99,26 +131,29 @@ export class BuildLsCommand extends ApifyCommand { const latestBuildGetsTaggedAs = actorInfo.versions.find((v) => v.versionNumber === actorVersion)?.buildTag; - const table = tableFactory(); + const table = tableFactory(compact); for (const build of buildsForVersion) { const buildNumber = Reflect.get(build, 'buildNumber') as string; const hasTag = versionToTag[buildNumber]; - const tableRow: [string, string, string, string, string] = [ + const tableRow: [string, string, string, string] = [ `${buildNumber}${hasTag ? ` (${chalk.yellow(hasTag)})` : ''}`, chalk.gray(build.id), prettyPrintStatus(build.status), - TimestampFormatter.display(build.startedAt), '', ]; if (build.finishedAt) { - tableRow[4] = TimestampFormatter.display(build.finishedAt); + const diff = build.finishedAt.getTime() - build.startedAt.getTime(); + + tableRow[3] = chalk.gray(`${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); } else { const diff = Date.now() - build.startedAt.getTime(); - tableRow[4] = chalk.gray(`Running for ${DurationFormatter.format(diff)}`); + tableRow[3] = chalk.gray( + `Running for ${ShortDurationFormatter.format(diff, undefined, { left: '' })}`, + ); } table.push(tableRow); @@ -132,6 +167,7 @@ export class BuildLsCommand extends ApifyCommand { simpleLog({ message: table.toString(), + stdout: true, }); simpleLog({ diff --git a/src/lib/utils.ts b/src/lib/utils.ts index aacf4658..ea4c05dc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -24,7 +24,7 @@ import { LOCAL_STORAGE_SUBDIRS, SOURCE_FILE_FORMATS, } from '@apify/consts'; -import { DurationFormatter as SapphireDurationFormatter } from '@sapphire/duration'; +import { DurationFormatter as SapphireDurationFormatter, TimeTypes } from '@sapphire/duration'; import { Timestamp } from '@sapphire/timestamp'; import AdmZip from 'adm-zip'; import _Ajv from 'ajv'; @@ -746,6 +746,30 @@ export const ensureApifyDirectory = (file: string) => { export const TimestampFormatter = new Timestamp('YYYY-MM-DD [at] HH:mm:ss'); export const DurationFormatter = new SapphireDurationFormatter(); +export const ShortDurationFormatter = new SapphireDurationFormatter({ + [TimeTypes.Day]: { + DEFAULT: 'd', + }, + [TimeTypes.Hour]: { + DEFAULT: 'h', + }, + [TimeTypes.Minute]: { + DEFAULT: 'm', + }, + [TimeTypes.Month]: { + DEFAULT: 'M', + }, + [TimeTypes.Second]: { + DEFAULT: 's', + }, + [TimeTypes.Week]: { + DEFAULT: 'w', + }, + [TimeTypes.Year]: { + DEFAULT: 'y', + }, +}); + /** * A "polyfill" for Object.groupBy */ From 3656c546b2b2f5bc777462120b4add26018d08ce Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 30 Aug 2024 16:14:54 +0300 Subject: [PATCH 08/15] chore: push --log flag --- src/commands/builds/create.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 79704b0f..8d591879 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/apify_command.js'; import { resolveActorContext } from '../../lib/commands/resolve-actor-context.js'; import { error, simpleLog } from '../../lib/outputs.js'; -import { getLoggedClientOrThrow, objectGroupBy, TimestampFormatter } from '../../lib/utils.js'; +import { getLoggedClientOrThrow, objectGroupBy, outputJobLog, TimestampFormatter } from '../../lib/utils.js'; export class BuildsCreateCommand extends ApifyCommand { static override description = 'Creates a new build of the Actor.'; @@ -22,12 +22,15 @@ export class BuildsCreateCommand extends ApifyCommand Date: Fri, 30 Aug 2024 16:17:10 +0300 Subject: [PATCH 09/15] chore: nicer log --- src/commands/builds/create.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 8d591879..bd9d33fe 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -120,8 +120,7 @@ export class BuildsCreateCommand extends ApifyCommand Date: Mon, 2 Sep 2024 16:03:26 +0300 Subject: [PATCH 10/15] chore: some requested changes --- src/commands/builds/ls.ts | 6 +++--- src/lib/commands/pretty-print-status.ts | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index d535a36e..6fc9a43b 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -118,9 +118,9 @@ export class BuildLsCommand extends ApifyCommand { message: `${chalk.reset('Showing')} ${chalk.yellow(builds.items.length)} out of ${chalk.yellow(builds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, }); - for (const [actorVersion, buildsForVersion] of Object.entries(result).sort((a, b) => - a[0].localeCompare(b[0]), - )) { + const sortedActorVersions = Object.entries(result).sort((a, b) => a[0].localeCompare(b[0])); + + for (const [actorVersion, buildsForVersion] of sortedActorVersions) { if (!buildsForVersion?.length) { simpleLog({ message: `No builds for version ${actorVersion}`, diff --git a/src/lib/commands/pretty-print-status.ts b/src/lib/commands/pretty-print-status.ts index f44ac4b0..5ee635a4 100644 --- a/src/lib/commands/pretty-print-status.ts +++ b/src/lib/commands/pretty-print-status.ts @@ -1,8 +1,7 @@ -// TIMED-OUT -> Timed Out - import type { ACTOR_JOB_STATUSES, ACTOR_JOB_TERMINAL_STATUSES } from '@apify/consts'; import chalk from 'chalk'; +// TIMED-OUT -> Timed Out // ABORTED -> Aborted export function prettyPrintStatus( status: (typeof ACTOR_JOB_STATUSES)[keyof typeof ACTOR_JOB_STATUSES] | (typeof ACTOR_JOB_TERMINAL_STATUSES)[number], From 3b44f38fb18073edc3e752375d60ea41449dd25c Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 2 Sep 2024 19:35:18 +0300 Subject: [PATCH 11/15] chore: basic tests --- .github/workflows/cucumber.yaml | 10 +- features/builds-namespace.feature.md | 119 +++++++++++++++++++ features/test-implementations/0.utils.ts | 12 ++ features/test-implementations/0.world.ts | 30 ++++- features/test-implementations/1.setup.ts | 101 +++++++++++++++- features/test-implementations/2.execution.ts | 105 +++++++++++++++- features/test-implementations/3.results.ts | 16 ++- features/test-implementations/README.md | 31 +++++ src/commands/builds/create.ts | 5 +- src/commands/builds/ls.ts | 5 +- src/lib/commands/resolve-actor-context.ts | 24 +++- 11 files changed, 437 insertions(+), 21 deletions(-) create mode 100644 features/builds-namespace.feature.md create mode 100644 features/test-implementations/0.utils.ts diff --git a/.github/workflows/cucumber.yaml b/.github/workflows/cucumber.yaml index 22ff6b85..358b02d7 100644 --- a/.github/workflows/cucumber.yaml +++ b/.github/workflows/cucumber.yaml @@ -2,6 +2,13 @@ name: Cucumber E2E tests on: workflow_dispatch: + push: + paths: + - "features/**" + # risky... but we trust our developers :finger_crossed: + pull_request: + paths: + - "features/**" jobs: make_salad: @@ -39,6 +46,5 @@ jobs: - name: Run Cucumber tests env: APIFY_CLI_DISABLE_TELEMETRY: 1 - # TODO: once we start writing tests that interact with Apify platform, just uncomment this line :salute: - # TEST_USER_TOKEN: ${{ secrets.APIFY_TEST_USER_API_TOKEN }} + TEST_USER_TOKEN: ${{ secrets.APIFY_TEST_USER_API_TOKEN }} run: yarn test:cucumber diff --git a/features/builds-namespace.feature.md b/features/builds-namespace.feature.md new file mode 100644 index 00000000..f912a2d8 --- /dev/null +++ b/features/builds-namespace.feature.md @@ -0,0 +1,119 @@ +# Feature: Builds namespace + +- As an Actor developer or user +- I want to be able to manage the builds of my actors on Apify Console +- In order to trigger new builds from the CLI, list them, and get details about them + +## Background: + +- Given my `pwd` is a fully initialized Actor project directory +- And the `actor.json` is valid +- And I am a logged in Apify Console User + +## Rule: Creating builds works + +### Example: calling create with invalid actor ID fails + +- When I run: + ``` + $ apify builds create --actor=invalid-id + ``` +- Then I can read text on stderr: + ``` + Actor with name or ID "invalid-id" was not found + ``` + +### Example: calling create from an unpublished actor directory fails + +- When I run: + ``` + $ apify builds create + ``` +- Then I can read text on stderr: + ``` + Actor with name "{{ testActorName }}" was not found + ``` + +### Example: calling create from a published actor directory works + +- Given the local Actor is pushed to the Apify platform +- When I run: + ``` + $ apify builds create + ``` +- Then I can read text on stderr: + ``` + Build Started + ``` +- And I can read text on stderr: + ``` + {{ testActorName}} + ``` + +### Example: calling create from a published actor with `--json` prints valid JSON data + +- Given the local Actor is pushed to the Apify platform +- When I run: + ``` + $ apify builds create --json + ``` + - Then I can read valid JSON on stdout + +## Rule: Printing information about builds works + +### Example: calling info with invalid build ID fails + +- When I run: + ``` + $ apify builds info invalid-id + ``` +- Then I can read text on stderr: + ``` + Build with ID "invalid-id" was not found + ``` + +### Example: calling info with valid build ID works + +- Given the local Actor is pushed to the Apify platform +- When I run: + ``` + $ apify builds create + ``` +- And I capture the build ID +- And I run with captured data: + ``` + $ apify builds info {{ buildId }} + ``` +- Then I can read text on stderr: + ``` + {{ testActorName }} + ``` + +### Example: calling info with valid build ID and `--json` prints valid JSON data + +- Given the local Actor is pushed to the Apify platform +- When I run: + ``` + $ apify builds create + ``` +- And I capture the build ID +- And I run with captured data: + ``` + $ apify builds info {{ buildId }} --json + ``` +- Then I can read valid JSON on stdout + +## Rule: Listing builds works + + + +### Example: calling list with --json prints valid JSON data + +- Given the local Actor is pushed to the Apify platform +- When I run: + ``` + $ apify builds ls --json + ``` +- Then I can read valid JSON on stdout + + diff --git a/features/test-implementations/0.utils.ts b/features/test-implementations/0.utils.ts new file mode 100644 index 00000000..e72cc316 --- /dev/null +++ b/features/test-implementations/0.utils.ts @@ -0,0 +1,12 @@ +export interface StringMatcherTemplate { + testActorName?: string; + buildId?: string; +} + +export function replaceMatchersInString(str: string, matchers: StringMatcherTemplate): string { + for (const [key, replaceValue] of Object.entries(matchers) as [keyof StringMatcherTemplate, string][]) { + str = str.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), replaceValue); + } + + return str; +} diff --git a/features/test-implementations/0.world.ts b/features/test-implementations/0.world.ts index 872f8a43..8bd432f9 100644 --- a/features/test-implementations/0.world.ts +++ b/features/test-implementations/0.world.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import type { IWorld } from '@cucumber/cucumber'; import { Result } from '@sapphire/result'; +import type { ApifyClient } from 'apify-client'; import { type Options, type ExecaError, type Result as ExecaResult, execaNode } from 'execa'; type DynamicOptions = { @@ -33,7 +34,14 @@ export interface TestWorld extends IWorld { * Input that should be provided to the command via stdin */ stdinInput?: string; + /** + * The name of the actor, used for matchers + */ + name?: string; }; + apifyClient?: ApifyClient; + authStatePath?: string; + capturedData?: Record; } /** @@ -59,7 +67,8 @@ export async function executeCommand({ rawCommand, stdin, cwd = TestTmpRoot, -}: { rawCommand: string; stdin?: string; cwd?: string | URL }) { + env, +}: { rawCommand: string; stdin?: string; cwd?: string | URL; env?: Record }) { // Step 0: ensure the command is executable -> strip out $, trim spaces const commandToRun = rawCommand.split('\n').map((str) => str.replace(/^\$/, '').trim()); @@ -86,6 +95,7 @@ export async function executeCommand({ const options: DynamicOptions = { cwd, + env, }; if (process.env.CUCUMBER_PRINT_EXEC) { @@ -143,7 +153,7 @@ export async function executeCommand({ export function assertWorldIsValid( world: TestWorld, -): asserts world is TestWorld & { testActor: { pwd: URL; initialized: true } } { +): asserts world is TestWorld & { testActor: { pwd: URL; initialized: true; name: string } } { if (!world.testActor || !world.testActor.initialized) { throw new RangeError( 'Test actor must be initialized before running any subsequent background requirements. You may have the order of your steps wrong. The "Given my `pwd` is a fully initialized Actor project directory" step needs to run before this step', @@ -188,6 +198,20 @@ export function assertWorldHasRunResult(world: TestWorld): asserts world is Test } } +export function assertWorldIsLoggedIn(world: TestWorld): asserts world is TestWorld & { apifyClient: ApifyClient } { + if (!world.apifyClient) { + throw new RangeError('You must run the "Given a logged in Apify Console user" step before running this step'); + } +} + +export function assertWorldHasCapturedData(world: TestWorld): asserts world is TestWorld & { + capturedData: Record; +} { + if (!world.capturedData) { + throw new RangeError(`You must run the "I capture the ID" step before running this step`); + } +} + export async function getActorRunResults(world: TestWorld & { testActor: { pwd: URL; initialized: true } }) { const startedPath = new URL('./storage/key_value_stores/default/STARTED.json', world.testActor.pwd); const inputPath = new URL('./storage/key_value_stores/default/RECEIVED_INPUT.json', world.testActor.pwd); @@ -206,7 +230,7 @@ export async function getActorRunResults(world: TestWorld & { testActor: { pwd: } return { - started: parsed, + started: parsed as 'works', receivedInput: receivedInput ? JSON.parse(receivedInput) : null, }; } diff --git a/features/test-implementations/1.setup.ts b/features/test-implementations/1.setup.ts index 7e170ea0..e214556b 100644 --- a/features/test-implementations/1.setup.ts +++ b/features/test-implementations/1.setup.ts @@ -2,21 +2,54 @@ import { randomBytes } from 'node:crypto'; import { readFile, rm, writeFile } from 'node:fs/promises'; import { AfterAll, Given, setDefaultTimeout } from '@cucumber/cucumber'; - -import { assertWorldIsValid, executeCommand, getActorRunResults, TestTmpRoot, type TestWorld } from './0.world'; +import { ApifyClient } from 'apify-client'; + +import { + assertWorldIsLoggedIn, + assertWorldIsValid, + executeCommand, + getActorRunResults, + TestTmpRoot, + type TestWorld, +} from './0.world'; +import { getApifyClientOptions } from '../../src/lib/utils'; setDefaultTimeout(20_000); const createdActors: URL[] = []; +const pushedActorIds: string[] = []; +let globalClient: ApifyClient; if (!process.env.DO_NOT_DELETE_CUCUMBER_TEST_ACTORS) { AfterAll(async () => { - console.log(`\n Cleaning up actors for worker ${process.env.CUCUMBER_WORKER_ID}...`); + if (!createdActors.length) { + return; + } + + console.log(`\n Cleaning up Actors for worker ${process.env.CUCUMBER_WORKER_ID}...`); for (const path of createdActors) { await rm(path, { recursive: true, force: true }); } }); + + AfterAll(async () => { + if (!pushedActorIds.length) { + return; + } + + console.log(`\n Cleaning up pushed Actors for worker ${process.env.CUCUMBER_WORKER_ID}...`); + + const me = await globalClient.user('me').get(); + + for (const id of pushedActorIds) { + try { + await globalClient.actor(`${me.username}/${id}`).delete(); + } catch (err) { + console.error(`Failed to delete Actor ${id}: ${(err as Error).message}`); + } + } + }); } const actorJs = await readFile(new URL('./0.basic-actor.js', import.meta.url), 'utf8'); @@ -40,6 +73,7 @@ Given(/my `?pwd`? is a fully initialized actor project directory/i, { if (result.isOk()) { this.testActor.pwd = new URL(`./${actorName}/`, TestTmpRoot); this.testActor.initialized = true; + this.testActor.name = actorName; createdActors.push(this.testActor.pwd); @@ -152,3 +186,64 @@ Given( await writeFile(file, jsonValue); }, ); + +Given(/logged in apify console user/i, async function () { + if (!process.env.TEST_USER_TOKEN) { + throw new Error('No test user token provided'); + } + + // Try to make the client with the token + const client = new ApifyClient(getApifyClientOptions(process.env.TEST_USER_TOKEN)); + + try { + await client.user('me').get(); + } catch (err) { + throw new Error(`Failed to get user information: ${(err as Error).message}`); + } + + // Login with the CLI too + + const authStatePath = `cucumber-${randomBytes(12).toString('hex')}`; + + const result = await executeCommand({ + rawCommand: `apify login --token ${process.env.TEST_USER_TOKEN}`, + env: { + // Keep in sync with GLOBAL_CONFIGS_FOLDER in consts.ts + __APIFY_INTERNAL_TEST_AUTH_PATH__: authStatePath, + }, + }); + + // This will throw if there was an error + result.unwrap(); + + this.apifyClient = client; + this.authStatePath = authStatePath; + + // We need it for later cleanup + globalClient = client; +}); + +Given(/the local actor is pushed to the Apify platform/i, { timeout: 240_000 }, async function () { + assertWorldIsValid(this); + assertWorldIsLoggedIn(this); + + const extraEnv: Record = {}; + + if (this.authStatePath) { + // eslint-disable-next-line no-underscore-dangle + extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; + } + + const result = await executeCommand({ + rawCommand: 'apify push --no-prompt', + cwd: this.testActor.pwd, + env: extraEnv, + }); + + if (result.isOk()) { + pushedActorIds.push(this.testActor.name); + } else { + // This throws on errors + result.unwrap(); + } +}); diff --git a/features/test-implementations/2.execution.ts b/features/test-implementations/2.execution.ts index d78135d1..859a9ddf 100644 --- a/features/test-implementations/2.execution.ts +++ b/features/test-implementations/2.execution.ts @@ -1,18 +1,119 @@ import { When } from '@cucumber/cucumber'; -import { assertWorldIsValid, executeCommand, getActorRunResults, type TestWorld } from './0.world'; +import { replaceMatchersInString } from './0.utils'; +import { + assertWorldHasCapturedData, + assertWorldHasRanCommand, + assertWorldIsValid, + executeCommand, + getActorRunResults, + type TestWorld, +} from './0.world'; -When(/i run/i, async function (commandBlock: string) { +When(/i run:?$/i, async function (commandBlock: string) { assertWorldIsValid(this); if (typeof commandBlock !== 'string') { throw new TypeError('When using the `I run` step, you must provide a text block containing a command'); } + const extraEnv: Record = {}; + + if (this.authStatePath) { + // eslint-disable-next-line no-underscore-dangle + extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; + } + const result = await executeCommand({ rawCommand: commandBlock, cwd: this.testActor.pwd, stdin: this.testActor.stdinInput, + env: extraEnv, + }); + + const runResults = await getActorRunResults(this).catch(() => null); + + if (result.isOk()) { + const value = result.unwrap(); + + if (this.testResults) { + console.error(`\n Warning: Overwriting existing test results: ${JSON.stringify(this.testResults)}`); + } + + this.testResults = { + exitCode: value.exitCode!, + stderr: value.stderr, + stdout: value.stdout, + runResults, + }; + } else { + const error = result.unwrapErr(); + + if (this.testResults) { + console.error(`\n Warning: Overwriting existing test results: ${JSON.stringify(this.testResults)}`); + } + + this.testResults = { + exitCode: error.exitCode!, + stderr: error.stderr, + stdout: error.stdout, + runResults, + }; + } +}); + +When(/i capture the (build) id/i, function (match: string) { + assertWorldIsValid(this); + assertWorldHasRanCommand(this); + + this.capturedData ??= {}; + + switch (match.toLowerCase()) { + case 'build': { + const buildId = + this.testResults?.stderr.match(/Build Started \(ID: (\w+)\)/)?.[1] ?? + // Try stdout as well, as it might be there + this.testResults?.stdout.match(/Build Started \(ID: (\w+)\)/)?.[1]; + + if (!buildId) { + throw new Error('Failed to capture build ID'); + } + + this.capturedData.buildId = buildId; + + break; + } + + default: { + throw new TypeError(`Unhandled capture match type: ${match}`); + } + } +}); + +When(/i run with captured data/i, async function (commandBlock: string) { + assertWorldIsValid(this); + assertWorldHasCapturedData(this); + + if (typeof commandBlock !== 'string') { + throw new TypeError( + 'When using the `I run with captured data` step, you must provide a text block containing a command', + ); + } + + const extraEnv: Record = {}; + + if (this.authStatePath) { + // eslint-disable-next-line no-underscore-dangle + extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath; + } + + const result = await executeCommand({ + rawCommand: replaceMatchersInString(commandBlock, { + buildId: this.capturedData.buildId, + }), + cwd: this.testActor.pwd, + stdin: this.testActor.stdinInput, + env: extraEnv, }); const runResults = await getActorRunResults(this).catch(() => null); diff --git a/features/test-implementations/3.results.ts b/features/test-implementations/3.results.ts index 6eaacbf2..859b53c6 100644 --- a/features/test-implementations/3.results.ts +++ b/features/test-implementations/3.results.ts @@ -2,6 +2,7 @@ import { deepStrictEqual, notStrictEqual, strictEqual } from 'node:assert'; import { Then } from '@cucumber/cucumber'; +import { replaceMatchersInString } from './0.utils'; import { assertWorldHasRanCommand, assertWorldHasRunResult, assertWorldIsValid, type TestWorld } from './0.world'; Then(/the local run has an input json/i, function (jsonBlock: string) { @@ -86,7 +87,9 @@ Then(/i can read text on stderr/i, function (expectedStdout: string) } const lowercasedResult = this.testResults.stderr.toLowerCase(); - const lowercasedExpected = expectedStdout.toLowerCase(); + const lowercasedExpected = replaceMatchersInString(expectedStdout, { + testActorName: this.testActor.name, + }).toLowerCase(); strictEqual( lowercasedResult.includes(lowercasedExpected), @@ -94,3 +97,14 @@ Then(/i can read text on stderr/i, function (expectedStdout: string) `Expected to find "${lowercasedExpected}" in "${lowercasedResult}"`, ); }); + +Then(/I can read valid JSON on stdout/i, function () { + assertWorldIsValid(this); + assertWorldHasRanCommand(this); + + try { + JSON.parse(this.testResults.stdout); + } catch { + throw new Error(`Expected valid JSON on stdout, but got: ${this.testResults.stdout}`); + } +}); diff --git a/features/test-implementations/README.md b/features/test-implementations/README.md index a5ac52b1..bbd68a3e 100644 --- a/features/test-implementations/README.md +++ b/features/test-implementations/README.md @@ -18,6 +18,21 @@ This file also includes methods that are shared across all layers of the test ca Contains the basic source code actors run in the tests. If this file requires more specific logic, newer versions can be created and referenced in the [Setup](#setup-1setupts) file. +## Utilities (`0.utils.ts`) + +Contains random utilities that can be used across all stages of a test case. + +### Replacers + +Certain tests may want to reference values from the test actor (for instance checking that the actor name is mentioned in the output). To make this easier, we have a replacer system that can replace certain strings with values from the test actor. + +The syntax to use is `{{}}`, where `replacer` is the name of the replacer as listed below. You can add in whitespaces between the braces and the replacer name if you wish. + +The following replacers are implemented: + +- `testActorName`: the name of the test actor (same as `name` in `.actor/actor.json`) +- `buildId`: the ID of the build that was created, when the `I capture the build ID` step is ran + ## Setup (`1.setup.ts`) This file is intended to setup the world: gather any arguments, prepare an actor to test, setup the input, prepare the stdin input, etc. This is where you configure all `Given` steps. @@ -46,6 +61,10 @@ Currently, the following phrases are implemented: """ ``` - This step supports providing input to the CLI via a file. +- `given a logged in apify console user` + - This step ensures the test is ran assuming a logged in user on the Apify console. +- `given the local actor is pushed to the apify platform` + - This step ensures the test is ran assuming the actor is pushed to the Apify platform. Certain phrases may require a specific order to be executed in. Errors will be thrown accordingly if the order is not followed. @@ -65,6 +84,16 @@ Currently, the following phrases are implemented: ``` - This step supports running only CLI commands. It also expects only **one** command to be ran. Any more than one command will result in an error (this is done for simplicity sake) - When referring to the CLI, you should mention the `apify` binary (as if you'd write this in a terminal). For testing sake, when we actually run the command, it will instead call the `tsx ./bin/dev.js` script, meaning changes that you do to the CLI will be reflected in the tests without needing a rebuild. +- `when i capture the id` + - Example: + ``` + When I capture the build ID + ``` + - This step captures the ID of the specified type and stores it in the world. This is useful for checking the output of the CLI, as some variables may be needed to check the output of the actor run. + - Currently, the following types are implemented: + - `build`: captures the ID of the build that was created +- `when i run with captured data` + - Identical to `when I run`, with the only difference being that you can use it in conjunction with `when i capture the id` to run the CLI with the captured data. ## Results (`3.results.ts`) @@ -108,3 +137,5 @@ Currently, the following phrases are implemented: `​`​` ``` - This step checks if the text provided is in the stderr output. If the text is not in the stderr output, an error will be thrown. +- `then i can read valid json on stdout` + - This step checks if the stdout output is valid JSON. If the stdout output is not valid JSON, an error will be thrown. diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index bd9d33fe..9064fdf4 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -36,10 +36,9 @@ export class BuildsCreateCommand extends ApifyCommand"', + message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor="`, }); return; diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index 6fc9a43b..625aa70c 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -69,10 +69,9 @@ export class BuildLsCommand extends ApifyCommand { // TODO: technically speaking, we don't *need* an actor id to list builds. But it makes more sense to have a table of builds for a specific actor. const ctx = await resolveActorContext({ providedActorNameOrId: actor, client }); - if (!ctx) { + if (!ctx.valid) { error({ - message: - 'Unable to detect what Actor to list the builds for. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor="', + message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor="`, }); return; diff --git a/src/lib/commands/resolve-actor-context.ts b/src/lib/commands/resolve-actor-context.ts index d7fe1000..115e2788 100644 --- a/src/lib/commands/resolve-actor-context.ts +++ b/src/lib/commands/resolve-actor-context.ts @@ -22,10 +22,14 @@ export async function resolveActorContext({ if (providedActorNameOrId?.includes('/')) { const actor = await client.actor(providedActorNameOrId).get(); if (!actor) { - return null; + return { + valid: false as const, + reason: `Actor with ID "${providedActorNameOrId}" was not found`, + }; } return { + valid: true as const, userFriendlyId: `${actor.username}/${actor.name}`, id: actor.id, }; @@ -37,6 +41,7 @@ export async function resolveActorContext({ if (actorById) { return { + valid: true as const, userFriendlyId: `${actorById.username}/${actorById.name}`, id: actorById.id, }; @@ -46,26 +51,37 @@ export async function resolveActorContext({ if (actorByName) { return { + valid: true as const, userFriendlyId: `${actorByName.username}/${actorByName.name}`, id: actorByName.id, }; } - return null; + return { + valid: false as const, + reason: `Actor with name or ID "${providedActorNameOrId}" was not found`, + }; } if (localConfig.name) { const actor = await client.actor(`${usernameOrId}/${localConfig.name}`).get(); if (!actor) { - return null; + return { + valid: false as const, + reason: `Actor with name "${localConfig.name}" was not found`, + }; } return { + valid: true as const, userFriendlyId: `${actor.username}/${actor.name}`, id: actor.id, }; } - return null; + return { + valid: false as const, + reason: 'Unable to detect what Actor to create a build for', + }; } From e05095622db475c342fd0829af1d7ed7f5d6f382 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Fri, 6 Sep 2024 15:21:01 +0300 Subject: [PATCH 12/15] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jindřich Bär --- src/commands/builds/create.ts | 6 +++--- src/commands/builds/info.ts | 6 +++--- src/commands/builds/log.ts | 6 +++--- src/commands/builds/ls.ts | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 9064fdf4..3a889363 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -12,10 +12,10 @@ export class BuildsCreateCommand extends ApifyCommand { - static override description = 'Prints information about a specific build'; + static override description = 'Prints information about a specific build.'; static override args = { buildId: Args.string({ required: true, - description: 'The build id to get information about', + description: 'The build ID to get information about.', }), }; @@ -27,7 +27,7 @@ export class BuildInfoCommand extends ApifyCommand { const build = await apifyClient.build(buildId).get(); if (!build) { - error({ message: `Build with ID "${buildId}" was not found on your account` }); + error({ message: `Build with ID "${buildId}" was not found on your account.` }); return; } diff --git a/src/commands/builds/log.ts b/src/commands/builds/log.ts index 7a1d6e75..76f29296 100644 --- a/src/commands/builds/log.ts +++ b/src/commands/builds/log.ts @@ -5,12 +5,12 @@ import { error, info } from '../../lib/outputs.js'; import { getLoggedClientOrThrow, outputJobLog } from '../../lib/utils.js'; export class BuildLogCommand extends ApifyCommand { - static override description = 'Prints the log of a specific build'; + static override description = 'Prints the log of a specific build.'; static override args = { buildId: Args.string({ required: true, - description: 'The build id to get the log from', + description: 'The build ID to get the log from.', }), }; @@ -22,7 +22,7 @@ export class BuildLogCommand extends ApifyCommand { const build = await apifyClient.build(buildId).get(); if (!build) { - error({ message: `Build with ID "${buildId}" was not found on your account` }); + error({ message: `Build with ID "${buildId}" was not found on your account.` }); return; } diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index 625aa70c..d489f52e 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -38,7 +38,7 @@ export class BuildLsCommand extends ApifyCommand { static override flags = { actor: Flags.string({ description: - 'Optional Actor ID or Name to list builds for. By default, it will use the Actor from the current directory', + 'Optional Actor ID or Name to list builds for. By default, it will use the Actor from the current directory.', }), offset: Flags.integer({ description: 'Number of builds that will be skipped.', @@ -71,7 +71,7 @@ export class BuildLsCommand extends ApifyCommand { if (!ctx.valid) { error({ - message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor="`, + message: `${ctx.reason}. Please run this command in an Actor directory, or specify the Actor ID by running this command with "--actor=".`, }); return; From f978bd91a6543d175047daf53fe13d1505a5990e Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 9 Sep 2024 11:26:41 +0300 Subject: [PATCH 13/15] chore: attempt to split up ls more --- src/commands/builds/info.ts | 14 +++-- src/commands/builds/ls.ts | 114 +++++++++++++++++++++--------------- 2 files changed, 75 insertions(+), 53 deletions(-) diff --git a/src/commands/builds/info.ts b/src/commands/builds/info.ts index 27fb3f97..53abb186 100644 --- a/src/commands/builds/info.ts +++ b/src/commands/builds/info.ts @@ -49,16 +49,20 @@ export class BuildInfoCommand extends ApifyCommand { } } + // TODO: untyped field, https://github.com/apify/apify-client-js/issues/526 const exitCode = Reflect.get(build, 'exitCode') as number | undefined; + const fullActorName = actor?.username ? `${actor.username}/${actor.name}` : actor?.name ?? 'unknown-actor'; + const versionTaggedAs = buildTag ? ` (tagged as ${chalk.yellow(buildTag)})` : ''; + const exitCodeStatus = typeof exitCode !== 'undefined' ? ` (exit code: ${chalk.gray(exitCode)})` : ''; + const message: string[] = [ // - `${chalk.yellow('Actor')}: ${actor?.username ? `${actor.username}/` : ''}${actor?.name ?? 'unknown-actor'} (${chalk.gray(build.actId)})`, + `${chalk.yellow('Actor')}: ${fullActorName} (${chalk.gray(build.actId)})`, '', `${chalk.yellow('Build Information')} (ID: ${chalk.gray(build.id)})`, - ` ${chalk.yellow('Build Number')}: ${build.buildNumber}${buildTag ? ` (tagged as ${chalk.yellow(buildTag)})` : ''}`, - // exitCode is also not typed... - ` ${chalk.yellow('Status')}: ${prettyPrintStatus(build.status)}${typeof exitCode !== 'undefined' ? ` (exit code: ${chalk.gray(exitCode)})` : ''}`, + ` ${chalk.yellow('Build Number')}: ${build.buildNumber}${versionTaggedAs}`, + ` ${chalk.yellow('Status')}: ${prettyPrintStatus(build.status)}${exitCodeStatus}`, ` ${chalk.yellow('Started')}: ${TimestampFormatter.display(build.startedAt)}`, ]; @@ -78,7 +82,7 @@ export class BuildInfoCommand extends ApifyCommand { message.push(` ${chalk.yellow('Compute Units')}: ${build.stats.computeUnits.toFixed(3)}`); } - // Untyped field again 😢 + // TODO: untyped field, https://github.com/apify/apify-client-js/issues/526 const dockerImageSize = Reflect.get(build.stats ?? {}, 'imageSizeBytes') as number | undefined; if (dockerImageSize) { diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index d489f52e..c320595c 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import type { BuildCollectionClientListItem } from 'apify-client'; import chalk from 'chalk'; import Table from 'cli-table'; @@ -77,9 +78,10 @@ export class BuildLsCommand extends ApifyCommand { return; } - const builds = await client.actor(ctx.id).builds().list({ desc, limit, offset }); + const allBuilds = await client.actor(ctx.id).builds().list({ desc, limit, offset }); + const actorInfo = (await client.actor(ctx.id).get())!; - const result = objectGroupBy(builds.items, (item) => { + const buildsByActorVersion = objectGroupBy(allBuilds.items, (item) => { const versionNumber = Reflect.get(item, 'buildNumber') as string; const [major, minor] = versionNumber.split('.'); @@ -87,9 +89,7 @@ export class BuildLsCommand extends ApifyCommand { return `${major}.${minor}`; }); - const actorInfo = (await client.actor(ctx.id).get())!; - - const versionToTag = Object.entries(actorInfo.taggedBuilds ?? {}).reduce( + const buildTagToActorVersion = Object.entries(actorInfo.taggedBuilds ?? {}).reduce( (acc, [tag, data]) => { acc[data.buildNumber] = tag; @@ -100,24 +100,25 @@ export class BuildLsCommand extends ApifyCommand { if (json) { // Hydrate the builds with their tags - for (const build of builds.items) { + for (const build of allBuilds.items) { + // TODO: untyped field, https://github.com/apify/apify-client-js/issues/526 const buildNumber = Reflect.get(build, 'buildNumber') as string; - const hasTag = versionToTag[buildNumber]; + const hasTag = buildTagToActorVersion[buildNumber]; if (hasTag) { Reflect.set(build, 'buildTag', hasTag); } } - return builds; + return allBuilds; } simpleLog({ - message: `${chalk.reset('Showing')} ${chalk.yellow(builds.items.length)} out of ${chalk.yellow(builds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, + message: `${chalk.reset('Showing')} ${chalk.yellow(allBuilds.items.length)} out of ${chalk.yellow(allBuilds.total)} builds for Actor ${chalk.yellow(ctx.userFriendlyId)} (${chalk.gray(ctx.id)})\n`, }); - const sortedActorVersions = Object.entries(result).sort((a, b) => a[0].localeCompare(b[0])); + const sortedActorVersions = Object.entries(buildsByActorVersion).sort((a, b) => a[0].localeCompare(b[0])); for (const [actorVersion, buildsForVersion] of sortedActorVersions) { if (!buildsForVersion?.length) { @@ -128,52 +129,69 @@ export class BuildLsCommand extends ApifyCommand { continue; } - const latestBuildGetsTaggedAs = actorInfo.versions.find((v) => v.versionNumber === actorVersion)?.buildTag; - - const table = tableFactory(compact); - - for (const build of buildsForVersion) { - const buildNumber = Reflect.get(build, 'buildNumber') as string; - - const hasTag = versionToTag[buildNumber]; - - const tableRow: [string, string, string, string] = [ - `${buildNumber}${hasTag ? ` (${chalk.yellow(hasTag)})` : ''}`, - chalk.gray(build.id), - prettyPrintStatus(build.status), - '', - ]; - - if (build.finishedAt) { - const diff = build.finishedAt.getTime() - build.startedAt.getTime(); + const latestBuildTag = actorInfo.versions.find((v) => v.versionNumber === actorVersion)?.buildTag; + const table = this.generateTableForActorVersion({ + buildsForVersion, + compact, + buildTagToActorVersion, + }); - tableRow[3] = chalk.gray(`${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); - } else { - const diff = Date.now() - build.startedAt.getTime(); - tableRow[3] = chalk.gray( - `Running for ${ShortDurationFormatter.format(diff, undefined, { left: '' })}`, - ); - } + const latestBuildTagMessage = latestBuildTag + ? ` (latest build gets tagged with ${chalk.yellow(latestBuildTag)})` + : ''; - table.push(tableRow); - } + const message = [ + chalk.reset(`Builds for Actor Version ${chalk.yellow(actorVersion)}${latestBuildTagMessage}`), + table.toString(), + '', + ]; simpleLog({ - message: chalk.reset( - `Builds for Actor Version ${chalk.yellow(actorVersion)}${latestBuildGetsTaggedAs ? ` (latest build gets tagged with ${chalk.yellow(latestBuildGetsTaggedAs)})` : ''}`, - ), + message: message.join('\n'), }); + } - simpleLog({ - message: table.toString(), - stdout: true, - }); + return undefined; + } - simpleLog({ - message: '', - }); + private generateTableForActorVersion({ + compact, + buildsForVersion, + buildTagToActorVersion, + }: { + compact: boolean; + buildsForVersion: BuildCollectionClientListItem[]; + buildTagToActorVersion: Record; + }) { + const table = tableFactory(compact); + + for (const build of buildsForVersion) { + // TODO: untyped field, https://github.com/apify/apify-client-js/issues/526 + const buildNumber = Reflect.get(build, 'buildNumber') as string; + + const hasTag = buildTagToActorVersion[buildNumber] + ? ` (${chalk.yellow(buildTagToActorVersion[buildNumber])})` + : ''; + + const tableRow: [string, string, string, string] = [ + `${buildNumber}${hasTag}`, + chalk.gray(build.id), + prettyPrintStatus(build.status), + '', + ]; + + if (build.finishedAt) { + const diff = build.finishedAt.getTime() - build.startedAt.getTime(); + + tableRow[3] = chalk.gray(`${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); + } else { + const diff = Date.now() - build.startedAt.getTime(); + tableRow[3] = chalk.gray(`Running for ${ShortDurationFormatter.format(diff, undefined, { left: '' })}`); + } + + table.push(tableRow); } - return undefined; + return table; } } From f8b67c091545d290ad2ef11b7fb51da3d02f2230 Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 9 Sep 2024 11:33:16 +0300 Subject: [PATCH 14/15] chore: disable pr for now (will be handled in future PR) --- .github/workflows/cucumber.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cucumber.yaml b/.github/workflows/cucumber.yaml index 358b02d7..fd305f08 100644 --- a/.github/workflows/cucumber.yaml +++ b/.github/workflows/cucumber.yaml @@ -6,9 +6,9 @@ on: paths: - "features/**" # risky... but we trust our developers :finger_crossed: - pull_request: - paths: - - "features/**" + # pull_request: + # paths: + # - "features/**" jobs: make_salad: From 0ec21c00c4dacf4e9af431d81af30759204476af Mon Sep 17 00:00:00 2001 From: Vlad Frangu Date: Mon, 9 Sep 2024 13:32:17 +0300 Subject: [PATCH 15/15] docs: correct Actor spelling --- src/commands/builds/ls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/builds/ls.ts b/src/commands/builds/ls.ts index c320595c..05475263 100644 --- a/src/commands/builds/ls.ts +++ b/src/commands/builds/ls.ts @@ -34,7 +34,7 @@ const tableFactory = (compact = false) => { }; export class BuildLsCommand extends ApifyCommand { - static override description = 'Lists all builds of the actor.'; + static override description = 'Lists all builds of the Actor.'; static override flags = { actor: Flags.string({