diff --git a/src/commands/actors/info.ts b/src/commands/actors/info.ts index 8729cfd30..99d025801 100644 --- a/src/commands/actors/info.ts +++ b/src/commands/actors/info.ts @@ -1,4 +1,4 @@ -import type { Actor, ActorChargeEvent, ActorTaggedBuild, Build, User } from 'apify-client'; +import type { Actor, ActorChargeEvent, ActorTaggedBuild, ApifyApiError, ApifyClient, Build, User } from 'apify-client'; import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; @@ -30,6 +30,19 @@ function formatEventPrice(price: number): string { return `$${Number(price.toPrecision(2))}`; } +async function getDefaultBuild(client: ApifyClient, actorId: string, fallback?: Build) { + try { + const build = await client.actor(actorId).defaultBuild(); + return (await build.get()) ?? fallback; + } catch (err) { + if ((err as ApifyApiError).statusCode === 404) { + return fallback; + } + + throw err; + } +} + const eventTitleColumn = '\u200b'; const eventPriceUsdColumn = '\u200b\u200b'; @@ -123,42 +136,24 @@ export class ActorsInfoCommand extends ApifyCommand { return; } - const latest = actorInfo.taggedBuilds?.latest; - - if (readme) { - if (!latest) { - error({ - message: 'No README found for this Actor.', - stdout: true, - }); - - return; - } - - if (!latest.build?.readme) { - error({ - message: 'No README found for this Actor.', - stdout: true, - }); + if (readme || input) { + const build = await getDefaultBuild(client, ctx.id, actorInfo.taggedBuilds?.latest?.build); - return; - } + if (readme) { + if (!build?.readme) { + error({ + message: 'No README found for this Actor.', + stdout: true, + }); - simpleLog({ message: latest.build.readme, stdout: true }); - return; - } - - if (input) { - if (!latest) { - error({ - message: 'No input schema found for this Actor.', - stdout: true, - }); + return; + } + simpleLog({ message: build.readme, stdout: true }); return; } - if (!latest.build?.inputSchema) { + if (!build?.inputSchema) { error({ message: 'No input schema found for this Actor.', stdout: true, @@ -167,7 +162,7 @@ export class ActorsInfoCommand extends ApifyCommand { return; } - simpleLog({ message: latest.build.inputSchema, stdout: true }); + simpleLog({ message: build.inputSchema, stdout: true }); return; } diff --git a/test/local/commands/actors-info.test.ts b/test/local/commands/actors-info.test.ts new file mode 100644 index 000000000..67dcae9ef --- /dev/null +++ b/test/local/commands/actors-info.test.ts @@ -0,0 +1,130 @@ +import type { ApifyClient } from 'apify-client'; + +import { ActorsInfoCommand } from '../../../src/commands/actors/info.js'; +import { testRunCommand } from '../../../src/lib/command-framework/apify-command.js'; +import { useAuthSetup } from '../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../__setup__/hooks/useConsoleSpy.js'; + +const { mockGetLoggedClientOrThrow, mockGetCurrentUserInfo } = vitest.hoisted(() => ({ + mockGetLoggedClientOrThrow: vitest.fn(), + mockGetCurrentUserInfo: vitest.fn(), +})); + +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLoggedClientOrThrow: mockGetLoggedClientOrThrow, + getCurrentUserInfo: mockGetCurrentUserInfo, + }; +}); + +useAuthSetup(); + +const { logMessages } = useConsoleSpy(); + +const defaultBuild = { + buildNumber: '1.0.30', + readme: 'default build readme', + inputSchema: '{"title":"default build schema"}', +}; + +const fakeClient = ({ + taggedBuilds, + defaultBuildResult, + defaultBuildError, +}: { + taggedBuilds: object; + defaultBuildResult?: object; + defaultBuildError?: Error; +}) => + ({ + actor: () => ({ + get: async () => ({ + id: 'actorId', + name: 'some-actor', + username: 'someone-else', + userId: 'userId', + taggedBuilds, + defaultRunOptions: { build: 'version-1' }, + }), + defaultBuild: async () => { + if (defaultBuildError) throw defaultBuildError; + if (!defaultBuildResult) throw Object.assign(new Error('Default build not found'), { statusCode: 404 }); + return { get: async () => defaultBuildResult }; + }, + }), + user: () => ({ get: async () => ({ username: 'someone-else', profile: {} }) }), + build: () => ({ get: async () => ({ readme: 'tagged build readme', inputSchema: '{"title":"tagged"}' }) }), + }) as unknown as ApifyClient; + +// The command framework records a thrown error on the real `process`; put it back so it cannot leak. +let originalExitCode: typeof process.exitCode; + +beforeEach(() => { + originalExitCode = process.exitCode; + mockGetCurrentUserInfo.mockResolvedValue({ id: 'userId', username: 'someone-else' }); +}); + +afterEach(() => { + process.exitCode = originalExitCode; + mockGetLoggedClientOrThrow.mockReset(); + mockGetCurrentUserInfo.mockReset(); +}); + +describe('apify actors info', () => { + it('prints the default build input schema when the Actor has no latest tag', async () => { + mockGetLoggedClientOrThrow.mockResolvedValue( + fakeClient({ + taggedBuilds: { 'version-1': { buildId: 'b1', buildNumber: '1.0.30' } }, + defaultBuildResult: defaultBuild, + }), + ); + + await testRunCommand(ActorsInfoCommand, { args_actorId: 'someone-else/some-actor', flags_input: true }); + + expect(logMessages.error).toEqual([]); + expect(logMessages.log.join('\n')).toBe(defaultBuild.inputSchema); + }); + + it('prefers the default build README over the latest tag', async () => { + mockGetLoggedClientOrThrow.mockResolvedValue( + fakeClient({ + taggedBuilds: { + latest: { buildId: 'b0', buildNumber: '0.0.32' }, + 'version-1': { buildId: 'b1', buildNumber: '1.0.30' }, + }, + defaultBuildResult: defaultBuild, + }), + ); + + await testRunCommand(ActorsInfoCommand, { args_actorId: 'someone-else/some-actor', flags_readme: true }); + + expect(logMessages.log.join('\n')).toBe(defaultBuild.readme); + }); + + it('falls back to the latest tag when the default build cannot be resolved', async () => { + mockGetLoggedClientOrThrow.mockResolvedValue( + fakeClient({ taggedBuilds: { latest: { buildId: 'b0', buildNumber: '0.0.32' } } }), + ); + + await testRunCommand(ActorsInfoCommand, { args_actorId: 'someone-else/some-actor', flags_input: true }); + + expect(logMessages.log.join('\n')).toBe('{"title":"tagged"}'); + }); + + it('does not fall back to the latest tag when the default build lookup fails with a non-404 error', async () => { + mockGetLoggedClientOrThrow.mockResolvedValue( + fakeClient({ + taggedBuilds: { latest: { buildId: 'b0', buildNumber: '0.0.32' } }, + defaultBuildError: Object.assign(new Error('Internal error'), { statusCode: 500 }), + }), + ); + + await testRunCommand(ActorsInfoCommand, { args_actorId: 'someone-else/some-actor', flags_input: true }); + + expect(process.exitCode).toBe(1); + expect(logMessages.error.join('\n')).toContain('Internal error'); + expect(logMessages.log.join('\n')).not.toContain('tagged'); + }); +});