From 7f71e4797425ad86bd9578b0e13ff259a5c21a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Pr=C5=AF=C5=A1a?= <87543374+Patai5@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:11:13 +0200 Subject: [PATCH 1/2] fix: apply default actor timeout to runs --- lib/lib.ts | 31 ++++++++++++++++--------------- lib/types.ts | 14 +++++++++++--- test/unit/lib.test.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 test/unit/lib.test.ts diff --git a/lib/lib.ts b/lib/lib.ts index 7756bde..0b63aec 100644 --- a/lib/lib.ts +++ b/lib/lib.ts @@ -39,8 +39,14 @@ const DEFAULT_TEST_OPTIONS: ActorTestOptions = { concurrent: true, // test should finish within 1 hour timeout: DEFAULT_TEST_RUN_DURATION_MS, + retry: 1, }; +const DEFAULT_TEST_ACTOR_OPTIONS: ActorTestOptions = { + // Prevent orphaned runs - timeout the Actor 1 minute before the test does so everything is logged correctly. + // - Otherwise the run link URL can get lost. + timeout: DEFAULT_TEST_RUN_DURATION_MS / 1000 - 60, +}; /** * Platform tests need `TESTER_APIFY_TOKEN` to talk to the platform, so without it we skip them altogether. * @@ -51,12 +57,6 @@ export const describe = (name: string, fn?: SuiteFactory, options: Actor vitestDescribe.runIf(!!TESTER_APIFY_TOKEN || !!RUN_ALL_PLATFORM_TESTS)(name, options, fn); }; -const DEFAULT_TEST_ACTOR_OPTIONS: ActorTestOptions = { - retry: 1, - // prevent orphaned runs - timeout: DEFAULT_TEST_RUN_DURATION_MS, -}; - /** * @param actorId - The actor's raw platform ID or its full name (`owner/name`, e.g. `"apify/web-scraper"`). */ @@ -66,10 +66,8 @@ export const testActor = ( fn: TestFunction<{ run: ReturnType> }>, testOptions?: ActorTestOptions, ) => { - const options = { - ...DEFAULT_TEST_ACTOR_OPTIONS, - ...testOptions, - }; + const options = { ...DEFAULT_TEST_OPTIONS, ...testOptions }; + const name = `${actorId}: ${testName}`; // `RUN_ALL_PLATFORM_TESTS` is needed for the scheduled tests, which have no `ACTOR_BUILDS` to match the // tests against - without it, every test would be filtered out as an actor we didn't build. @@ -100,10 +98,8 @@ export const testStandbyActor = ( fn: TestFunction<{ callStandby: ReturnType> }>, testOptions?: ActorTestOptions, ) => { - const options = { - ...DEFAULT_TEST_ACTOR_OPTIONS, - ...testOptions, - }; + const options = { ...DEFAULT_TEST_OPTIONS, ...testOptions }; + const name = `${actorId}: ${testName}`; // `RUN_ALL_PLATFORM_TESTS` is needed for the scheduled tests, which have no `ACTOR_BUILDS` to match the // tests against - without it, every test would be filtered out as an actor we didn't build. @@ -285,7 +281,8 @@ const createStartRunFn = (actorId: string, testContext: TestContext) => { ...(prefilledInput && (await getActorPrefilledInput(apifyClient, actorId, buildId))), ...input, }; - const run = await actor.call(actorInput, { build, log: null, ...options }); + const actorOptions = { ...DEFAULT_TEST_ACTOR_OPTIONS, build, log: null, ...options }; + const run = await actor.call(actorInput, actorOptions); const runLink = generateRunLink(run); await annotate(`${task.name} - ${runLink}`, 'run_link'); @@ -306,3 +303,7 @@ const createStartRunFn = (actorId: string, testContext: TestContext) => { const generateRunLink = (run: ActorRun | ActorRunListItem): string => { return `https://console.apify.com/view/runs/${run.id}`; }; + +/** Used for unit testing */ +// eslint-disable-next-line no-underscore-dangle +export const _private = { createStartRunFn } as const; diff --git a/lib/types.ts b/lib/types.ts index d231bd5..bcafd1f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -10,7 +10,15 @@ export type ActorBuild = { export type RunOptions = { input: Omit; - options?: ActorCallOptions; + options?: Omit & { + /** + * Timeout for the actor run in seconds. Zero value means there is no timeout. + * - If `undefined`, the run uses timeout of the default Actor run configuration. + * + * @default 3540 // 59 minutes (finish the run before the test) + */ + timeout?: number; + }; prefilledInput?: boolean; /** * If you specify `runId`, all the other options will be ignored and this run's data will @@ -138,8 +146,8 @@ export type ActorTestOptions = Omit & { // we are just extending the docs here to replace the default value, otherwise it's the exact same retry?: TestOptions['retry']; /** - * Timeout for the actor run in milliseconds. Zero value means there is no timeout. - * - If `undefined`, the run uses timeout of the default Actor run configuration. + * Timeout for the test in milliseconds. Zero value means there is no timeout. + * - If `undefined`, the run uses timeout of the default test configuration. * * @default 60 * 60 * 1000 // 1 hour */ diff --git a/test/unit/lib.test.ts b/test/unit/lib.test.ts new file mode 100644 index 0000000..f552e41 --- /dev/null +++ b/test/unit/lib.test.ts @@ -0,0 +1,29 @@ +import type { ActorClient, ActorRun } from 'apify-client'; +import { ApifyClient } from 'apify-client'; +import type { TestContext } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +import { _private } from '../../lib/lib.js'; +import * as UtilsModule from '../../lib/utils.js'; + +const { createStartRunFn } = _private; + +describe('createStartRunFn()', () => { + it('should apply default timeout to actor runs', async () => { + // Arrange + const actorCallMock = vi.fn(async () => Promise.resolve({ id: 'fake-run-id' } as ActorRun)); + vi.spyOn(ApifyClient.prototype, 'actor').mockReturnValue({ + call: actorCallMock as ActorClient['call'], + } as ActorClient); + vi.spyOn(UtilsModule, 'sleep').mockResolvedValue(undefined); + + const testContext = { task: {}, annotate: vi.fn() as TestContext['annotate'] } as TestContext; + + // Act + await createStartRunFn('123', testContext)({ input: {} }); + + // Assert + expect(actorCallMock).toHaveBeenCalledTimes(1); + expect(actorCallMock).toHaveBeenCalledWith({}, { build: undefined, log: null, timeout: 3540 }); + }); +}); From ccc5bbf06c75245a88c43f1a9d6d8b1be6f7b073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Pr=C5=AF=C5=A1a?= <87543374+Patai5@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:44:51 +0200 Subject: [PATCH 2/2] fix: respect prefilled actor timeout --- lib/consts.ts | 4 ++++ lib/lib.ts | 19 ++++++++++--------- test/unit/lib.test.ts | 36 ++++++++++++++++++++++++++++++------ 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/lib/consts.ts b/lib/consts.ts index b1cad50..fd93391 100644 --- a/lib/consts.ts +++ b/lib/consts.ts @@ -17,6 +17,10 @@ export const TO_FINISH_WITH_OPTIONS: ToFinishWithOptionsWithDefaults = { */ export const DEFAULT_TEST_RUN_DURATION_MS = 60 * 60 * 1000; // 1 hour +// Prevent orphaned runs - timeout the Actor 1 minute before the test does so everything is logged correctly. +// - Otherwise the run link URL can get lost. +export const DEFAULT_TEST_ACTOR_TIMEOUT_SECS = DEFAULT_TEST_RUN_DURATION_MS / 1000 - 60; + /** * Delay before checking the dataset and statistics after a run finishes to resolve eventual consistency. * - This value should ensure that the dataset and statistics are fully updated before any assertions are made. diff --git a/lib/lib.ts b/lib/lib.ts index 0b63aec..ba374b0 100644 --- a/lib/lib.ts +++ b/lib/lib.ts @@ -3,7 +3,7 @@ import { ApifyClient } from 'apify-client'; import type { SuiteFactory, TestContext, TestFunction } from 'vitest'; import { describe as vitestDescribe, ExpectStatic, test as vitestTest } from 'vitest'; -import { DATASET_SYNC_DELAY_MS, DEFAULT_TEST_RUN_DURATION_MS } from './consts.js'; +import { DATASET_SYNC_DELAY_MS, DEFAULT_TEST_ACTOR_TIMEOUT_SECS, DEFAULT_TEST_RUN_DURATION_MS } from './consts.js'; import { extendExpect } from './extend-expect.js'; import { RunTestResult } from './run-test-result.js'; import type { ActorBuild, ActorTestOptions, RunOptions } from './types.js'; @@ -42,11 +42,6 @@ const DEFAULT_TEST_OPTIONS: ActorTestOptions = { retry: 1, }; -const DEFAULT_TEST_ACTOR_OPTIONS: ActorTestOptions = { - // Prevent orphaned runs - timeout the Actor 1 minute before the test does so everything is logged correctly. - // - Otherwise the run link URL can get lost. - timeout: DEFAULT_TEST_RUN_DURATION_MS / 1000 - 60, -}; /** * Platform tests need `TESTER_APIFY_TOKEN` to talk to the platform, so without it we skip them altogether. * @@ -275,13 +270,19 @@ const createStartRunFn = (actorId: string, testContext: TestContext) => { return new RunTestResult(apifyClient, run); } - const actor = apifyClient.actor(actorId); - const actorInput = { ...(prefilledInput && (await getActorPrefilledInput(apifyClient, actorId, buildId))), ...input, }; - const actorOptions = { ...DEFAULT_TEST_ACTOR_OPTIONS, build, log: null, ...options }; + + const actor = apifyClient.actor(actorId); + const actorInfo = await actor.get(); + const timeout = Math.min( + actorInfo?.defaultRunOptions?.timeoutSecs ?? DEFAULT_TEST_ACTOR_TIMEOUT_SECS, + DEFAULT_TEST_ACTOR_TIMEOUT_SECS, + ); + + const actorOptions = { timeout, build, log: null, ...options }; const run = await actor.call(actorInput, actorOptions); const runLink = generateRunLink(run); diff --git a/test/unit/lib.test.ts b/test/unit/lib.test.ts index f552e41..7faba09 100644 --- a/test/unit/lib.test.ts +++ b/test/unit/lib.test.ts @@ -1,29 +1,53 @@ -import type { ActorClient, ActorRun } from 'apify-client'; +import type { Actor, ActorClient, ActorRun } from 'apify-client'; import { ApifyClient } from 'apify-client'; import type { TestContext } from 'vitest'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_TEST_ACTOR_TIMEOUT_SECS } from '../../lib/consts.js'; import { _private } from '../../lib/lib.js'; import * as UtilsModule from '../../lib/utils.js'; const { createStartRunFn } = _private; describe('createStartRunFn()', () => { + const actorCallMock = vi.fn(async () => Promise.resolve({ id: 'fake-run-id' } as ActorRun)); + const testContext = { task: {}, annotate: vi.fn() as TestContext['annotate'] } as TestContext; + + vi.spyOn(UtilsModule, 'sleep').mockResolvedValue(undefined); + + beforeEach(vi.clearAllMocks); + it('should apply default timeout to actor runs', async () => { // Arrange - const actorCallMock = vi.fn(async () => Promise.resolve({ id: 'fake-run-id' } as ActorRun)); + vi.spyOn(ApifyClient.prototype, 'actor').mockReturnValue({ call: actorCallMock as ActorClient['call'], + get: vi.fn(async () => ({ defaultRunOptions: {} }) as Actor) as ActorClient['get'], } as ActorClient); - vi.spyOn(UtilsModule, 'sleep').mockResolvedValue(undefined); - const testContext = { task: {}, annotate: vi.fn() as TestContext['annotate'] } as TestContext; + // Act + await createStartRunFn('123', testContext)({ input: {} }); + + // Assert + expect(actorCallMock).toHaveBeenCalledTimes(1); + expect(actorCallMock).toHaveBeenCalledWith( + {}, + { build: undefined, log: null, timeout: DEFAULT_TEST_ACTOR_TIMEOUT_SECS }, + ); + }); + + it('should respect the default actor run timeout if less', async () => { + // Arrange + vi.spyOn(ApifyClient.prototype, 'actor').mockReturnValue({ + call: actorCallMock as ActorClient['call'], + get: vi.fn(async () => ({ defaultRunOptions: { timeoutSecs: 60 } }) as Actor) as ActorClient['get'], + } as ActorClient); // Act await createStartRunFn('123', testContext)({ input: {} }); // Assert expect(actorCallMock).toHaveBeenCalledTimes(1); - expect(actorCallMock).toHaveBeenCalledWith({}, { build: undefined, log: null, timeout: 3540 }); + expect(actorCallMock).toHaveBeenCalledWith({}, { build: undefined, log: null, timeout: 60 }); }); });