From d1836c811ff29249dabdbfeacdfadeee4d1c1cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 11 Sep 2026 16:46:06 +0200 Subject: [PATCH] fix(actors ls): do not crash when an Actor record has no stats `apify actors ls` read `item.actor.stats.totalBuilds` behind an `item.actor` null check only. The API omits `stats` on some Actor records, so the command exited with `Cannot read properties of undefined (reading 'totalBuilds')`. apify-client types `Actor.stats` as required, so TypeScript did not flag it. The e2e tests all pass `--json`, which returns before the table is rendered, so the path was untested. Co-Authored-By: Claude Opus 5 --- src/commands/actors/ls.ts | 2 +- test/local/commands/actors-ls.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 test/local/commands/actors-ls.test.ts diff --git a/src/commands/actors/ls.ts b/src/commands/actors/ls.ts index fee970d26..99a386c63 100644 --- a/src/commands/actors/ls.ts +++ b/src/commands/actors/ls.ts @@ -287,7 +287,7 @@ export class ActorsLsCommand extends ApifyCommand { 'Last run': lastRunDisplayedTimestamp, 'Last run status': item.lastRun ? prettyPrintStatus(item.lastRun.status) : '', 'Modified at': MultilineTimestampFormatter.display(item.modifiedAt), - Builds: item.actor ? chalk.cyan(item.actor.stats.totalBuilds) : chalk.gray('Unknown'), + Builds: item.actor?.stats ? chalk.cyan(item.actor.stats.totalBuilds) : chalk.gray('Unknown'), 'Last run duration': ResponsiveTable.isSmallTerminal() ? kSkipColumn : chalk.cyan(lastRunDuration), 'Default build': defaultBuild, _Small_LastRunText: runStatus, diff --git a/test/local/commands/actors-ls.test.ts b/test/local/commands/actors-ls.test.ts new file mode 100644 index 000000000..06779a270 --- /dev/null +++ b/test/local/commands/actors-ls.test.ts @@ -0,0 +1,69 @@ +import type { ApifyClient } from 'apify-client'; + +import { ActorsLsCommand } from '../../../src/commands/actors/ls.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 } = vitest.hoisted(() => ({ + mockGetLoggedClientOrThrow: vitest.fn(), +})); + +vitest.mock('../../../src/lib/utils.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getLoggedClientOrThrow: mockGetLoggedClientOrThrow, + }; +}); + +useAuthSetup(); + +const { logMessages } = useConsoleSpy(); + +const listItem = { + id: 'abc123', + createdAt: new Date('2024-01-01T00:00:00Z'), + modifiedAt: new Date('2024-01-02T00:00:00Z'), + name: 'some-actor', + username: 'someone-else', + title: 'Some Actor', + stats: { totalRuns: 3, lastRunStartedAt: '2024-01-02T00:00:00Z' }, +}; + +// The API omits `stats` on some Actor records even though apify-client types it as required. +const actorWithoutStats = { + id: 'abc123', + name: 'some-actor', + username: 'someone-else', + title: 'Some Actor', + defaultRunOptions: { build: 'latest', timeoutSecs: 3600, memoryMbytes: 1024 }, +}; + +const fakeClient = () => + ({ + actors: () => ({ + list: async () => ({ count: 1, desc: false, items: [listItem], limit: 20, offset: 0, total: 1 }), + }), + actor: () => ({ + get: async () => actorWithoutStats, + runs: () => ({ + list: async () => ({ count: 0, desc: true, items: [], limit: 1, offset: 0, total: 0 }), + }), + }), + }) as unknown as ApifyClient; + +afterEach(() => { + mockGetLoggedClientOrThrow.mockReset(); +}); + +describe('apify actors ls', () => { + it('renders the table when an Actor record has no stats', async () => { + mockGetLoggedClientOrThrow.mockResolvedValue(fakeClient()); + + await testRunCommand(ActorsLsCommand, { flags_my: true }); + + expect(logMessages.error).toEqual([]); + expect(logMessages.log.join('\n')).toContain('Some Actor'); + }); +});