From aef59addc6f1da1b7ad74f720c2184d593b69b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 5 Oct 2026 14:05:13 +0200 Subject: [PATCH 1/2] fix(actors info): follow the Actor's default build for --input and --readme Closes #1454 Co-Authored-By: Claude Sonnet 5.5 --- src/commands/actors/info.ts | 55 ++++++------- test/local/commands/actors-info.test.ts | 101 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 32 deletions(-) create mode 100644 test/local/commands/actors-info.test.ts diff --git a/src/commands/actors/info.ts b/src/commands/actors/info.ts index 839ea250f..a572eec59 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, ApifyClient, Build, User } from 'apify-client'; import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; @@ -30,6 +30,15 @@ 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 { + return fallback; + } +} + const eventTitleColumn = '\u200b'; const eventPriceUsdColumn = '\u200b\u200b'; @@ -121,42 +130,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, - }); - - return; - } + if (readme || input) { + const build = await getDefaultBuild(client, ctx.id, actorInfo.taggedBuilds?.latest?.build); - simpleLog({ message: latest.build.readme, stdout: true }); - return; - } + if (readme) { + if (!build?.readme) { + error({ + message: 'No README found for this Actor.', + stdout: true, + }); - 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, @@ -165,7 +156,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..def2cb86b --- /dev/null +++ b/test/local/commands/actors-info.test.ts @@ -0,0 +1,101 @@ +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 }: { taggedBuilds: object; defaultBuildResult?: object }) => + ({ + actor: () => ({ + get: async () => ({ + id: 'actorId', + name: 'some-actor', + username: 'someone-else', + userId: 'userId', + taggedBuilds, + defaultRunOptions: { build: 'version-1' }, + }), + defaultBuild: async () => { + if (!defaultBuildResult) throw new Error('Default build not found'); + 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; + +beforeEach(() => { + mockGetCurrentUserInfo.mockResolvedValue({ id: 'userId', username: 'someone-else' }); +}); + +afterEach(() => { + 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"}'); + }); +}); From e2bd41b4e231927c1f01ebe38b24e08dfe60cd3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 5 Oct 2026 14:31:01 +0200 Subject: [PATCH 2/2] fix(actors info): fail on non-404 errors when resolving the default build Only a 404 means the Actor has no default build. Any other error now fails the command instead of silently printing the `latest` schema with exit 0. Co-Authored-By: Claude Sonnet 5.5 --- src/commands/actors/info.ts | 10 +++++--- test/local/commands/actors-info.test.ts | 33 +++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/commands/actors/info.ts b/src/commands/actors/info.ts index a572eec59..ece1833ba 100644 --- a/src/commands/actors/info.ts +++ b/src/commands/actors/info.ts @@ -1,4 +1,4 @@ -import type { Actor, ActorChargeEvent, ActorTaggedBuild, ApifyClient, 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'; @@ -34,8 +34,12 @@ async function getDefaultBuild(client: ApifyClient, actorId: string, fallback?: try { const build = await client.actor(actorId).defaultBuild(); return (await build.get()) ?? fallback; - } catch { - return fallback; + } catch (err) { + if ((err as ApifyApiError).statusCode === 404) { + return fallback; + } + + throw err; } } diff --git a/test/local/commands/actors-info.test.ts b/test/local/commands/actors-info.test.ts index def2cb86b..67dcae9ef 100644 --- a/test/local/commands/actors-info.test.ts +++ b/test/local/commands/actors-info.test.ts @@ -29,7 +29,15 @@ const defaultBuild = { inputSchema: '{"title":"default build schema"}', }; -const fakeClient = ({ taggedBuilds, defaultBuildResult }: { taggedBuilds: object; defaultBuildResult?: object }) => +const fakeClient = ({ + taggedBuilds, + defaultBuildResult, + defaultBuildError, +}: { + taggedBuilds: object; + defaultBuildResult?: object; + defaultBuildError?: Error; +}) => ({ actor: () => ({ get: async () => ({ @@ -41,7 +49,8 @@ const fakeClient = ({ taggedBuilds, defaultBuildResult }: { taggedBuilds: object defaultRunOptions: { build: 'version-1' }, }), defaultBuild: async () => { - if (!defaultBuildResult) throw new Error('Default build not found'); + if (defaultBuildError) throw defaultBuildError; + if (!defaultBuildResult) throw Object.assign(new Error('Default build not found'), { statusCode: 404 }); return { get: async () => defaultBuildResult }; }, }), @@ -49,11 +58,16 @@ const fakeClient = ({ taggedBuilds, defaultBuildResult }: { taggedBuilds: object 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(); }); @@ -98,4 +112,19 @@ describe('apify actors info', () => { 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'); + }); });