diff --git a/src/commands/actors/call.ts b/src/commands/actors/call.ts index e5f31e25d..a5fa6eb12 100644 --- a/src/commands/actors/call.ts +++ b/src/commands/actors/call.ts @@ -15,9 +15,10 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { ApifyCommand, StdinMode } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { consoleRunUrl } from '../../lib/commands/agent-output.js'; import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; -import { finalizeRun, runUrl } from '../../lib/commands/run-result.js'; +import { finalizeRun } from '../../lib/commands/run-result.js'; import { CommandExitCodes, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { error, simpleLog } from '../../lib/outputs.js'; import { getLocalConfig, getLocalUserInfo, getLoggedClientOrThrow, TimestampFormatter } from '../../lib/utils.js'; @@ -176,7 +177,7 @@ export class ActorsCallCommand extends ApifyCommand { // A *lot* is copied from `runs info` if (!this.flags.silent) { - const url = runUrl(actorId, yieldedRun.id); + const url = consoleRunUrl(actorId, yieldedRun.id); const message: string[] = [`${chalk.yellow('Started')}: ${TimestampFormatter.display(yieldedRun.startedAt)}`]; diff --git a/src/commands/actors/push.ts b/src/commands/actors/push.ts index 683462834..4908131bc 100644 --- a/src/commands/actors/push.ts +++ b/src/commands/actors/push.ts @@ -2,21 +2,17 @@ import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { join, resolve } from 'node:path'; import process from 'node:process'; -import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions } from 'apify-client'; +import type { Actor, ActorCollectionCreateOptions, ActorDefaultRunOptions, Build } from 'apify-client'; import open from 'open'; import { fetchManifest } from '@apify/actor-templates'; -import { - ACTOR_JOB_STATUSES, - ACTOR_JOB_TERMINAL_STATUSES, - ACTOR_SOURCE_TYPES, - MAX_MULTIFILE_BYTES, -} from '@apify/consts'; +import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES, ACTOR_SOURCE_TYPES, MAX_MULTIFILE_BYTES } from '@apify/consts'; import { createHmacSignature } from '@apify/utilities'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { isTerminalStatus } from '../../lib/commands/agent-output.js'; import { CommandExitCodes, DEPRECATED_LOCAL_CONFIG_NAME, LOCAL_CONFIG_PATH } from '../../lib/consts.js'; import { sumFilesSizeInBytes } from '../../lib/files.js'; import { useAbortJobOnSignal } from '../../lib/hooks/useAbortJobOnSignal.js'; @@ -441,7 +437,7 @@ Skipping push. Use --force to override.`, // share one budget. Without this, a log stream that dies near the cap // would let the poll loop wait another full --wait-for-finish on top. const deadline = waitForFinishMillis === undefined ? Infinity : Date.now() + waitForFinishMillis; - let build = await actorClient.build(version, { + let build: Build | undefined = await actorClient.build(version, { useCache: true, waitForFinish: 2, // NOTE: We need to wait some time to Apify open stream and we can create connection }); @@ -451,7 +447,7 @@ Skipping push. Use --force to override.`, // build doesn't keep running after the user gives up waiting. using _signalHandler = useAbortJobOnSignal({ apifyClient, - kind: 'build', + kind: ACTOR_JOB_TYPES.BUILD, jobId: build.id, }); @@ -463,18 +459,18 @@ Skipping push. Use --force to override.`, console.error(err); } - const refreshedBuild = await apifyClient.build(build.id).get(); - if (!refreshedBuild) { - error({ message: `Could not fetch build with ID "${build.id}" after deployment.` }); + const buildId = build.id; + build = await apifyClient.build(buildId).get(); + if (!build) { + error({ message: `Could not fetch build with ID "${buildId}" after deployment.` }); process.exitCode = CommandExitCodes.BuildFailed; return; } - build = refreshedBuild; // `outputJobLog` can return before the build is actually terminal (stream // ended early, timeout hit). Poll the remaining budget so the status // branches below see the real outcome. - while (!ACTOR_JOB_TERMINAL_STATUSES.includes(build.status as never) && Date.now() < deadline) { + while (!isTerminalStatus(build.status) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 1000)); build = (await apifyClient.build(build.id).get())!; } @@ -507,7 +503,7 @@ Skipping push. Use --force to override.`, const outcome = resolvePushOutcome(buildStatus); const actorUrl = `https://console.apify.com${redirectUrlPart}/actors/${build.actId}`; - const buildUrl = `${actorUrl}#/builds/${build.buildNumber}`; + const buildUrl = `${actorUrl}/builds/${build.buildNumber}`; // Surface the tail of the build log as the failure reason. Best-effort: // the build status already conveys the outcome if the log can't be read. diff --git a/src/commands/actors/start.ts b/src/commands/actors/start.ts index 1ddf99b64..30adcadb3 100644 --- a/src/commands/actors/start.ts +++ b/src/commands/actors/start.ts @@ -6,7 +6,7 @@ import chalk from 'chalk'; import { ApifyCommand, StdinMode } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; -import { consoleRunUrl } from '../../lib/commands/agent-output.js'; +import { consoleActorUrl, consoleRunUrl } from '../../lib/commands/agent-output.js'; import { getInputOverride } from '../../lib/commands/resolve-input.js'; import { runActorOrTaskOnCloud, SharedRunOnCloudFlags } from '../../lib/commands/run-on-cloud.js'; import { LOCAL_CONFIG_PATH } from '../../lib/consts.js'; @@ -141,7 +141,7 @@ export class ActorsStartCommand extends ApifyCommand waited: false, actor: { id: actorId, - url: `https://console.apify.com/actors/${actorId}`, + url: consoleActorUrl(actorId), }, run: { id: run.id, diff --git a/src/commands/builds/create.ts b/src/commands/builds/create.ts index 7e386b6ce..bc988d0d1 100644 --- a/src/commands/builds/create.ts +++ b/src/commands/builds/create.ts @@ -2,6 +2,8 @@ import process from 'node:process'; import chalk from 'chalk'; +import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES } from '@apify/consts'; + import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; @@ -157,7 +159,7 @@ export class BuildsCreateCommand extends ApifyCommand { message.push(''); - const url = `https://console.apify.com/actors/${build.actId}/builds/${build.buildNumber}`; + const url = consoleBuildUrl(build.actId, build.buildNumber); message.push(`${chalk.blue('View in Apify Console')}: ${url}`); diff --git a/src/commands/builds/wait.ts b/src/commands/builds/wait.ts index 5f147a519..49685eb71 100644 --- a/src/commands/builds/wait.ts +++ b/src/commands/builds/wait.ts @@ -1,6 +1,6 @@ import process from 'node:process'; -import type { Build } from 'apify-client'; +import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES } from '@apify/consts'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; @@ -65,15 +65,15 @@ export class BuildsWaitCommand extends ApifyCommand { const { job, timedOutWaiting } = await waitForTerminalStatus({ apifyClient, jobId: buildId, - kind: 'build', + kind: ACTOR_JOB_TYPES.BUILD, maxWaitMillis: timeout ? timeout * 1000 : undefined, pollIntervalMillis: pollInterval ? pollInterval * 1000 : undefined, }); - const build = job as Build; + const build = job; const url = consoleBuildUrl(build.actId, build.buildNumber); - const ok = build.status === 'SUCCEEDED'; - const exitCode = exitCodeForWaitResult({ job, timedOutWaiting }, 'build'); + const ok = build.status === ACTOR_JOB_STATUSES.SUCCEEDED; + const exitCode = exitCodeForWaitResult({ job, timedOutWaiting }, ACTOR_JOB_TYPES.BUILD); const giveUpMessage = `Gave up waiting after ${timeout}s; build is still ${build.status}`; let logTail: string[] = []; @@ -119,7 +119,7 @@ export class BuildsWaitCommand extends ApifyCommand { simpleLog({ message: formatResultSummary({ resultLabel: 'Apify build result', - overallStatus: build.status as never, + overallStatus: build.status, lines, links, errorReason: ok ? undefined : logTail, diff --git a/src/commands/runs/info.ts b/src/commands/runs/info.ts index 7c0118548..9e956b31b 100644 --- a/src/commands/runs/info.ts +++ b/src/commands/runs/info.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; +import { consoleDatasetUrl, consoleKeyValueStoreUrl, consoleRunUrl } from '../../lib/commands/agent-output.js'; import { prettyPrintBytes } from '../../lib/commands/pretty-print-bytes.js'; import { prettyPrintStatus } from '../../lib/commands/pretty-print-status.js'; import { CompactMode, ResponsiveTable } from '../../lib/commands/responsive-table.js'; @@ -258,9 +259,9 @@ export class RunsInfoCommand extends ApifyCommand { message.push(''); - const url = `https://console.apify.com/actors/${run.actId}/runs/${run.id}`; - const datasetUrl = `https://console.apify.com/storage/datasets/${run.defaultDatasetId}`; - const keyValueStoreUrl = `https://console.apify.com/storage/key-value-stores/${run.defaultKeyValueStoreId}`; + const url = consoleRunUrl(run.actId, run.id); + const datasetUrl = consoleDatasetUrl(run.defaultDatasetId); + const keyValueStoreUrl = consoleKeyValueStoreUrl(run.defaultKeyValueStoreId); message.push(`${chalk.blue('Export results')}: ${datasetUrl}`); message.push(`${chalk.blue('View saved items')}: ${keyValueStoreUrl}`); diff --git a/src/commands/runs/wait.ts b/src/commands/runs/wait.ts index 92e65547e..a910f98cd 100644 --- a/src/commands/runs/wait.ts +++ b/src/commands/runs/wait.ts @@ -1,6 +1,6 @@ import process from 'node:process'; -import type { ActorRun } from 'apify-client'; +import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES } from '@apify/consts'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; @@ -70,15 +70,15 @@ export class RunsWaitCommand extends ApifyCommand { const { job, timedOutWaiting } = await waitForTerminalStatus({ apifyClient, jobId: runId, - kind: 'run', + kind: ACTOR_JOB_TYPES.RUN, maxWaitMillis: timeout ? timeout * 1000 : undefined, pollIntervalMillis: pollInterval ? pollInterval * 1000 : undefined, }); - const run = job as ActorRun; + const run = job; const url = consoleRunUrl(run.actId, run.id); - const ok = run.status === 'SUCCEEDED'; - const exitCode = exitCodeForWaitResult({ job, timedOutWaiting }, 'run'); + const ok = run.status === ACTOR_JOB_STATUSES.SUCCEEDED; + const exitCode = exitCodeForWaitResult({ job, timedOutWaiting }, ACTOR_JOB_TYPES.RUN); const giveUpMessage = `Gave up waiting after ${timeout}s; run is still ${run.status}`; let logTail: string[] = []; @@ -131,7 +131,7 @@ export class RunsWaitCommand extends ApifyCommand { simpleLog({ message: formatResultSummary({ resultLabel: 'Apify run result', - overallStatus: run.status as never, + overallStatus: run.status, lines, links, errorReason: ok ? undefined : logTail, diff --git a/src/lib/commands/agent-output.ts b/src/lib/commands/agent-output.ts index b10b82329..1b99e28ce 100644 --- a/src/lib/commands/agent-output.ts +++ b/src/lib/commands/agent-output.ts @@ -1,34 +1,36 @@ import type { ActorRun, ApifyClient, Build } from 'apify-client'; import chalk from 'chalk'; -import { ACTOR_JOB_TERMINAL_STATUSES } from '@apify/consts'; +import { ACTOR_JOB_STATUSES, ACTOR_JOB_TERMINAL_STATUSES, ACTOR_JOB_TYPES } from '@apify/consts'; import { CommandExitCodes } from '../consts.js'; -export type TerminalStatus = 'SUCCEEDED' | 'FAILED' | 'ABORTED' | 'TIMED-OUT'; +export type TerminalStatus = (typeof ACTOR_JOB_TERMINAL_STATUSES)[number]; -const TERMINAL_STATUSES = new Set(ACTOR_JOB_TERMINAL_STATUSES as readonly string[]); +export type JobStatus = (typeof ACTOR_JOB_STATUSES)[keyof typeof ACTOR_JOB_STATUSES]; -export function isTerminalStatus(status: string | undefined): status is TerminalStatus { - return !!status && TERMINAL_STATUSES.has(status); +export type JobType = (typeof ACTOR_JOB_TYPES)[keyof typeof ACTOR_JOB_TYPES]; + +export function isTerminalStatus(status: JobStatus | undefined): status is TerminalStatus { + return !!status && (ACTOR_JOB_TERMINAL_STATUSES as readonly string[]).includes(status); } -export function exitCodeForJobStatus(status: string | undefined, kind: 'build' | 'run'): number { +export function exitCodeForJobStatus(status: JobStatus | undefined, kind: JobType): number { switch (status) { - case 'SUCCEEDED': + case ACTOR_JOB_STATUSES.SUCCEEDED: return 0; - case 'TIMED-OUT': - case 'TIMING-OUT': - return kind === 'build' ? CommandExitCodes.BuildTimedOut : CommandExitCodes.RunTimedOut; - case 'ABORTED': - case 'ABORTING': - return kind === 'build' ? CommandExitCodes.BuildAborted : CommandExitCodes.RunAborted; + case ACTOR_JOB_STATUSES.TIMED_OUT: + case ACTOR_JOB_STATUSES.TIMING_OUT: + return kind === ACTOR_JOB_TYPES.BUILD ? CommandExitCodes.BuildTimedOut : CommandExitCodes.RunTimedOut; + case ACTOR_JOB_STATUSES.ABORTED: + case ACTOR_JOB_STATUSES.ABORTING: + return kind === ACTOR_JOB_TYPES.BUILD ? CommandExitCodes.BuildAborted : CommandExitCodes.RunAborted; default: - return kind === 'build' ? CommandExitCodes.BuildFailed : CommandExitCodes.RunFailed; + return kind === ACTOR_JOB_TYPES.BUILD ? CommandExitCodes.BuildFailed : CommandExitCodes.RunFailed; } } -export function exitCodeForWaitResult(result: WaitForJobResult, kind: 'build' | 'run'): number { +export function exitCodeForWaitResult(result: WaitForJobResult, kind: JobType): number { // A client-side wait give-up is not a platform timeout, so report it with a distinct exit // code rather than mislabelling the still-running job as having timed out on the platform. return result.timedOutWaiting ? CommandExitCodes.WaitTimedOut : exitCodeForJobStatus(result.job.status, kind); @@ -37,15 +39,15 @@ export function exitCodeForWaitResult(result: WaitForJobResult, kind: 'build' | export interface WaitForJobOptions { apifyClient: ApifyClient; jobId: string; - kind: 'build' | 'run'; + kind: JobType; /** Poll interval in milliseconds. Defaults to 2000. */ pollIntervalMillis?: number; /** Maximum time to wait before giving up. Defaults to no limit. */ maxWaitMillis?: number; } -export interface WaitForJobResult { - job: Build | ActorRun; +export interface WaitForJobResult { + job: T; /** * True when the wait gave up because `maxWaitMillis` elapsed before the job reached a terminal * status. In that case `job.status` is the real, still-non-terminal platform status (e.g. RUNNING) — @@ -54,18 +56,24 @@ export interface WaitForJobResult { timedOutWaiting: boolean; } +interface JobTypeMap { + BUILD: Build; + RUN: ActorRun; +} + +export async function waitForTerminalStatus( + options: WaitForJobOptions & { kind: K }, +): Promise>; export async function waitForTerminalStatus(options: WaitForJobOptions): Promise { const { apifyClient, jobId, kind, pollIntervalMillis = 2000, maxWaitMillis } = options; const startedAt = Date.now(); while (true) { const job = - kind === 'build' - ? ((await apifyClient.build(jobId).get()) as Build | undefined) - : ((await apifyClient.run(jobId).get()) as ActorRun | undefined); + kind === ACTOR_JOB_TYPES.BUILD ? await apifyClient.build(jobId).get() : await apifyClient.run(jobId).get(); if (!job) { - throw new Error(`${kind === 'build' ? 'Build' : 'Run'} with ID "${jobId}" was not found.`); + throw new Error(`${kind === ACTOR_JOB_TYPES.BUILD ? 'Build' : 'Run'} with ID "${jobId}" was not found.`); } if (isTerminalStatus(job.status)) { @@ -88,34 +96,45 @@ export async function fetchLogTail(apifyClient: ApifyClient, jobId: string, maxL const log = await apifyClient.log(jobId).get(); if (!log) return []; - const lines = log.split('\n').filter((line) => line.length > 0); + const lines = log + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0); return lines.slice(-maxLines); } catch { return []; } } +export function consoleActorUrl(actorId: string): string { + return `https://console.apify.com/actors/${actorId}`; +} + export function consoleRunUrl(actorId: string, runId: string): string { return `https://console.apify.com/actors/${actorId}/runs/${runId}`; } export function consoleBuildUrl(actorId: string, buildNumber: string): string { - return `https://console.apify.com/actors/${actorId}#/builds/${buildNumber}`; + return `https://console.apify.com/actors/${actorId}/builds/${buildNumber}`; } export function consoleDatasetUrl(datasetId: string): string { return `https://console.apify.com/storage/datasets/${datasetId}`; } -function statusColor(status: string): string { - if (status === 'SUCCEEDED') return chalk.green(status); - if (status === 'RUNNING' || status === 'READY') return chalk.blue(status); +export function consoleKeyValueStoreUrl(keyValueStoreId: string): string { + return `https://console.apify.com/storage/key-value-stores/${keyValueStoreId}`; +} + +function statusColor(status: JobStatus): string { + if (status === ACTOR_JOB_STATUSES.SUCCEEDED) return chalk.green(status); + if (status === ACTOR_JOB_STATUSES.RUNNING || status === ACTOR_JOB_STATUSES.READY) return chalk.blue(status); return chalk.red(status); } export interface ResultSummaryOptions { resultLabel: string; // e.g. "Apify push result" - overallStatus: 'SUCCEEDED' | 'FAILED' | 'ABORTED' | 'TIMED-OUT' | 'RUNNING'; + overallStatus: JobStatus; lines: { label: string; value: string }[]; links?: { label: string; url: string }[]; errorReason?: string[]; diff --git a/src/lib/commands/run-on-cloud.ts b/src/lib/commands/run-on-cloud.ts index 8c421cb99..74ec6164e 100644 --- a/src/lib/commands/run-on-cloud.ts +++ b/src/lib/commands/run-on-cloud.ts @@ -3,7 +3,7 @@ import process from 'node:process'; import type { ActorRun, ApifyClient, TaskStartOptions } from 'apify-client'; import chalk from 'chalk'; -import { ACTOR_JOB_STATUSES } from '@apify/consts'; +import { ACTOR_JOB_STATUSES, ACTOR_JOB_TYPES } from '@apify/consts'; import { Flags } from '../command-framework/flags.js'; import { CommandExitCodes } from '../consts.js'; @@ -117,7 +117,7 @@ export async function* runActorOrTaskOnCloud(apifyClient: ApifyClient, options: // terminated by the consumer (e.g. `break` out of `for await`). using _signalHandler = useAbortJobOnSignal({ apifyClient, - kind: 'run', + kind: ACTOR_JOB_TYPES.RUN, jobId: run.id, runType: type, silent, diff --git a/src/lib/commands/run-result.ts b/src/lib/commands/run-result.ts index 96661c331..842f8e3fd 100644 --- a/src/lib/commands/run-result.ts +++ b/src/lib/commands/run-result.ts @@ -8,6 +8,7 @@ import { ACTOR_JOB_STATUSES } from '@apify/consts'; import { CommandExitCodes } from '../consts.js'; import { simpleLog } from '../outputs.js'; import { printJsonToStdout } from '../utils.js'; +import { consoleActorUrl, consoleDatasetUrl, consoleRunUrl, fetchLogTail } from './agent-output.js'; /** Which command produced the run, used for labels and the JSON `operation` field. */ export type RunResultOperation = 'call' | 'task-run'; @@ -20,20 +21,6 @@ const OPERATION_LABELS: Record = { /** How many trailing log lines to surface as the failure reason. */ const LOG_TAIL_LINES = 10; -const CONSOLE_BASE_URL = 'https://console.apify.com'; - -function actorUrl(actorId: string) { - return `${CONSOLE_BASE_URL}/actors/${actorId}`; -} - -export function runUrl(actorId: string, runId: string) { - return `${CONSOLE_BASE_URL}/actors/${actorId}/runs/${runId}`; -} - -function datasetUrl(datasetId: string) { - return `${CONSOLE_BASE_URL}/storage/datasets/${datasetId}`; -} - function isSucceeded(run: ActorRun): boolean { return run.status === ACTOR_JOB_STATUSES.SUCCEEDED; } @@ -81,23 +68,7 @@ export async function fetchRunLogTail(apifyClient: ApifyClient, run: ActorRun): return []; } - let log: string | undefined; - - try { - log = await apifyClient.log(run.id).get(); - } catch { - return []; - } - - if (!log) { - return []; - } - - return log - .split('\n') - .map((line) => line.trimEnd()) - .filter((line) => line.length > 0) - .slice(-LOG_TAIL_LINES); + return fetchLogTail(apifyClient, run.id, LOG_TAIL_LINES); } export interface RunResultOptions { @@ -125,17 +96,17 @@ export function buildRunResultJson({ run, operation, logTail }: RunResultOptions operation, actor: { id: run.actId, - url: actorUrl(run.actId), + url: consoleActorUrl(run.actId), }, run: { id: run.id, status: run.status, - url: runUrl(run.actId, run.id), + url: consoleRunUrl(run.actId, run.id), }, storage: { defaultDatasetId: run.defaultDatasetId, defaultKeyValueStoreId: run.defaultKeyValueStoreId, - datasetUrl: datasetUrl(run.defaultDatasetId), + datasetUrl: consoleDatasetUrl(run.defaultDatasetId), }, exitCode: getRunExitCode(run), }; @@ -173,8 +144,8 @@ export function printRunResultSummary({ run, operation, logTail }: RunResultOpti `${chalk.yellow('Dataset ID')}: ${run.defaultDatasetId}`, `${chalk.yellow('Key-value store ID')}: ${run.defaultKeyValueStoreId}`, '', - `${chalk.blue('Run URL')}: ${runUrl(run.actId, run.id)}`, - `${chalk.blue('Dataset URL')}: ${datasetUrl(run.defaultDatasetId)}`, + `${chalk.blue('Run URL')}: ${consoleRunUrl(run.actId, run.id)}`, + `${chalk.blue('Dataset URL')}: ${consoleDatasetUrl(run.defaultDatasetId)}`, ); if (!ok) { diff --git a/src/lib/hooks/useAbortJobOnSignal.ts b/src/lib/hooks/useAbortJobOnSignal.ts index ff02872ce..8255e5483 100644 --- a/src/lib/hooks/useAbortJobOnSignal.ts +++ b/src/lib/hooks/useAbortJobOnSignal.ts @@ -1,6 +1,8 @@ import type { ApifyClient } from 'apify-client'; import chalk from 'chalk'; +import { ACTOR_JOB_TYPES } from '@apify/consts'; + import { INTERRUPT_SIGNALS } from '../consts.js'; import { error, info } from '../outputs.js'; import { useSignalHandler } from './useSignalHandler.js'; @@ -13,13 +15,13 @@ export type UseAbortJobOnSignalInput = { } & ( | { /** Abort an Actor build. Builds have no graceful/force distinction. */ - kind: 'build'; + kind: typeof ACTOR_JOB_TYPES.BUILD; /** ID of the build to abort. */ jobId: string; } | { /** Abort an Actor or Task run. Runs escalate: graceful on the first signal, forced on the second. */ - kind: 'run'; + kind: typeof ACTOR_JOB_TYPES.RUN; /** ID of the run to abort. */ jobId: string; /** Used purely for the user-visible status line (e.g. "aborting actor run ..."). */ @@ -36,9 +38,9 @@ export type UseAbortJobOnSignalInput = { * Repeat signals never terminate the CLI while an abort is in flight — the * listener stays registered for the lifetime of the `using` binding: * - * - For `kind: 'build'`, the first signal issues the abort and subsequent + * - For builds, the first signal issues the abort and subsequent * signals are silent no-ops. The build-abort API has no "gracefully" knob. - * - For `kind: 'run'`, the first signal issues `abort({ gracefully: true })` + * - For runs, the first signal issues `abort({ gracefully: true })` * with a hint that pressing Ctrl+C again forces an immediate abort. The * second signal issues `abort({ gracefully: false })`. Third and later * signals are silent no-ops. @@ -48,7 +50,7 @@ export type UseAbortJobOnSignalInput = { * { * using _signalHandler = useAbortJobOnSignal({ * apifyClient: client, - * kind: 'build', + * kind: ACTOR_JOB_TYPES.BUILD, * jobId: build.id, * }); * @@ -67,7 +69,7 @@ export function useAbortJobOnSignal(input: UseAbortJobOnSignalInput): Disposable handler: async (signal) => { abortAttempt += 1; - if (input.kind === 'build') { + if (input.kind === ACTOR_JOB_TYPES.BUILD) { if (abortAttempt > 1) { return; } diff --git a/test/local/lib/agent-output.test.ts b/test/local/lib/agent-output.test.ts new file mode 100644 index 000000000..d344207ae --- /dev/null +++ b/test/local/lib/agent-output.test.ts @@ -0,0 +1,102 @@ +import type { ActorRun, ApifyClient, Build } from 'apify-client'; + +import { ACTOR_JOB_TYPES } from '@apify/consts'; + +import { + exitCodeForJobStatus, + exitCodeForWaitResult, + isTerminalStatus, + waitForTerminalStatus, +} from '../../../src/lib/commands/agent-output.js'; +import { CommandExitCodes } from '../../../src/lib/consts.js'; + +const makeBuild = (status: Build['status']): Build => ({ id: 'build123', status }) as Build; +const makeRun = (status: ActorRun['status']): ActorRun => ({ id: 'run123', status }) as ActorRun; + +const fakeClient = ({ builds = [], runs = [] }: { builds?: (Build | undefined)[]; runs?: (ActorRun | undefined)[] }) => + ({ + build: () => ({ get: async () => builds.shift() }), + run: () => ({ get: async () => runs.shift() }), + }) as unknown as ApifyClient; + +describe('isTerminalStatus', () => { + it.each(['SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED-OUT'] as const)('returns true for %s', (status) => { + expect(isTerminalStatus(status)).toBe(true); + }); + + it.each(['READY', 'RUNNING', 'TIMING-OUT', 'ABORTING', undefined] as const)('returns false for %s', (status) => { + expect(isTerminalStatus(status)).toBe(false); + }); +}); + +describe('exitCodeForJobStatus', () => { + it.each([ + ['SUCCEEDED', 0, 0], + ['TIMED-OUT', CommandExitCodes.BuildTimedOut, CommandExitCodes.RunTimedOut], + ['TIMING-OUT', CommandExitCodes.BuildTimedOut, CommandExitCodes.RunTimedOut], + ['ABORTED', CommandExitCodes.BuildAborted, CommandExitCodes.RunAborted], + ['ABORTING', CommandExitCodes.BuildAborted, CommandExitCodes.RunAborted], + ['FAILED', CommandExitCodes.BuildFailed, CommandExitCodes.RunFailed], + ['RUNNING', CommandExitCodes.BuildFailed, CommandExitCodes.RunFailed], + ] as const)('maps %s to build=%i run=%i', (status, buildCode, runCode) => { + expect(exitCodeForJobStatus(status, ACTOR_JOB_TYPES.BUILD)).toBe(buildCode); + expect(exitCodeForJobStatus(status, ACTOR_JOB_TYPES.RUN)).toBe(runCode); + }); +}); + +describe('exitCodeForWaitResult', () => { + it('returns WaitTimedOut when the client gave up waiting', () => { + const result = { job: makeRun('RUNNING'), timedOutWaiting: true }; + expect(exitCodeForWaitResult(result, ACTOR_JOB_TYPES.RUN)).toBe(CommandExitCodes.WaitTimedOut); + }); + + it('falls back to the job status exit code otherwise', () => { + const result = { job: makeBuild('SUCCEEDED'), timedOutWaiting: false }; + expect(exitCodeForWaitResult(result, ACTOR_JOB_TYPES.BUILD)).toBe(0); + }); +}); + +describe('waitForTerminalStatus', () => { + it('returns immediately when the job is already terminal', async () => { + const apifyClient = fakeClient({ builds: [makeBuild('SUCCEEDED')] }); + + const result = await waitForTerminalStatus({ apifyClient, jobId: 'build123', kind: ACTOR_JOB_TYPES.BUILD }); + + expect(result).toEqual({ job: makeBuild('SUCCEEDED'), timedOutWaiting: false }); + }); + + it('polls until the job reaches a terminal status', async () => { + const apifyClient = fakeClient({ runs: [makeRun('RUNNING'), makeRun('RUNNING'), makeRun('SUCCEEDED')] }); + + const result = await waitForTerminalStatus({ + apifyClient, + jobId: 'run123', + kind: ACTOR_JOB_TYPES.RUN, + pollIntervalMillis: 1, + }); + + expect(result).toEqual({ job: makeRun('SUCCEEDED'), timedOutWaiting: false }); + }); + + it('gives up with timedOutWaiting when maxWaitMillis elapses first', async () => { + const apifyClient = fakeClient({ runs: Array.from({ length: 100 }, () => makeRun('RUNNING')) }); + + const result = await waitForTerminalStatus({ + apifyClient, + jobId: 'run123', + kind: ACTOR_JOB_TYPES.RUN, + pollIntervalMillis: 1, + maxWaitMillis: 20, + }); + + expect(result).toEqual({ job: makeRun('RUNNING'), timedOutWaiting: true }); + }); + + it('throws when the job does not exist', async () => { + const apifyClient = fakeClient({ builds: [undefined] }); + + await expect(waitForTerminalStatus({ apifyClient, jobId: 'missing', kind: ACTOR_JOB_TYPES.BUILD })).rejects.toThrow( + 'Build with ID "missing" was not found.', + ); + }); +});