diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 8730da4a..bfdc6d9e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -15,6 +15,9 @@ on: # Release flow will trigger checks manually workflow_call: + # Allows running the checks - most importantly the integration tests - on demand. + workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -48,6 +51,55 @@ jobs: - name: Run Tests run: pnpm test + integration_tests: + name: Integration tests (Node ${{ matrix.node-version }}) + # These run against the live Apify API with real credentials, so they are skipped for fork PRs, + # where repository secrets are not available. + if: >- + ${{ + !contains(github.event.head_commit.message, '[skip ci]') && + ( + (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/master') || + github.event_name == 'workflow_dispatch' + ) + }} + runs-on: ubuntu-latest + + env: + APIFY_TEST_USER_API_TOKEN: ${{ secrets.APIFY_TEST_USER_PYTHON_SDK_API_TOKEN }} + + # A single Node version on purpose: these tests exercise HTTP against the live Apify API, where + # the Node version carries no signal, and every extra job multiplies the load on the shared + # test account. Version coverage is the unit tier's job. + strategy: + fail-fast: false + matrix: + node-version: [26] + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v7 + with: + node-version: ${{ matrix.node-version }} + + - name: Install pnpm and dependencies + uses: apify/actions/pnpm-install@v1.4.0 + + # A workflow that calls this one as a reusable workflow - the release flow does - gets no + # secrets unless it opts in with `secrets: inherit`, so the token can legitimately be empty. + # Skip rather than fail there, otherwise a missing token blocks the release. + - name: Run integration tests + if: ${{ env.APIFY_TEST_USER_API_TOKEN != '' }} + run: pnpm test:integration + + - name: Report the skipped integration tests + if: ${{ env.APIFY_TEST_USER_API_TOKEN == '' }} + run: echo "::warning::APIFY_TEST_USER_API_TOKEN is empty, the integration tests did not run." + test_bundling: name: Test bundler support runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c265960..bc23e474 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,9 @@ npm run build:browser # RSBuild browser/UMD bundle npm run clean # Remove dist directory # Testing -npm test # Build and run vitest suite +npm test # Build and run the unit tests +npm run test:integration # Run the integration tests against the live API +npm run test:all # Build and run both tiers npm run tsc-check-tests # TypeScript check test files # Linting & Formatting @@ -147,17 +149,37 @@ npm run format ## Testing -Tests are written using [Vitest](https://vitest.dev/) and run against a mock server. +Tests are written using [Vitest](https://vitest.dev/) and split into two projects: + +- **`unit`** - everything outside `test/integration/`, run against a mock server. Offline and fast. +- **`integration`** - `test/integration/`, run against the live Apify API with a real token. ### Running Tests ```bash -npm test # Full build + test run -npx vitest run # Run tests only (requires prior build) -npx vitest --watch # Watch mode -npx vitest run actors # Run specific test file +npm test # Full build + unit tests +npx vitest run --project unit # Unit tests only (requires prior build) +npx vitest --watch --project unit # Watch mode +npx vitest run --project unit actors # Run specific test file +``` + +### Running the Integration Tests + +They create and delete real resources under a test user, so they need an API token: + +```bash +export APIFY_TEST_USER_API_TOKEN=... # Token of the user the tests run as +npm run test:integration ``` +Set `APIFY_INTEGRATION_TESTS_API_URL` to point the tier at a different deployment, such as staging. +Without `APIFY_TEST_USER_API_TOKEN` every integration test fails in its `beforeAll` hook - run +`npm test` instead if you only want the offline tier. + +In CI the tier runs on pull requests opened from this repository. It is skipped for pull requests +from forks, where repository secrets are unavailable, and can be triggered by hand via +`workflow_dispatch`. + ### Test Structure Tests use a mock server located in `test/mock_server/` that simulates the Apify API: @@ -212,7 +234,8 @@ export function addMyResourceRoutes(router: Router) { ### Test Timeout -Tests have a 20-second timeout configured in `vitest.config.mts`. +Configured in `vitest.config.mts`: 20 seconds for unit tests, 300 seconds for integration tests, +where Actor runs and builds dominate the runtime. ## Pull Request Guidelines diff --git a/package.json b/package.json index fe3eb177..1bcb7f27 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,9 @@ "postbuild": "gen-esm-wrapper dist/index.js dist/index.mjs", "prepublishOnly": "(test $CI || (echo \"Publishing is reserved to CI!\"; exit 1))", "clean": "rimraf dist tsconfig.tsbuildinfo", - "test": "pnpm build && vitest run", + "test": "pnpm build && vitest run --project unit", + "test:integration": "vitest run --project integration", + "test:all": "pnpm build && vitest run", "test:bundling": "pnpm build && pnpm --prefix=./test/bundling run bundle:all", "lint": "oxlint --type-aware", "lint:fix": "oxlint --type-aware --fix", diff --git a/test/integration/_fixtures.ts b/test/integration/_fixtures.ts new file mode 100644 index 00000000..064759e3 --- /dev/null +++ b/test/integration/_fixtures.ts @@ -0,0 +1,18 @@ +import { ApifyClient } from 'apify-client'; + +/** API token of the primary test user. Every test in this tier needs it. */ +const TOKEN_ENV_VAR = 'APIFY_TEST_USER_API_TOKEN'; + +/** Overrides the API the suite runs against, so it can be pointed at a staging deployment. */ +const API_URL_ENV_VAR = 'APIFY_INTEGRATION_TESTS_API_URL'; + +/** Client authenticated as the primary test user. */ +export function makeClient(): ApifyClient { + const token = process.env[TOKEN_ENV_VAR]; + if (!token) { + throw new Error(`${TOKEN_ENV_VAR} environment variable is missing, cannot run tests!`); + } + + const baseUrl = process.env[API_URL_ENV_VAR]; + return new ApifyClient({ token, ...(baseUrl ? { baseUrl } : {}) }); +} diff --git a/test/integration/_utils.ts b/test/integration/_utils.ts new file mode 100644 index 00000000..76e93d5d --- /dev/null +++ b/test/integration/_utils.ts @@ -0,0 +1,139 @@ +import { randomInt } from 'node:crypto'; + +const ID_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +/** Generate a random ID of `length` characters, drawn from the alphabet the API uses for object IDs. */ +export function randomId(length: number): string { + let id = ''; + for (let i = 0; i < length; i++) { + id += ID_CHARS[randomInt(ID_CHARS.length)]; + } + return id; +} + +const NAME_PREFIX = 'js-client-test'; +const API_NAME_LIMIT = 63; +const RANDOM_ID_LENGTH = 8; +const LABEL_LENGTH_LIMIT = API_NAME_LIMIT - `${NAME_PREFIX}--`.length - RANDOM_ID_LENGTH; + +/** + * Generate a unique resource name containing the given label. + * + * Keeps the result within the API limit of 63 characters, so callers can pass a descriptive label + * without having to count. + */ +export function getRandomResourceName(label: string): string { + const normalized = label.replaceAll('_', '-'); + if (normalized.length > LABEL_LENGTH_LIMIT) { + throw new Error(`Max label length is ${LABEL_LENGTH_LIMIT}, but got ${normalized.length}`); + } + return `${NAME_PREFIX}-${normalized}-${randomId(RANDOM_ID_LENGTH)}`; +} + +async function sleep(millis: number): Promise { + await new Promise((resolve) => setTimeout(resolve, millis)); +} + +/** + * Every status a run may legitimately report right after it was started. + * + * Assert against this set - rather than a single status - whenever the test does not control the + * outcome of the run. Under load the platform can move a run through to a terminal state before the + * `start()` response is even read, and it can legitimately fail or be aborted without that meaning + * the client is broken. Narrow assertions belong only where the test does control the outcome, such + * as after `waitForFinish()` or `call()`. + */ +export const ANY_RUN_STATUS = [ + 'READY', + 'RUNNING', + 'SUCCEEDED', + 'TIMED-OUT', + 'TIMING-OUT', + 'FAILED', + 'ABORTING', + 'ABORTED', +] as const; + +/** + * Options that turn off the log redirection `call()` performs by default. + * + * `ActorClient.call()` streams the run log to stdout unless `log` is `null`, which costs two extra + * API requests per call and floods the CI output. Pass this wherever the redirected log is not what + * the test is checking. + */ +export const NO_LOG_REDIRECT = { log: null } as const; + +export interface PollOptions { + /** Total seconds to keep polling before giving up. */ + timeoutSecs?: number; + /** Seconds to wait between polls. */ + pollIntervalSecs?: number; + /** Multiplies the interval after each poll, to cover a long timeout with few calls. */ + backoffFactor?: number; +} + +/** + * Poll `fn` until `condition(result)` holds or the timeout expires. + * + * Returns the last polled result either way, so the caller runs its own assertion and gets a useful + * failure message. Use this instead of a fixed sleep when waiting for eventually-consistent state, + * such as a freshly created resource appearing in a listing. For waits whose length varies by orders + * of magnitude, such as an Actor run container starting up, pass a `backoffFactor` above 1. + */ +export async function pollUntilCondition( + fn: () => Promise, + condition: (value: T) => boolean = (value) => Boolean(value), + options: PollOptions = {}, +): Promise { + const { timeoutSecs = 30, pollIntervalSecs = 1, backoffFactor = 1 } = options; + const deadline = Date.now() + timeoutSecs * 1000; + let delayMillis = pollIntervalSecs * 1000; + + let result = await fn(); + while (!condition(result)) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(delayMillis, remaining)); + delayMillis *= backoffFactor; + result = await fn(); + } + return result; +} + +const COLLECT_MAX_ATTEMPTS = 5; +const COLLECT_INTERVAL_SECS = 1; + +/** + * Drain an async-iterable listing until every expected ID is present. + * + * Handles eventual consistency on listing endpoints: under parallel load a freshly created resource + * may be missing from the listing for a short window. Each attempt builds a fresh iterable via + * `iterableFactory` and drains it, stopping early once all `expectedIds` are found. The most recent + * collection is returned regardless, so the caller can assert with a helpful message. + * + * Loops on attempt count rather than a wall-clock deadline: drains take HTTP time, and charging that + * against a deadline would mean fewer retries under load - exactly when they are needed most. + */ +export async function collectUntilPresent( + iterableFactory: () => AsyncIterable, + expectedIds: Iterable, +): Promise { + const expected = [...expectedIds]; + + const drain = async (): Promise => { + const collected: T[] = []; + for await (const item of iterableFactory()) { + collected.push(item); + } + return collected; + }; + + let collected = await drain(); + for (let attempt = 1; attempt < COLLECT_MAX_ATTEMPTS; attempt++) { + const collectedIds = new Set(collected.map((item) => item.id)); + if (expected.every((id) => collectedIds.has(id))) break; + await sleep(COLLECT_INTERVAL_SECS * 1000); + collected = await drain(); + } + return collected; +} diff --git a/test/integration/actor.test.ts b/test/integration/actor.test.ts new file mode 100644 index 00000000..00b65d8e --- /dev/null +++ b/test/integration/actor.test.ts @@ -0,0 +1,367 @@ +import { afterEach, beforeAll, expect, test, vi } from 'vitest'; + +import type { + Actor, + ActorChargeEvent, + ActorCollectionListItem, + ApifyClient, + PricePerDatasetItemActorPricingInfo, + PricePerEventActorPricingInfo, +} from 'apify-client'; +import { ActorListSortBy, ActorSourceType } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { ANY_RUN_STATUS, getRandomResourceName, NO_LOG_REDIRECT } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; +const WEB_SCRAPER_ACTOR = 'apify/web-scraper'; + +/** + * Actor carrying pricing-info entries for every non-trivial variant: `FLAT_PRICE_PER_MONTH`, both flat + * and tiered `PRICE_PER_DATASET_ITEM`, and tiered `PAY_PER_EVENT` with `isPrimaryEvent` / + * `isOneTimeEvent` fields. + */ +const ALL_PRICING_VARIANTS_ACTOR = 'apify/facebook-pages-scraper'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** Create a throwaway Actor. Nothing here is ever built, so it costs no compute. */ +async function createActor(options: { title?: string } = {}): Promise { + return client.actors().create({ + name: getRandomResourceName('actor'), + ...(options.title ? { title: options.title } : {}), + }); +} + +/** + * The listing endpoint returns `stats`, but `ActorCollectionListItem` does not declare it yet. Read it + * through this shape so the sorting assertions stay honest until the type catches up. + */ +type ListItemWithStats = ActorCollectionListItem & { stats?: { lastRunStartedAt?: Date } }; + +/** + * Sort keys of the Actors in a listing that have actually run. + * + * An Actor that never ran carries no `lastRunStartedAt`, and where the API places those in the ordering + * is not part of the client's contract. Compare only the timestamps that are present, the same way the + * run feed assertions do. + */ +function lastRunSortKeys(items: ActorCollectionListItem[]): number[] { + return (items as ListItemWithStats[]) + .map((item) => item.stats?.lastRunStartedAt?.getTime()) + .filter((value): value is number => value !== undefined); +} + +test('get() resolves a public Actor by its full name', async () => { + const actor = await client.actor(WEB_SCRAPER_ACTOR).get(); + + expect(actor?.id).toBeTruthy(); + expect(actor?.name).toBe('web-scraper'); + expect(actor?.username).toBe('apify'); +}); + +test('get() resolves apify/hello-world, the Actor the run-based tests rely on', async () => { + const actor = await client.actor(HELLO_WORLD_ACTOR).get(); + + expect(actor?.name).toBe('hello-world'); + expect(actor?.username).toBe('apify'); +}); + +test('get() resolves to undefined for an Actor that does not exist', async () => { + await expect(client.actor('this-actor/does-not-exist-anywhere').get()).resolves.toBeUndefined(); +}); + +test('actors().list({ my: true }) returns a page of Actors', async () => { + const actorsPage = await client.actors().list({ my: true, limit: 10 }); + + expect(Array.isArray(actorsPage.items)).toBe(true); + expect(actorsPage.items.length).toBeLessThanOrEqual(10); +}); + +test('actors().list() honours limit and offset', async () => { + const actorsPage = await client.actors().list({ limit: 5, offset: 0 }); + + expect(Array.isArray(actorsPage.items)).toBe(true); + expect(actorsPage.items.length).toBeLessThanOrEqual(5); + expect(actorsPage.limit).toBe(5); + expect(actorsPage.offset).toBe(0); +}); + +test('actors().list() sorted by last run, descending, comes back in that order', async () => { + const actorsPage = await client + .actors() + .list({ limit: 10, desc: true, sortBy: ActorListSortBy.LAST_RUN_STARTED_AT }); + + // Assert monotonicity rather than comparing against a locally re-sorted copy: the API and a local + // sort may break ties on identical timestamps differently, which says nothing about the client. + const keys = lastRunSortKeys(actorsPage.items); + expect(keys).toEqual([...keys].sort((a, b) => b - a)); +}); + +test('actors().list() sorted by last run, ascending, comes back in that order', async () => { + const actorsPage = await client + .actors() + .list({ limit: 10, desc: false, sortBy: ActorListSortBy.LAST_RUN_STARTED_AT }); + + const keys = lastRunSortKeys(actorsPage.items); + expect(keys).toEqual([...keys].sort((a, b) => a - b)); +}); + +test('actors().list() is async-iterable and yields the user Actors', async () => { + const collected: ActorCollectionListItem[] = []; + for await (const actor of client.actors().list({ my: true, limit: 10 })) { + collected.push(actor); + } + + expect(collected.length, 'the test account should own at least one Actor').toBeGreaterThanOrEqual(1); + for (const actor of collected) { + expect(actor.id).toBeTruthy(); + } +}); + +test('an Actor can be created, updated and deleted, and each step is visible on a re-read', async () => { + const actorName = getRandomResourceName('actor'); + const createdActor = await client.actors().create({ + name: actorName, + title: 'Test Actor', + description: 'Test actor for integration tests', + versions: [ + { + versionNumber: '0.1', + sourceType: ActorSourceType.SourceFiles, + buildTag: 'latest', + sourceFiles: [{ name: 'main.js', format: 'TEXT', content: 'console.log("Hello")' }], + }, + ], + }); + expect(createdActor.id).toBeTruthy(); + expect(createdActor.name).toBe(actorName); + + const actorClient = client.actor(createdActor.id); + + try { + // Only title and description - changing defaultRunOptions requires the Actor to have a build. + const updatedActor = await actorClient.update({ + title: 'Updated Test Actor', + description: 'Updated description', + }); + expect(updatedActor.title).toBe('Updated Test Actor'); + expect(updatedActor.description).toBe('Updated description'); + + const retrievedActor = await actorClient.get(); + expect(retrievedActor?.title).toBe('Updated Test Actor'); + } finally { + await actorClient.delete(); + } + + await expect(actorClient.get()).resolves.toBeUndefined(); +}); + +test('update() sets the categories and SEO fields, and they persist', async () => { + const createdActor = await createActor({ title: 'Test Actor for Categories' }); + const actorClient = client.actor(createdActor.id); + + try { + const updated = await actorClient.update({ + categories: ['MARKETING'], + seoTitle: 'SEO Test Title', + seoDescription: 'SEO Test Description', + }); + + expect(updated.categories).toEqual(['MARKETING']); + expect(updated.seoTitle).toBe('SEO Test Title'); + expect(updated.seoDescription).toBe('SEO Test Description'); + } finally { + await actorClient.delete(); + } +}); + +test('defaultBuild() returns a client for a build that can then be read', async () => { + const buildClient = await client.actor(HELLO_WORLD_ACTOR).defaultBuild(); + + const build = await buildClient.get(); + expect(build?.id).toBeTruthy(); + expect(build?.status).toBeTruthy(); +}); + +test('defaultBuild() accepts waitForFinish and still returns a readable build', async () => { + const buildClient = await client.actor(HELLO_WORLD_ACTOR).defaultBuild({ waitForFinish: 1 }); + + const build = await buildClient.get(); + expect(build?.id).toBeTruthy(); +}); + +test('lastRun() resolves to a readable run', async () => { + const actorClient = client.actor(HELLO_WORLD_ACTOR); + const run = await actorClient.call(undefined, NO_LOG_REDIRECT); + + try { + const lastRun = await actorClient.lastRun().get(); + + expect(lastRun?.id).toBeTruthy(); + } finally { + await client.run(run.id).delete(); + } +}); + +test('validateInput() accepts an empty input for apify/hello-world', async () => { + await expect(client.actor(HELLO_WORLD_ACTOR).validateInput({})).resolves.toBe(true); +}); + +test('start() applies the build, memory and timeout overrides it is given', async () => { + const run = await client + .actor(HELLO_WORLD_ACTOR) + .start(undefined, { build: 'latest', memory: 256, timeout: 120, waitForFinish: 60 }); + const runClient = client.run(run.id); + + try { + expect(run.id).toBeTruthy(); + expect(run.options.memoryMbytes).toBe(256); + expect(run.options.timeoutSecs).toBe(120); + expect(ANY_RUN_STATUS).toContain(run.status); + } finally { + // A run that is still executing cannot be deleted. + await runClient.waitForFinish(); + await runClient.delete(); + } +}); + +test('start() passes a run input through, and the run succeeds with it', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).start({ message: 'integration-test-input' }); + const runClient = client.run(run.id); + + try { + expect(run.id).toBeTruthy(); + + const finishedRun = await runClient.waitForFinish(); + expect(finishedRun.status).toBe('SUCCEEDED'); + } finally { + await runClient.delete(); + } +}); + +test('call() waits for the run and resolves once it has SUCCEEDED', async () => { + const run = await client + .actor(HELLO_WORLD_ACTOR) + .call({ message: 'integration-test' }, { build: 'latest', memory: 256, ...NO_LOG_REDIRECT }); + + try { + expect(run.status).toBe('SUCCEEDED'); + expect(run.options.memoryMbytes).toBe(256); + } finally { + await client.run(run.id).delete(); + } +}); + +test('webhooks().list() is empty for a newly created Actor', async () => { + const createdActor = await createActor({ title: 'Test Actor for Webhooks' }); + const actorClient = client.actor(createdActor.id); + + try { + const webhooksPage = await actorClient.webhooks().list(); + + expect(webhooksPage.items).toHaveLength(0); + } finally { + await actorClient.delete(); + } +}); + +/** + * The tiered pricing shapes below are not declared on the v3 types yet, so the assertions read them + * through these local shapes. Pinning the field names here is what would catch an alias being + * dropped once the types do declare them. + */ +type TieredPricePerDatasetItem = PricePerDatasetItemActorPricingInfo & { + tieredPricing?: Record; +}; + +type TieredChargeEvent = ActorChargeEvent & { + eventTieredPricingUsd?: Record; + isPrimaryEvent?: boolean; + isOneTimeEvent?: boolean; +}; + +test('get() returns tiered PRICE_PER_DATASET_ITEM pricing with its tiers intact', async () => { + const actor = await client.actor(ALL_PRICING_VARIANTS_ACTOR).get(); + expect(actor?.pricingInfos?.length).toBeGreaterThan(0); + + const tieredEntries = (actor!.pricingInfos ?? []) + .filter((info): info is TieredPricePerDatasetItem => info.pricingModel === 'PRICE_PER_DATASET_ITEM') + .filter((info) => info.tieredPricing !== undefined); + + expect( + tieredEntries.length, + `${ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PRICE_PER_DATASET_ITEM entry - ` + + 'pick a different Actor if its pricing changed.', + ).toBeGreaterThan(0); + + // Fixture-drift guard: tiered pricing is only meaningful with more than one tier and with tiers + // that actually differ in price. A degenerate single-tier or all-zero payload would silently look + // like flat pricing and the assertion above would keep passing. + for (const entry of tieredEntries) { + const tiers = Object.values(entry.tieredPricing!); + expect( + tiers.length, + `${ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has only ${tiers.length} tier(s); expected ` + + 'multiple tiers (e.g. FREE/BRONZE/SILVER/GOLD/PLATINUM/DIAMOND).', + ).toBeGreaterThanOrEqual(2); + + const distinctPrices = new Set(tiers.map((tier) => tier.tieredPricePerUnitUsd)); + expect( + distinctPrices.size, + `${ALL_PRICING_VARIANTS_ACTOR} tiered PPD entry has all-identical prices; tiers should differ.`, + ).toBeGreaterThanOrEqual(2); + } +}); + +test('get() returns tiered PAY_PER_EVENT charge events with their flags intact', async () => { + const actor = await client.actor(ALL_PRICING_VARIANTS_ACTOR).get(); + expect(actor?.pricingInfos?.length).toBeGreaterThan(0); + + const tieredEvents = (actor!.pricingInfos ?? []) + .filter((info): info is PricePerEventActorPricingInfo => info.pricingModel === 'PAY_PER_EVENT') + .flatMap((info) => Object.values(info.pricingPerEvent.actorChargeEvents ?? {}) as TieredChargeEvent[]) + .filter((event) => event.eventTieredPricingUsd !== undefined); + + expect( + tieredEvents.length, + `${ALL_PRICING_VARIANTS_ACTOR} should have at least one tiered PAY_PER_EVENT event - ` + + 'pick a different Actor if its pricing changed.', + ).toBeGreaterThan(0); + + expect( + tieredEvents.some((event) => event.isPrimaryEvent === true), + `${ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has isPrimaryEvent === true.`, + ).toBe(true); + expect( + tieredEvents.some((event) => event.isOneTimeEvent !== undefined), + `${ALL_PRICING_VARIANTS_ACTOR}: no tiered PPE event has isOneTimeEvent populated.`, + ).toBe(true); +}); + +test('call({ log: "default" }) streams the run log to the console while the run executes', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, { log: 'default' }); + + try { + expect(run.status).toBe('SUCCEEDED'); + + // The default redirect logger prefixes every line it emits with ` runId: -> `. + const lines = logSpy.mock.calls.map(([line]) => String(line)); + expect( + lines.filter((line) => line.includes(`runId:${run.id}`)), + 'no log line was redirected from the run', + ).not.toHaveLength(0); + } finally { + await client.run(run.id).delete(); + } +}); diff --git a/test/integration/actor_env_var.test.ts b/test/integration/actor_env_var.test.ts new file mode 100644 index 00000000..0b00341e --- /dev/null +++ b/test/integration/actor_env_var.test.ts @@ -0,0 +1,152 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { Actor, ActorEnvironmentVariable, ActorVersion, ApifyClient } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { getRandomResourceName } from './_utils.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +function sourceFilesVersion(versionNumber: string, envVars?: ActorEnvironmentVariable[]): ActorVersion { + return { + versionNumber, + sourceType: ActorSourceType.SourceFiles, + buildTag: 'latest', + sourceFiles: [{ name: 'main.js', format: 'TEXT', content: 'console.log("Hello")' }], + ...(envVars ? { envVars } : {}), + }; +} + +/** Create a throwaway Actor with a single unbuilt version, so this costs no compute. */ +async function createActor(version: ActorVersion): Promise { + return client.actors().create({ name: getRandomResourceName('actor'), versions: [version] }); +} + +test('envVars().list() returns the environment variables a version was created with', async () => { + const actor = await createActor(sourceFilesVersion('0.0', [{ name: 'TEST_VAR', value: 'test_value' }])); + const actorClient = client.actor(actor.id); + + try { + const envVars = await actorClient.version('0.0').envVars().list(); + + expect(envVars.items.length).toBeGreaterThanOrEqual(1); + expect(envVars.items[0].name).toBe('TEST_VAR'); + expect(envVars.items[0].value).toBe('test_value'); + } finally { + await actorClient.delete(); + } +}); + +test('envVars().create() adds a variable that is then retrievable by name', async () => { + const actor = await createActor(sourceFilesVersion('1.0')); + const actorClient = client.actor(actor.id); + const versionClient = actorClient.version('1.0'); + + try { + const createdEnvVar = await versionClient + .envVars() + .create({ name: 'MY_VAR', value: 'my_value', isSecret: false }); + expect(createdEnvVar.name).toBe('MY_VAR'); + expect(createdEnvVar.value).toBe('my_value'); + expect(createdEnvVar.isSecret).toBe(false); + + const retrievedEnvVar = await versionClient.envVar('MY_VAR').get(); + expect(retrievedEnvVar?.name).toBe('MY_VAR'); + expect(retrievedEnvVar?.value).toBe('my_value'); + } finally { + await actorClient.delete(); + } +}); + +test('update() changes the value of an environment variable, and it persists', async () => { + const actor = await createActor(sourceFilesVersion('0.1', [{ name: 'UPDATE_VAR', value: 'initial_value' }])); + const actorClient = client.actor(actor.id); + const envVarClient = actorClient.version('0.1').envVar('UPDATE_VAR'); + + try { + const updatedEnvVar = await envVarClient.update({ name: 'UPDATE_VAR', value: 'updated_value' }); + expect(updatedEnvVar.name).toBe('UPDATE_VAR'); + expect(updatedEnvVar.value).toBe('updated_value'); + + const retrievedEnvVar = await envVarClient.get(); + expect(retrievedEnvVar?.value).toBe('updated_value'); + } finally { + await actorClient.delete(); + } +}); + +test('delete() removes one environment variable and leaves the others alone', async () => { + const actor = await createActor( + sourceFilesVersion('0.1', [ + { name: 'VAR_TO_DELETE', value: 'delete_me' }, + { name: 'VAR_TO_KEEP', value: 'keep_me' }, + ]), + ); + const actorClient = client.actor(actor.id); + const versionClient = actorClient.version('0.1'); + + try { + await versionClient.envVar('VAR_TO_DELETE').delete(); + + await expect(versionClient.envVar('VAR_TO_DELETE').get()).resolves.toBeUndefined(); + + const remainingEnvVar = await versionClient.envVar('VAR_TO_KEEP').get(); + expect(remainingEnvVar?.name).toBe('VAR_TO_KEEP'); + } finally { + await actorClient.delete(); + } +}); + +test('envVars().list() is async-iterable and yields every environment variable', async () => { + const envVars = [0, 1, 2].map((index) => ({ name: `VAR_${index}`, value: `value_${index}` })); + const actor = await createActor(sourceFilesVersion('0.0', envVars)); + const actorClient = client.actor(actor.id); + + try { + const collected: ActorEnvironmentVariable[] = []; + for await (const envVar of actorClient.version('0.0').envVars().list()) { + collected.push(envVar); + } + + expect(collected).toHaveLength(3); + expect(new Set(collected.map((item) => item.name))).toEqual(new Set(['VAR_0', 'VAR_1', 'VAR_2'])); + } finally { + await actorClient.delete(); + } +}); + +test('a secret environment variable is stored, but its value is never read back', async () => { + const actor = await createActor(sourceFilesVersion('0.0')); + const actorClient = client.actor(actor.id); + const versionClient = actorClient.version('0.0'); + + try { + const created = await versionClient + .envVars() + .create({ name: 'MY_SECRET', value: 'super-secret-token', isSecret: true }); + expect(created.name).toBe('MY_SECRET'); + expect(created.isSecret).toBe(true); + + const retrieved = await versionClient.envVar('MY_SECRET').get(); + expect(retrieved?.isSecret).toBe(true); + expect(retrieved?.value).toBeUndefined(); + } finally { + await actorClient.delete(); + } +}); + +test('get() resolves to undefined for an environment variable that does not exist', async () => { + const actor = await createActor(sourceFilesVersion('0.0')); + const actorClient = client.actor(actor.id); + + try { + await expect(actorClient.version('0.0').envVar('THIS_DOES_NOT_EXIST').get()).resolves.toBeUndefined(); + } finally { + await actorClient.delete(); + } +}); diff --git a/test/integration/actor_version.test.ts b/test/integration/actor_version.test.ts new file mode 100644 index 00000000..3f98a43a --- /dev/null +++ b/test/integration/actor_version.test.ts @@ -0,0 +1,135 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { Actor, ActorVersion, ApifyClient, FinalActorVersion } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { getRandomResourceName } from './_utils.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +function sourceFilesVersion(versionNumber: string, buildTag: string, content = 'console.log("Hello")'): ActorVersion { + return { + versionNumber, + sourceType: ActorSourceType.SourceFiles, + buildTag, + sourceFiles: [{ name: 'main.js', format: 'TEXT', content }], + }; +} + +/** Create a throwaway Actor. The versions are never built, so this costs no compute. */ +async function createActor(versions?: ActorVersion[]): Promise { + return client.actors().create({ + name: getRandomResourceName('actor'), + ...(versions ? { versions } : {}), + }); +} + +test('versions().list() returns the versions an Actor was created with', async () => { + const actor = await createActor([sourceFilesVersion('0.0', 'latest')]); + const actorClient = client.actor(actor.id); + + try { + const versions = await actorClient.versions().list(); + + expect(versions.items.length).toBeGreaterThanOrEqual(1); + expect(versions.items[0].versionNumber).toBe('0.0'); + expect(versions.items[0].buildTag).toBe('latest'); + } finally { + await actorClient.delete(); + } +}); + +test('versions().create() adds a version that is then retrievable by number', async () => { + const actor = await createActor(); + const actorClient = client.actor(actor.id); + + try { + const createdVersion = await actorClient + .versions() + .create(sourceFilesVersion('1.0', 'test', 'console.log("Hello from version 1.0")')); + expect(createdVersion.versionNumber).toBe('1.0'); + expect(createdVersion.buildTag).toBe('test'); + expect(createdVersion.sourceType).toBe(ActorSourceType.SourceFiles); + + const retrievedVersion = await actorClient.version('1.0').get(); + expect(retrievedVersion?.versionNumber).toBe('1.0'); + expect(retrievedVersion?.buildTag).toBe('test'); + } finally { + await actorClient.delete(); + } +}); + +test('update() changes the build tag of a version, and it persists', async () => { + const actor = await createActor([sourceFilesVersion('0.1', 'initial', 'console.log("Initial")')]); + const actorClient = client.actor(actor.id); + const versionClient = actorClient.version('0.1'); + + try { + const updatedVersion = await versionClient.update( + sourceFilesVersion('0.1', 'updated', 'console.log("Updated")'), + ); + expect(updatedVersion.versionNumber).toBe('0.1'); + expect(updatedVersion.buildTag).toBe('updated'); + + const retrievedVersion = await versionClient.get(); + expect(retrievedVersion?.buildTag).toBe('updated'); + } finally { + await actorClient.delete(); + } +}); + +test('delete() removes one version and leaves the others alone', async () => { + const actor = await createActor([ + sourceFilesVersion('0.1', 'v1', 'console.log("v1")'), + sourceFilesVersion('0.2', 'v2', 'console.log("v2")'), + ]); + const actorClient = client.actor(actor.id); + + try { + await actorClient.version('0.1').delete(); + + await expect(actorClient.version('0.1').get()).resolves.toBeUndefined(); + + const remainingVersion = await actorClient.version('0.2').get(); + expect(remainingVersion?.versionNumber).toBe('0.2'); + } finally { + await actorClient.delete(); + } +}); + +test('versions().list() is async-iterable and yields every version', async () => { + const actor = await createActor([ + sourceFilesVersion('0.0', 'latest', 'console.log(0)'), + sourceFilesVersion('0.1', 'v1', 'console.log(1)'), + sourceFilesVersion('0.2', 'v2', 'console.log(2)'), + ]); + const actorClient = client.actor(actor.id); + + try { + const collected: FinalActorVersion[] = []; + for await (const version of actorClient.versions().list()) { + collected.push(version); + } + + expect(collected).toHaveLength(3); + expect(new Set(collected.map((item) => item.versionNumber))).toEqual(new Set(['0.0', '0.1', '0.2'])); + } finally { + await actorClient.delete(); + } +}); + +test('get() resolves to undefined for a version that does not exist', async () => { + const actor = await createActor(); + const actorClient = client.actor(actor.id); + + try { + await expect(actorClient.version('99.99').get()).resolves.toBeUndefined(); + } finally { + await actorClient.delete(); + } +}); diff --git a/test/integration/apify_client.test.ts b/test/integration/apify_client.test.ts new file mode 100644 index 00000000..9b0d98c3 --- /dev/null +++ b/test/integration/apify_client.test.ts @@ -0,0 +1,17 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ApifyClient } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +test('the client authenticates against the live API and resolves the current user', async () => { + const me = await client.user('me').get(); + + expect(me.username).toBeTruthy(); +}); diff --git a/test/integration/build.test.ts b/test/integration/build.test.ts new file mode 100644 index 00000000..7b8e1d50 --- /dev/null +++ b/test/integration/build.test.ts @@ -0,0 +1,218 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { Actor, ApifyClient, BuildCollectionClientListItem } from 'apify-client'; +import { ActorSourceType } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { getRandomResourceName } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; + +/** + * Apify-owned Actor whose `latest` build sets `minMemoryMbytes: 128`, well below the 256 MB the spec + * used to require. It also publishes `actorDefinition.version: "0.0.1"`, which exercises the + * semver-triplet version pattern. + */ +const SMALL_MIN_MEMORY_ACTOR = 'apify/instagram-profile-scraper'; + +/** + * Apify-owned Actor whose build list includes entries with `meta.origin: "CI"` from the internal CI + * pipeline. CI builds are infrequent and rotate out of the most recent window, so the listing has to + * page deep with `desc: true` to find one. + */ +const CI_ORIGIN_ACTOR = 'apify/cheerio-scraper'; + +/** The listing endpoint returns `actId`, but `BuildCollectionClientListItem` does not declare it yet. */ +type ListItemWithActorId = BuildCollectionClientListItem & { actId?: string }; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +/** + * Return a stable build id from `actor.taggedBuilds`, preferring the `latest` tag. + * + * Reading the first entry instead would depend on whichever tag the API happens to serialize first. + */ +function pickBuildId(actor: Actor): string { + const taggedBuilds = actor.taggedBuilds ?? {}; + const buildId = + taggedBuilds.latest?.buildId ?? Object.values(taggedBuilds).find((build) => build?.buildId)?.buildId; + + expect(buildId, `${actor.username}/${actor.name} has no tagged build with a build id`).toBeTruthy(); + return buildId!; +} + +async function firstHelloWorldBuild(limit = 1): Promise { + const buildsPage = await client.actor(HELLO_WORLD_ACTOR).builds().list({ limit }); + expect(buildsPage.items.length, `${HELLO_WORLD_ACTOR} should have at least one build`).toBeGreaterThan(0); + return buildsPage.items; +} + +test('builds().list() returns the builds of a public Actor', async () => { + const buildsPage = await client.actor(HELLO_WORLD_ACTOR).builds().list({ limit: 10 }); + + expect(buildsPage.items.length).toBeGreaterThan(0); + const firstBuild = buildsPage.items[0] as ListItemWithActorId; + expect(firstBuild.id).toBeTruthy(); + expect(firstBuild.actId).toBeTruthy(); +}); + +test('get() returns the build that the listing pointed at', async () => { + const [listedBuild] = await firstHelloWorldBuild(); + + const build = await client.build(listedBuild.id).get(); + + expect(build?.id).toBe(listedBuild.id); + expect(build?.actId).toBeTruthy(); + expect(build?.status).toBeTruthy(); +}); + +test('get() resolves to undefined for a build that does not exist', async () => { + await expect(client.build('NoNeXiStEnTbUiLd').get()).resolves.toBeUndefined(); +}); + +test('builds().list() at the user level returns the builds of the test user', async () => { + const buildsPage = await client.builds().list({ limit: 10 }); + + expect(Array.isArray(buildsPage.items)).toBe(true); + for (const build of buildsPage.items) { + expect(build.id).toBeTruthy(); + } +}); + +test('waitForFinish() returns immediately for a build that is already finished', async () => { + const builds = await firstHelloWorldBuild(5); + const finished = builds.find((item) => item.status === 'SUCCEEDED') ?? builds[0]; + + const build = await client.build(finished.id).waitForFinish({ waitSecs: 5 }); + + expect(build.id).toBe(finished.id); +}); + +test('getOpenApiDefinition() returns the OpenAPI document of a build', async () => { + const [listedBuild] = await firstHelloWorldBuild(); + + const openApiDefinition = await client.build(listedBuild.id).getOpenApiDefinition(); + + expect(openApiDefinition).toBeTypeOf('object'); + expect(openApiDefinition.openapi).toBeTruthy(); +}); + +test('builds().list() is async-iterable and yields the builds of an Actor', async () => { + const collected: BuildCollectionClientListItem[] = []; + for await (const build of client.actor(HELLO_WORLD_ACTOR).builds().list({ limit: 5 })) { + collected.push(build); + } + + expect(collected.length).toBeGreaterThanOrEqual(1); + for (const build of collected as ListItemWithActorId[]) { + expect(build.id).toBeTruthy(); + expect(build.actId).toBeTruthy(); + } +}); + +test('builds().list() at the user level is async-iterable', async () => { + const collected: BuildCollectionClientListItem[] = []; + for await (const build of client.builds().list({ limit: 5 })) { + collected.push(build); + } + + expect(collected.length, 'the test account should have at least one build').toBeGreaterThanOrEqual(1); + for (const build of collected) { + expect(build.id).toBeTruthy(); + } +}); + +test('a build can be aborted and deleted on an Actor the test user owns', async () => { + const createdActor = await client.actors().create({ + name: getRandomResourceName('actor'), + title: 'Test Actor for Build Delete', + versions: [ + { + versionNumber: '0.1', + sourceType: ActorSourceType.SourceFiles, + buildTag: 'beta', + sourceFiles: [{ name: 'main.js', format: 'TEXT', content: 'console.log("Hello v0.1")' }], + }, + { + versionNumber: '0.2', + sourceType: ActorSourceType.SourceFiles, + buildTag: 'latest', + sourceFiles: [{ name: 'main.js', format: 'TEXT', content: 'console.log("Hello v0.2")' }], + }, + ], + }); + const actorClient = client.actor(createdActor.id); + + try { + // Two builds are needed: the default build cannot be deleted. + const firstBuild = await actorClient.build('0.1'); + const firstBuildClient = client.build(firstBuild.id); + await firstBuildClient.waitForFinish(); + + const secondBuild = await actorClient.build('0.2'); + const secondBuildClient = client.build(secondBuild.id); + + const finishedBuild = await secondBuildClient.waitForFinish(); + expect(['SUCCEEDED', 'FAILED']).toContain(finishedBuild.status); + + // Aborting an already finished build returns it in its current state rather than failing. + const abortedBuild = await secondBuildClient.abort(); + expect(['SUCCEEDED', 'FAILED']).toContain(abortedBuild.status); + + await firstBuildClient.delete(); + + await expect(firstBuildClient.get()).resolves.toBeUndefined(); + } finally { + await actorClient.delete(); + } +}); + +test('get() returns an actorDefinition whose minMemoryMbytes may be below 256', async () => { + const actor = await client.actor(SMALL_MIN_MEMORY_ACTOR).get(); + expect(actor).toBeDefined(); + + const build = await client.build(pickBuildId(actor!)).get(); + expect(build?.actorDefinition, 'expected an actorDefinition on a SUCCEEDED build').toBeDefined(); + + // Fixture-drift guard: this is only meaningful while the chosen build carries a value below the + // old 256 MB floor. + const { minMemoryMbytes } = build!.actorDefinition!; + expect( + minMemoryMbytes, + `${SMALL_MIN_MEMORY_ACTOR} latest build has minMemoryMbytes=${minMemoryMbytes} (expected <256). ` + + 'Pick a different fixture to keep this test meaningful.', + ).toBeLessThan(256); +}); + +test('get() returns an actorDefinition version in semver-triplet form', async () => { + const actor = await client.actor(SMALL_MIN_MEMORY_ACTOR).get(); + expect(actor).toBeDefined(); + + const build = await client.build(pickBuildId(actor!)).get(); + expect(build?.actorDefinition).toBeDefined(); + + // Fixture-drift guard: only meaningful while the chosen build's version carries more than one dot. + const { version } = build!.actorDefinition!; + expect( + version.split('.').length - 1, + `${SMALL_MIN_MEMORY_ACTOR} no longer publishes a multi-dot version (got ${version}) - ` + + 'pick a different fixture to keep this test meaningful.', + ).toBeGreaterThanOrEqual(2); +}); + +test('builds().list() returns builds whose meta.origin is CI', async () => { + const builds = await client.actor(CI_ORIGIN_ACTOR).builds().list({ limit: 100, desc: true }); + expect(builds.items.length, `${CI_ORIGIN_ACTOR} should have builds`).toBeGreaterThan(0); + + // Fixture-drift guard: only meaningful while the page actually contains a CI-origin build. + const ciOriginBuilds = builds.items.filter((build) => build.meta?.origin === 'CI'); + expect( + ciOriginBuilds.length, + `${CI_ORIGIN_ACTOR}: no builds with meta.origin === "CI" in the most recent 100. CI builds may ` + + 'have rotated out of the window - pick a different Actor or paginate deeper.', + ).toBeGreaterThan(0); +}); diff --git a/test/integration/dataset.test.ts b/test/integration/dataset.test.ts new file mode 100644 index 00000000..84fe5037 --- /dev/null +++ b/test/integration/dataset.test.ts @@ -0,0 +1,465 @@ +import { beforeAll, describe, expect, test } from 'vitest'; + +import type { ApifyClient, Dataset } from 'apify-client'; +import { DownloadItemsFormat } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { collectUntilPresent, getRandomResourceName, pollUntilCondition } from './_utils.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +async function createDataset(label = 'dataset'): Promise { + return client.datasets().getOrCreate(getRandomResourceName(label)); +} + +/** + * Wait until every pushed item is readable, since dataset writes are only eventually consistent. + * + * Also waits for the reported total to catch up, not just the returned items: paginated iteration + * derives its stop condition from the total, so a lagging one silently truncates the iteration. + */ +async function waitForItemCount(datasetId: string, expectedCount: number): Promise { + const page = await pollUntilCondition( + () => client.dataset(datasetId).listItems({ limit: Math.max(expectedCount, 1) }), + (result) => result.items.length === expectedCount && result.total === expectedCount, + ); + expect(page.items).toHaveLength(expectedCount); + expect(page.total).toBe(expectedCount); +} + +test('datasets().list() returns a page of the user datasets', async () => { + const datasetsPage = await client.datasets().list({ limit: 10 }); + + expect(Array.isArray(datasetsPage.items)).toBe(true); + expect(datasetsPage.limit).toBe(10); +}); + +test('datasets().list() honours limit and offset', async () => { + const datasetsPage = await client.datasets().list({ limit: 5, offset: 0 }); + + expect(Array.isArray(datasetsPage.items)).toBe(true); + expect(datasetsPage.limit).toBe(5); + expect(datasetsPage.offset).toBe(0); +}); + +test('datasets().getOrCreate() creates a named dataset and returns the existing one on a second call', async () => { + const uniqueName = getRandomResourceName('dataset'); + const dataset = await client.datasets().getOrCreate(uniqueName); + + try { + expect(dataset.name).toBe(uniqueName); + + const sameDataset = await client.datasets().getOrCreate(uniqueName); + expect(sameDataset.id).toBe(dataset.id); + } finally { + await client.dataset(dataset.id).delete(); + } +}); + +test('datasets().list() iterates the user datasets across pages', async () => { + const createdIds: string[] = []; + + try { + for (let i = 0; i < 3; i++) { + const dataset = await createDataset(); + createdIds.push(dataset.id); + } + + const collected = await collectUntilPresent( + () => client.datasets().list({ desc: true, limit: 50 }), + createdIds, + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + for (const createdId of createdIds) { + expect(collectedIds, `dataset ${createdId} is missing from the listing`).toContain(createdId); + } + } finally { + for (const id of createdIds) { + await client.dataset(id).delete(); + } + } +}); + +test('a created dataset is retrievable by id', async () => { + const datasetName = getRandomResourceName('dataset'); + const createdDataset = await client.datasets().getOrCreate(datasetName); + const datasetClient = client.dataset(createdDataset.id); + + try { + const retrievedDataset = await datasetClient.get(); + + expect(retrievedDataset?.id).toBe(createdDataset.id); + expect(retrievedDataset?.name).toBe(datasetName); + } finally { + await datasetClient.delete(); + } +}); + +test('update() renames a dataset and the new name persists', async () => { + const newName = getRandomResourceName('dataset-updated'); + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + const updatedDataset = await datasetClient.update({ name: newName }); + expect(updatedDataset.name).toBe(newName); + expect(updatedDataset.id).toBe(createdDataset.id); + + const retrievedDataset = await datasetClient.get(); + expect(retrievedDataset?.name).toBe(newName); + } finally { + await datasetClient.delete(); + } +}); + +test('get() resolves to undefined for a deleted dataset', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + await datasetClient.delete(); + + await expect(datasetClient.get()).resolves.toBeUndefined(); +}); + +test('pushItems() stores items that listItems() then returns', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + const itemsToPush = [ + { id: 1, name: 'Item 1', value: 100 }, + { id: 2, name: 'Item 2', value: 200 }, + { id: 3, name: 'Item 3', value: 300 }, + ]; + await datasetClient.pushItems(itemsToPush); + await waitForItemCount(createdDataset.id, 3); + + const itemsPage = await datasetClient.listItems(); + expect(itemsPage.count).toBe(3); + expect(itemsPage.items).toEqual(itemsToPush); + } finally { + await datasetClient.delete(); + } +}); + +test('pushItems() accepts a pre-serialized JSON string', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + const itemsToPush = [ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ]; + await datasetClient.pushItems(JSON.stringify(itemsToPush)); + await waitForItemCount(createdDataset.id, 2); + + const itemsPage = await datasetClient.listItems(); + expect(itemsPage.items).toEqual(itemsToPush); + } finally { + await datasetClient.delete(); + } +}); + +test('pushItems() round-trips a payload large enough to be compressed', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + // Well above the 1 KiB threshold at which the client compresses the request body, so this + // exercises the compressed-request path end to end against the API. + const itemsToPush = Array.from({ length: 50 }, (_, index) => ({ + index, + padding: 'x'.repeat(200), + })); + expect(Buffer.byteLength(JSON.stringify(itemsToPush))).toBeGreaterThan(1024); + + await datasetClient.pushItems(itemsToPush); + await waitForItemCount(createdDataset.id, 50); + + const itemsPage = await datasetClient.listItems({ limit: 50 }); + expect(itemsPage.items).toEqual(itemsToPush); + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() honours limit and offset', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems(Array.from({ length: 10 }, (_, index) => ({ index, value: index * 10 }))); + await waitForItemCount(createdDataset.id, 10); + + const firstPage = await datasetClient.listItems({ limit: 5 }); + expect(firstPage.items).toHaveLength(5); + expect(firstPage.count).toBe(5); + expect(firstPage.limit).toBe(5); + + const secondPage = await datasetClient.listItems({ offset: 5, limit: 5 }); + expect(secondPage.items).toHaveLength(5); + expect(secondPage.offset).toBe(5); + + expect(secondPage.items[0].index).not.toBe(firstPage.items[0].index); + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() with fields returns only the requested fields', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems([ + { id: 1, name: 'Item 1', value: 100, extra: 'data1' }, + { id: 2, name: 'Item 2', value: 200, extra: 'data2' }, + ]); + await waitForItemCount(createdDataset.id, 2); + + const itemsPage = await datasetClient.listItems({ fields: ['id', 'name'] }); + expect(itemsPage.items).toHaveLength(2); + + for (const item of itemsPage.items) { + expect(Object.keys(item).sort()).toEqual(['id', 'name']); + } + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() with desc reverses the item order', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems(Array.from({ length: 5 }, (_, index) => ({ idx: index }))); + await waitForItemCount(createdDataset.id, 5); + + const ascendingPage = await datasetClient.listItems(); + const descendingPage = await datasetClient.listItems({ desc: true }); + + expect(descendingPage.desc).toBe(true); + expect(descendingPage.items.map((item) => item.idx)).toEqual( + ascendingPage.items.map((item) => item.idx).reverse(), + ); + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() applies the omit, clean, skipHidden and skipEmpty filters', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + // A mix of regular, hidden (`#`-prefixed) and empty items, so each filter has something to drop. + const itemsToPush = [ + { id: 1, name: 'visible', '#secret': 'shh', extra: 'X' }, + {}, + { id: 2, name: 'also visible', '#secret': 'shh', extra: 'Y' }, + ]; + await datasetClient.pushItems(itemsToPush); + await waitForItemCount(createdDataset.id, itemsToPush.length); + + const omitPage = await datasetClient.listItems({ omit: ['extra'] }); + for (const item of omitPage.items) { + expect(item).not.toHaveProperty('extra'); + } + + const cleanPage = await datasetClient.listItems({ clean: true }); + for (const item of cleanPage.items) { + expect(Object.keys(item).length).toBeGreaterThan(0); + expect(item).not.toHaveProperty('#secret'); + } + + const skipHiddenPage = await datasetClient.listItems({ skipHidden: true }); + for (const item of skipHiddenPage.items) { + expect(item).not.toHaveProperty('#secret'); + } + + const skipEmptyPage = await datasetClient.listItems({ skipEmpty: true }); + expect(skipEmptyPage.items).toHaveLength(itemsToPush.filter((item) => Object.keys(item).length > 0).length); + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() is async-iterable and yields every item', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems(Array.from({ length: 5 }, (_, index) => ({ index }))); + await waitForItemCount(createdDataset.id, 5); + + const collected: Record[] = []; + for await (const item of datasetClient.listItems()) { + collected.push(item); + } + + expect(collected.map((item) => item.index)).toEqual([0, 1, 2, 3, 4]); + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() iteration pages through the dataset when chunkSize is smaller than the item count', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems(Array.from({ length: 12 }, (_, index) => ({ idx: index }))); + await waitForItemCount(createdDataset.id, 12); + + // A chunk size of 5 forces three underlying requests for 12 items. + const collected: Record[] = []; + for await (const item of datasetClient.listItems({ chunkSize: 5 })) { + collected.push(item); + } + + expect(collected).toHaveLength(12); + // Ordering across several paginated reads is not strictly guaranteed mid-flight, so compare + // the sorted view rather than positions. + expect(collected.map((item) => item.idx).sort((a, b) => a - b)).toEqual([...Array(12).keys()]); + } finally { + await datasetClient.delete(); + } +}); + +test('listItems() iteration applies the fields filter to every yielded item', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems( + Array.from({ length: 3 }, (_, index) => ({ id: index, name: `item-${index}`, extra: 'drop-me' })), + ); + await waitForItemCount(createdDataset.id, 3); + + const collected: Record[] = []; + for await (const item of datasetClient.listItems({ fields: ['id', 'name'] })) { + collected.push(item); + } + + expect(collected).toHaveLength(3); + for (const item of collected) { + expect(Object.keys(item).sort()).toEqual(['id', 'name']); + } + } finally { + await datasetClient.delete(); + } +}); + +test('getStatistics() returns per-field statistics', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems([ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ]); + await waitForItemCount(createdDataset.id, 2); + + const statistics = await datasetClient.getStatistics(); + + expect(statistics).toBeDefined(); + expect(statistics!.fieldStatistics).toBeTypeOf('object'); + } finally { + await datasetClient.delete(); + } +}); + +describe('downloadItems()', () => { + const items = [ + { id: 1, name: 'first' }, + { id: 2, name: 'second' }, + ]; + + test.for([ + { format: DownloadItemsFormat.JSON, id: 'json' }, + { format: DownloadItemsFormat.JSONL, id: 'jsonl' }, + { format: DownloadItemsFormat.CSV, id: 'csv' }, + { format: DownloadItemsFormat.XLSX, id: 'xlsx' }, + { format: DownloadItemsFormat.XML, id: 'xml' }, + { format: DownloadItemsFormat.RSS, id: 'rss' }, + { format: DownloadItemsFormat.HTML, id: 'html' }, + ])('serializes the items to $id', async ({ format }) => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + await datasetClient.pushItems(items); + await waitForItemCount(createdDataset.id, 2); + + const downloaded = await datasetClient.downloadItems(format); + expect(Buffer.isBuffer(downloaded)).toBe(true); + expect(downloaded.length).toBeGreaterThan(0); + + if (format === DownloadItemsFormat.JSON) { + expect(JSON.parse(downloaded.toString('utf8'))).toEqual(items); + } else if (format === DownloadItemsFormat.XLSX) { + // XLSX is a zip container, which always starts with the `PK` local file header. + expect(downloaded.subarray(0, 2).toString('latin1')).toBe('PK'); + } else { + // Every text format embeds the field values somewhere in its output. + const decoded = downloaded.toString('utf8'); + expect(decoded).toContain('first'); + expect(decoded).toContain('second'); + } + } finally { + await datasetClient.delete(); + } + }); +}); + +test('createItemsPublicUrl() returns a signed, never-expiring URL that serves the items', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + const items = Array.from({ length: 3 }, (_, index) => ({ id: index, value: index * 10 })); + await datasetClient.pushItems(items); + await waitForItemCount(createdDataset.id, 3); + + const publicUrl = await datasetClient.createItemsPublicUrl(); + expect(publicUrl).toContain(createdDataset.id); + expect(publicUrl).toContain('signature='); + + // Fetched with no credentials at all - the signature alone must authorize the read. + const response = await fetch(publicUrl); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual(items); + } finally { + await datasetClient.delete(); + } +}); + +test('createItemsPublicUrl() passes through expiry and listing options', async () => { + const createdDataset = await createDataset(); + const datasetClient = client.dataset(createdDataset.id); + + try { + const publicUrl = await datasetClient.createItemsPublicUrl({ + expiresInSecs: 2000, + limit: 10, + offset: 0, + }); + + expect(publicUrl).toContain('signature='); + expect(publicUrl).toContain('limit=10'); + expect(publicUrl).toContain('offset=0'); + + const response = await fetch(publicUrl); + expect(response.status).toBe(200); + } finally { + await datasetClient.delete(); + } +}); diff --git a/test/integration/key_value_store.test.ts b/test/integration/key_value_store.test.ts new file mode 100644 index 00000000..347988fc --- /dev/null +++ b/test/integration/key_value_store.test.ts @@ -0,0 +1,465 @@ +import type { Readable } from 'node:stream'; + +import { beforeAll, expect, test } from 'vitest'; + +import type { JsonValue } from 'type-fest'; + +import type { ApifyClient, KeyValueStore } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { collectUntilPresent, getRandomResourceName, pollUntilCondition } from './_utils.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +async function createStore(label = 'kvs'): Promise { + return client.keyValueStores().getOrCreate(getRandomResourceName(label)); +} + +/** Wait until a record written to the store is readable, since writes are only eventually consistent. */ +async function waitForRecord(storeId: string, key: string): Promise { + const exists = await pollUntilCondition(() => client.keyValueStore(storeId).recordExists(key)); + expect(exists, `record ${key} never became readable`).toBe(true); +} + +/** Wait until the store lists exactly `expectedCount` keys. */ +async function waitForKeyCount(storeId: string, expectedCount: number): Promise { + const keys = await pollUntilCondition( + () => client.keyValueStore(storeId).listKeys(), + (result) => result.items.length === expectedCount, + ); + expect(keys.items).toHaveLength(expectedCount); +} + +async function readStream(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +test('keyValueStores().list() returns a page of the user stores', async () => { + const storesPage = await client.keyValueStores().list({ limit: 10 }); + + expect(Array.isArray(storesPage.items)).toBe(true); + expect(storesPage.limit).toBe(10); +}); + +test('keyValueStores().list() honours limit and offset', async () => { + const storesPage = await client.keyValueStores().list({ limit: 5, offset: 0 }); + + expect(Array.isArray(storesPage.items)).toBe(true); + expect(storesPage.limit).toBe(5); + expect(storesPage.offset).toBe(0); +}); + +test('keyValueStores().getOrCreate() creates a named store and returns the existing one on a second call', async () => { + const uniqueName = getRandomResourceName('kvs'); + const store = await client.keyValueStores().getOrCreate(uniqueName); + + try { + expect(store.name).toBe(uniqueName); + + const sameStore = await client.keyValueStores().getOrCreate(uniqueName); + expect(sameStore.id).toBe(store.id); + } finally { + await client.keyValueStore(store.id).delete(); + } +}); + +test('keyValueStores().list() iterates the user stores across pages', async () => { + const createdIds: string[] = []; + + try { + for (let i = 0; i < 3; i++) { + const store = await createStore(); + createdIds.push(store.id); + } + + const collected = await collectUntilPresent( + () => client.keyValueStores().list({ desc: true, limit: 50 }), + createdIds, + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + for (const createdId of createdIds) { + expect(collectedIds, `store ${createdId} is missing from the listing`).toContain(createdId); + } + } finally { + for (const id of createdIds) { + await client.keyValueStore(id).delete(); + } + } +}); + +test('a created store is retrievable by id', async () => { + const storeName = getRandomResourceName('kvs'); + const createdStore = await client.keyValueStores().getOrCreate(storeName); + const storeClient = client.keyValueStore(createdStore.id); + + try { + const retrievedStore = await storeClient.get(); + + expect(retrievedStore?.id).toBe(createdStore.id); + expect(retrievedStore?.name).toBe(storeName); + } finally { + await storeClient.delete(); + } +}); + +test('update() renames a store and the new name persists', async () => { + const newName = getRandomResourceName('kvs-updated'); + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + const updatedStore = await storeClient.update({ name: newName }); + expect(updatedStore.name).toBe(newName); + expect(updatedStore.id).toBe(createdStore.id); + + const retrievedStore = await storeClient.get(); + expect(retrievedStore?.name).toBe(newName); + } finally { + await storeClient.delete(); + } +}); + +test('get() resolves to undefined for a deleted store', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + await storeClient.delete(); + + await expect(storeClient.get()).resolves.toBeUndefined(); +}); + +test('setRecord() stores a JSON value that getRecord() returns parsed', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + const testValue = { name: 'Test Item', value: 123, nested: { data: 'value' } }; + await storeClient.setRecord({ key: 'test-key', value: testValue }); + await waitForRecord(createdStore.id, 'test-key'); + + const record = await storeClient.getRecord('test-key'); + expect(record?.key).toBe('test-key'); + expect(record?.value).toEqual(testValue); + expect(record?.contentType).toContain('application/json'); + } finally { + await storeClient.delete(); + } +}); + +test('setRecord() stores a text value under an explicit content type', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + const testText = 'Hello, this is a test text!'; + await storeClient.setRecord({ key: 'text-key', value: testText, contentType: 'text/plain' }); + await waitForRecord(createdStore.id, 'text-key'); + + const record = await storeClient.getRecord('text-key'); + expect(record?.key).toBe('text-key'); + expect(record?.value).toBe(testText); + expect(record?.contentType).toContain('text/plain'); + } finally { + await storeClient.delete(); + } +}); + +test('setRecord() stores binary data that getRecord() returns byte-for-byte with the buffer option', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + const binaryValue = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from('fake-png-bytes'), + ]); + // `setRecord` accepts a Buffer at runtime, but its `value` is typed as `JsonValue`, so the + // binary case needs a cast. + await storeClient.setRecord({ + key: 'image.png', + value: binaryValue as unknown as JsonValue, + contentType: 'image/png', + }); + await waitForRecord(createdStore.id, 'image.png'); + + const record = await storeClient.getRecord('image.png', { buffer: true }); + expect(record?.key).toBe('image.png'); + expect(Buffer.isBuffer(record?.value)).toBe(true); + expect(record!.value.equals(binaryValue)).toBe(true); + expect(record?.contentType).toContain('image/png'); + } finally { + await storeClient.delete(); + } +}); + +test('getRecord() with the stream option yields the record body as a readable stream', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + await storeClient.setRecord({ key: 'stream-key', value: { data: 'streamed' } }); + await waitForRecord(createdStore.id, 'stream-key'); + + const record = await storeClient.getRecord('stream-key', { stream: true }); + expect(record).toBeDefined(); + + const body = await readStream(record!.value); + expect(JSON.parse(body.toString('utf8'))).toEqual({ data: 'streamed' }); + } finally { + await storeClient.delete(); + } +}); + +test('getRecord() resolves to undefined for a missing key', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + await expect(storeClient.getRecord('never-written')).resolves.toBeUndefined(); + } finally { + await storeClient.delete(); + } +}); + +test('recordExists() distinguishes a written key from a missing one', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + await storeClient.setRecord({ key: 'exists-key', value: { data: 'value' } }); + await waitForRecord(createdStore.id, 'exists-key'); + + await expect(storeClient.recordExists('exists-key')).resolves.toBe(true); + await expect(storeClient.recordExists('non-existent-key')).resolves.toBe(false); + } finally { + await storeClient.delete(); + } +}); + +test('deleteRecord() removes a record from the store', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + await storeClient.setRecord({ key: 'delete-me', value: { data: 'value' } }); + await waitForRecord(createdStore.id, 'delete-me'); + + await storeClient.deleteRecord('delete-me'); + + const record = await pollUntilCondition( + () => storeClient.getRecord('delete-me'), + (result) => result === undefined, + ); + expect(record).toBeUndefined(); + } finally { + await storeClient.delete(); + } +}); + +test('listKeys() returns every key in the store', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + for (let i = 0; i < 5; i++) { + await storeClient.setRecord({ key: `key-${i}`, value: { index: i } }); + } + await waitForKeyCount(createdStore.id, 5); + + const keysResult = await storeClient.listKeys(); + const keyNames = keysResult.items.map((item) => item.key); + for (let i = 0; i < 5; i++) { + expect(keyNames).toContain(`key-${i}`); + } + } finally { + await storeClient.delete(); + } +}); + +test('listKeys() honours limit', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + for (let i = 0; i < 10; i++) { + await storeClient.setRecord({ key: `item-${String(i).padStart(2, '0')}`, value: { index: i } }); + } + await waitForKeyCount(createdStore.id, 10); + + const keysResult = await storeClient.listKeys({ limit: 5 }); + expect(keysResult.items).toHaveLength(5); + expect(keysResult.isTruncated).toBe(true); + } finally { + await storeClient.delete(); + } +}); + +test('listKeys() paginates from exclusiveStartKey without repeating keys', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + // Zero-padded names keep the lexicographic order predictable. + for (let i = 0; i < 5; i++) { + await storeClient.setRecord({ key: `key-${String(i).padStart(2, '0')}`, value: { idx: i } }); + } + await waitForKeyCount(createdStore.id, 5); + + const firstPage = await storeClient.listKeys({ limit: 2 }); + expect(firstPage.items).toHaveLength(2); + + const lastKeyOfFirst = firstPage.items.at(-1)!.key; + const secondPage = await storeClient.listKeys({ exclusiveStartKey: lastKeyOfFirst }); + + const firstKeys = new Set(firstPage.items.map((item) => item.key)); + for (const item of secondPage.items) { + expect(firstKeys).not.toContain(item.key); + } + } finally { + await storeClient.delete(); + } +}); + +test('listKeys() is async-iterable and yields every key', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + for (let i = 0; i < 5; i++) { + await storeClient.setRecord({ key: `key-${i}`, value: { index: i } }); + } + await waitForKeyCount(createdStore.id, 5); + + const collectedKeys: string[] = []; + for await (const item of storeClient.listKeys()) { + collectedKeys.push(item.key); + } + + expect(collectedKeys).toHaveLength(5); + for (let i = 0; i < 5; i++) { + expect(collectedKeys).toContain(`key-${i}`); + } + } finally { + await storeClient.delete(); + } +}); + +test('listKeys() iteration stops at the requested limit', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + for (let i = 0; i < 10; i++) { + await storeClient.setRecord({ key: `item-${String(i).padStart(2, '0')}`, value: { index: i } }); + } + await waitForKeyCount(createdStore.id, 10); + + const collectedKeys: string[] = []; + for await (const item of storeClient.listKeys({ limit: 5 })) { + collectedKeys.push(item.key); + } + + expect(collectedKeys).toHaveLength(5); + } finally { + await storeClient.delete(); + } +}); + +test('listKeys() iteration applies the prefix filter', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + for (let i = 0; i < 3; i++) { + await storeClient.setRecord({ key: `prefix-a-${i}`, value: { type: 'a', index: i } }); + } + for (let i = 0; i < 2; i++) { + await storeClient.setRecord({ key: `prefix-b-${i}`, value: { type: 'b', index: i } }); + } + await waitForKeyCount(createdStore.id, 5); + + const collectedKeys: string[] = []; + for await (const item of storeClient.listKeys({ prefix: 'prefix-a-' })) { + collectedKeys.push(item.key); + } + + expect(collectedKeys).toHaveLength(3); + for (const key of collectedKeys) { + expect(key.startsWith('prefix-a-')).toBe(true); + } + } finally { + await storeClient.delete(); + } +}); + +test('getRecordPublicUrl() returns a signed URL that serves the record', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + await storeClient.setRecord({ key: 'my-record', value: { hello: 'world' } }); + await waitForRecord(createdStore.id, 'my-record'); + + const publicUrl = await storeClient.getRecordPublicUrl('my-record'); + expect(publicUrl).toContain(createdStore.id); + expect(publicUrl).toContain('my-record'); + + // Fetched with no credentials at all - the signature alone must authorize the read. + const response = await fetch(publicUrl); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ hello: 'world' }); + } finally { + await storeClient.delete(); + } +}); + +test('createKeysPublicUrl() returns a signed URL that lists the keys', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + for (let i = 0; i < 3; i++) { + await storeClient.setRecord({ key: `key-${i}`, value: { idx: i } }); + } + await waitForKeyCount(createdStore.id, 3); + + const publicUrl = await storeClient.createKeysPublicUrl({ limit: 10, expiresInSecs: 300 }); + expect(publicUrl).toContain(createdStore.id); + expect(publicUrl).toContain('signature='); + + const response = await fetch(publicUrl); + expect(response.status).toBe(200); + + const body = (await response.json()) as { data?: { items?: { key: string }[] } }; + const keyNames = (body.data?.items ?? []).map((item) => item.key); + for (let i = 0; i < 3; i++) { + expect(keyNames).toContain(`key-${i}`); + } + } finally { + await storeClient.delete(); + } +}); + +test('createKeysPublicUrl() returns a never-expiring signed URL', async () => { + const createdStore = await createStore(); + const storeClient = client.keyValueStore(createdStore.id); + + try { + const publicUrl = await storeClient.createKeysPublicUrl(); + expect(publicUrl).toContain('signature='); + + const response = await fetch(publicUrl); + expect(response.status).toBe(200); + } finally { + await storeClient.delete(); + } +}); diff --git a/test/integration/log.test.ts b/test/integration/log.test.ts new file mode 100644 index 00000000..076580f0 --- /dev/null +++ b/test/integration/log.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ApifyClient } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { NO_LOG_REDIRECT } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +test('get() returns the log of an Actor run as a string', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const log = await runClient.log().get(); + + expect(typeof log).toBe('string'); + expect(log!.length).toBeGreaterThan(0); + } finally { + await runClient.delete(); + } +}); + +test('get({ raw: true }) returns the log without the client-side processing', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const rawLog = await runClient.log().get({ raw: true }); + + expect(typeof rawLog).toBe('string'); + expect(rawLog!.length).toBeGreaterThan(0); + } finally { + await runClient.delete(); + } +}); + +test('get() returns the log of a build', async () => { + const buildsPage = await client.actor(HELLO_WORLD_ACTOR).builds().list({ limit: 1 }); + expect(buildsPage.items.length, `${HELLO_WORLD_ACTOR} should have at least one build`).toBeGreaterThan(0); + + const log = await client.build(buildsPage.items[0].id).log().get(); + + // A build log can legitimately be empty, so only its presence is pinned - `get()` answers with + // `undefined` on a 404, which is the failure this is here to catch. + expect(log).toBeDefined(); +}); + +test('stream() returns a readable stream of the run log', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const stream = await runClient.log().stream(); + expect(stream, 'stream() returned nothing in a Node.js environment').toBeDefined(); + + const chunks: Buffer[] = []; + for await (const chunk of stream!) { + chunks.push(Buffer.from(chunk)); + } + + expect(Buffer.concat(chunks).length).toBeGreaterThan(0); + } finally { + await runClient.delete(); + } +}); diff --git a/test/integration/request_queue.test.ts b/test/integration/request_queue.test.ts new file mode 100644 index 00000000..21194498 --- /dev/null +++ b/test/integration/request_queue.test.ts @@ -0,0 +1,560 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ApifyClient, RequestQueue, RequestQueueClient, RequestQueueClientGetRequestResult } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { collectUntilPresent, getRandomResourceName, pollUntilCondition, randomId } from './_utils.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +async function createQueue(label = 'queue'): Promise { + return client.requestQueues().getOrCreate(getRandomResourceName(label)); +} + +/** + * `getRequest()` returns `userData`, but `RequestQueueClientGetRequestResult` does not declare it. + * Read it through this shape so the round-trip assertion stays honest until the type catches up. + */ +type RequestWithUserData = RequestQueueClientGetRequestResult & { userData?: Record }; + +/** + * Poll the queue until `expectedCount` requests are visible. + * + * Uses `listHead()`, which has no side effects, so polling does not lock anything - locking here + * would leave the tests that exercise locks with an ambiguous count. + */ +async function ensureQueueIsPopulated(queueClient: RequestQueueClient, expectedCount: number): Promise { + const head = await pollUntilCondition( + () => queueClient.listHead({ limit: expectedCount }), + (result) => result.items.length === expectedCount, + ); + expect(head.items).toHaveLength(expectedCount); +} + +test('requestQueues().list() returns a page of the user queues', async () => { + const queuesPage = await client.requestQueues().list({ limit: 10 }); + + expect(Array.isArray(queuesPage.items)).toBe(true); + expect(queuesPage.limit).toBe(10); +}); + +test('requestQueues().list() honours limit and offset', async () => { + const queuesPage = await client.requestQueues().list({ limit: 5, offset: 0 }); + + expect(Array.isArray(queuesPage.items)).toBe(true); + expect(queuesPage.limit).toBe(5); + expect(queuesPage.offset).toBe(0); +}); + +test('requestQueues().getOrCreate() creates a named queue and returns the existing one on a second call', async () => { + const uniqueName = getRandomResourceName('rq'); + const queue = await client.requestQueues().getOrCreate(uniqueName); + + try { + expect(queue.name).toBe(uniqueName); + + const sameQueue = await client.requestQueues().getOrCreate(uniqueName); + expect(sameQueue.id).toBe(queue.id); + } finally { + await client.requestQueue(queue.id).delete(); + } +}); + +test('requestQueues().list() iterates the user queues across pages', async () => { + const createdIds: string[] = []; + + try { + for (let i = 0; i < 3; i++) { + const queue = await createQueue('rq'); + createdIds.push(queue.id); + } + + const collected = await collectUntilPresent( + () => client.requestQueues().list({ desc: true, limit: 50 }), + createdIds, + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + for (const createdId of createdIds) { + expect(collectedIds, `queue ${createdId} is missing from the listing`).toContain(createdId); + } + } finally { + for (const id of createdIds) { + await client.requestQueue(id).delete(); + } + } +}); + +test('a created queue is retrievable by id', async () => { + const queueName = getRandomResourceName('queue'); + const createdQueue = await client.requestQueues().getOrCreate(queueName); + const queueClient = client.requestQueue(createdQueue.id); + + try { + expect(createdQueue.name).toBe(queueName); + + const retrievedQueue = await queueClient.get(); + expect(retrievedQueue?.id).toBe(createdQueue.id); + expect(retrievedQueue?.name).toBe(queueName); + } finally { + await queueClient.delete(); + } +}); + +test('update() renames a queue and the new name persists', async () => { + const newName = getRandomResourceName('queue-updated'); + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const updatedQueue = await queueClient.update({ name: newName }); + expect(updatedQueue.name).toBe(newName); + expect(updatedQueue.id).toBe(createdQueue.id); + + const retrievedQueue = await queueClient.get(); + expect(retrievedQueue?.name).toBe(newName); + } finally { + await queueClient.delete(); + } +}); + +test('get() resolves to undefined for a deleted queue', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + await queueClient.delete(); + + await expect(queueClient.get()).resolves.toBeUndefined(); +}); + +test('addRequest() registers a request that getRequest() then returns', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const addResult = await queueClient.addRequest({ + url: 'https://example.com/test', + uniqueKey: 'test-key-1', + method: 'GET', + }); + expect(addResult.requestId).toBeTruthy(); + expect(addResult.wasAlreadyPresent).toBe(false); + + const request = await pollUntilCondition( + () => queueClient.getRequest(addResult.requestId), + (result) => result !== undefined, + ); + expect(request?.url).toBe('https://example.com/test'); + expect(request?.uniqueKey).toBe('test-key-1'); + } finally { + await queueClient.delete(); + } +}); + +test('updateRequest() changes the method and user data of an existing request', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const addResult = await queueClient.addRequest({ + url: 'https://example.com/original', + uniqueKey: 'update-test', + method: 'GET', + }); + + const originalRequest = await pollUntilCondition( + () => queueClient.getRequest(addResult.requestId), + (result) => result !== undefined, + ); + expect(originalRequest).toBeDefined(); + + const updateResult = await queueClient.updateRequest({ + id: addResult.requestId, + url: originalRequest!.url, + uniqueKey: originalRequest!.uniqueKey, + method: 'POST', + userData: { updated: true }, + }); + expect(updateResult.requestId).toBe(addResult.requestId); + + const updatedRequest = (await pollUntilCondition( + () => queueClient.getRequest(addResult.requestId), + (result) => result?.method === 'POST', + )) as RequestWithUserData | undefined; + expect(updatedRequest?.method).toBe('POST'); + expect(updatedRequest?.userData).toEqual({ updated: true }); + } finally { + await queueClient.delete(); + } +}); + +test('deleteRequest() removes a request from the queue', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const addResult = await queueClient.addRequest({ + url: 'https://example.com/to-delete', + uniqueKey: 'delete-me', + }); + + await pollUntilCondition( + () => queueClient.getRequest(addResult.requestId), + (result) => result !== undefined, + ); + + await queueClient.deleteRequest(addResult.requestId); + + const deletedRequest = await pollUntilCondition( + () => queueClient.getRequest(addResult.requestId), + (result) => result === undefined, + ); + expect(deletedRequest).toBeUndefined(); + } finally { + await queueClient.delete(); + } +}); + +test('listHead() returns requests from the head of the queue', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + for (let i = 0; i < 5; i++) { + await queueClient.addRequest({ url: `https://example.com/page-${i}`, uniqueKey: `page-${i}` }); + } + + const head = await pollUntilCondition( + () => queueClient.listHead({ limit: 3 }), + (result) => result.items.length === 3, + ); + expect(head.items).toHaveLength(3); + } finally { + await queueClient.delete(); + } +}); + +test('listRequests() returns every request in the queue', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + for (let i = 0; i < 5; i++) { + await queueClient.addRequest({ url: `https://example.com/item-${i}`, uniqueKey: `item-${i}` }); + } + + const listResult = await pollUntilCondition( + () => queueClient.listRequests(), + (result) => result.items.length === 5, + ); + expect(listResult.items).toHaveLength(5); + } finally { + await queueClient.delete(); + } +}); + +test('listRequests() paginates via the opaque cursor without repeating requests', async () => { + const createdQueue = await createQueue('rq'); + const queueClient = client.requestQueue(createdQueue.id); + + try { + for (let i = 0; i < 5; i++) { + await queueClient.addRequest({ url: `https://example.com/p-${i}`, uniqueKey: `u-${i}` }); + } + await ensureQueueIsPopulated(queueClient, 5); + + const firstPage = await queueClient.listRequests({ limit: 2 }); + expect(firstPage.items).toHaveLength(2); + + // With 5 requests and a limit of 2, the API must hand back a continuation token. + expect(firstPage.nextCursor).toBeTruthy(); + + const secondPage = await queueClient.listRequests({ limit: 10, cursor: firstPage.nextCursor }); + const firstIds = new Set(firstPage.items.map((item) => item.id)); + for (const item of secondPage.items) { + expect(firstIds).not.toContain(item.id); + } + } finally { + await queueClient.delete(); + } +}); + +test('listRequests() with the pending filter returns the unhandled requests', async () => { + const createdQueue = await createQueue('rq'); + const queueClient = client.requestQueue(createdQueue.id); + + try { + for (let i = 0; i < 3; i++) { + await queueClient.addRequest({ url: `https://example.com/f-${i}`, uniqueKey: `f-${i}` }); + } + await ensureQueueIsPopulated(queueClient, 3); + + const pendingPage = await queueClient.listRequests({ filter: ['pending'] }); + expect(pendingPage.items).toHaveLength(3); + } finally { + await queueClient.delete(); + } +}); + +test('listRequests() iteration stops at the requested limit', async () => { + const createdQueue = await createQueue('rq'); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const addedUrls: string[] = []; + for (let i = 0; i < 7; i++) { + const url = `https://example.com/page-${i}`; + await queueClient.addRequest({ url, uniqueKey: `unique-${i}` }); + addedUrls.push(url); + } + await ensureQueueIsPopulated(queueClient, 7); + + const collected: { url: string }[] = []; + for await (const request of queueClient.listRequests({ limit: 3 })) { + collected.push(request); + } + + // The limit caps the iteration, so only the first page's worth is yielded. + expect(collected).toHaveLength(3); + for (const request of collected) { + expect(addedUrls).toContain(request.url); + } + } finally { + await queueClient.delete(); + } +}); + +test('paginateRequests() yields pages sized by maxPageLimit until the queue is drained', async () => { + const createdQueue = await createQueue('rq'); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const addedUrls: string[] = []; + for (let i = 0; i < 7; i++) { + const url = `https://example.com/page-${i}`; + await queueClient.addRequest({ url, uniqueKey: `unique-${i}` }); + addedUrls.push(url); + } + await ensureQueueIsPopulated(queueClient, 7); + + const collectedUrls: string[] = []; + const pageSizes: number[] = []; + for await (const page of queueClient.paginateRequests({ maxPageLimit: 3 })) { + pageSizes.push(page.items.length); + collectedUrls.push(...page.items.map((item) => item.url)); + } + + expect(collectedUrls.sort()).toEqual(addedUrls.sort()); + // 7 requests in pages of at most 3 means no page may exceed the requested size. + for (const size of pageSizes) { + expect(size).toBeLessThanOrEqual(3); + } + } finally { + await queueClient.delete(); + } +}); + +test('batchAddRequests() registers every request in one call', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + const requestsToAdd = Array.from({ length: 10 }, (_, index) => ({ + url: `https://example.com/batch-${index}`, + uniqueKey: `batch-${index}`, + })); + const batchResult = await queueClient.batchAddRequests(requestsToAdd); + expect(batchResult.processedRequests).toHaveLength(10); + expect(batchResult.unprocessedRequests).toHaveLength(0); + + const listResult = await pollUntilCondition( + () => queueClient.listRequests(), + (result) => result.items.length === 10, + ); + expect(listResult.items).toHaveLength(10); + } finally { + await queueClient.delete(); + } +}); + +test('batchDeleteRequests() removes the requests it is given by unique key', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id); + + try { + for (let i = 0; i < 10; i++) { + await queueClient.addRequest({ url: `https://example.com/delete-${i}`, uniqueKey: `delete-${i}` }); + } + + const listResult = await pollUntilCondition( + () => queueClient.listRequests(), + (result) => result.items.length === 10, + ); + expect(listResult.items).toHaveLength(10); + + const requestsToDelete = listResult.items.slice(0, 5).map((item) => ({ uniqueKey: item.uniqueKey })); + const deleteResult = await queueClient.batchDeleteRequests(requestsToDelete); + expect(deleteResult.processedRequests).toHaveLength(5); + + const remaining = await pollUntilCondition( + () => queueClient.listRequests(), + (result) => result.items.length === 5, + ); + expect(remaining.items).toHaveLength(5); + } finally { + await queueClient.delete(); + } +}); + +test('listAndLockHead() locks the requests it returns', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id, { clientKey: randomId(10) }); + + try { + for (let i = 0; i < 5; i++) { + await queueClient.addRequest({ url: `https://example.com/lock-${i}`, uniqueKey: `lock-${i}` }); + } + await ensureQueueIsPopulated(queueClient, 5); + + const lockResult = await queueClient.listAndLockHead({ limit: 3, lockSecs: 60 }); + expect(lockResult.items).toHaveLength(3); + + for (const lockedRequest of lockResult.items) { + expect(lockedRequest.id).toBeTruthy(); + expect(lockedRequest.lockExpiresAt).toBeDefined(); + } + } finally { + await queueClient.delete(); + } +}); + +test('prolongRequestLock() pushes the lock expiry further out', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id, { clientKey: randomId(10) }); + + try { + await queueClient.addRequest({ url: 'https://example.com/prolong', uniqueKey: 'prolong-test' }); + + await ensureQueueIsPopulated(queueClient, 1); + + const lockResult = await queueClient.listAndLockHead({ limit: 1, lockSecs: 60 }); + expect(lockResult.items).toHaveLength(1); + const lockedRequest = lockResult.items[0]; + const originalLockExpiresAt = lockedRequest.lockExpiresAt!; + + const prolongResult = await queueClient.prolongRequestLock(lockedRequest.id, { lockSecs: 120 }); + expect(prolongResult.lockExpiresAt.getTime()).toBeGreaterThan(originalLockExpiresAt.getTime()); + } finally { + await queueClient.delete(); + } +}); + +test('deleteRequestLock() releases the lock and leaves the request in place', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id, { clientKey: randomId(10) }); + + try { + await queueClient.addRequest({ url: 'https://example.com/unlock', uniqueKey: 'unlock-test' }); + + await ensureQueueIsPopulated(queueClient, 1); + + const lockResult = await queueClient.listAndLockHead({ limit: 1, lockSecs: 60 }); + expect(lockResult.items).toHaveLength(1); + const lockedRequest = lockResult.items[0]; + + await queueClient.deleteRequestLock(lockedRequest.id); + + await expect(queueClient.getRequest(lockedRequest.id)).resolves.toBeDefined(); + + // The request is back at the head, which it would not be while a lock was still held. + const head = await pollUntilCondition( + () => queueClient.listAndLockHead({ limit: 1, lockSecs: 5 }), + (result) => result.items.length === 1, + ); + expect(head.items[0].id).toBe(lockedRequest.id); + } finally { + await queueClient.delete(); + } +}); + +test('deleteRequestLock() accepts the forefront option', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id, { clientKey: randomId(10) }); + + try { + for (let i = 0; i < 2; i++) { + await queueClient.addRequest({ url: `https://example.com/forefront-${i}`, uniqueKey: `forefront-${i}` }); + } + await ensureQueueIsPopulated(queueClient, 2); + + const lockResult = await queueClient.listAndLockHead({ limit: 2, lockSecs: 60 }); + expect(lockResult.items).toHaveLength(2); + + await queueClient.deleteRequestLock(lockResult.items[0].id, { forefront: true }); + + await expect(queueClient.getRequest(lockResult.items[0].id)).resolves.toBeDefined(); + } finally { + await queueClient.delete(); + } +}); + +test('unlockRequests() releases every lock held by this client', async () => { + const createdQueue = await createQueue(); + const queueClient = client.requestQueue(createdQueue.id, { clientKey: randomId(10) }); + + try { + for (let i = 0; i < 5; i++) { + await queueClient.addRequest({ url: `https://example.com/unlock-${i}`, uniqueKey: `unlock-${i}` }); + } + await ensureQueueIsPopulated(queueClient, 5); + + const lockResult = await queueClient.listAndLockHead({ limit: 3, lockSecs: 60 }); + expect(lockResult.items).toHaveLength(3); + const lockedIds = new Set(lockResult.items.map((item) => item.id)); + + // Locks are acknowledged before they become visible to later reads, so unlocking straight away can + // see fewer locks than were just taken. Locked requests drop out of the queue head, so wait until + // the locked IDs are gone from it. + await pollUntilCondition(async () => { + const head = await queueClient.listHead({ limit: 5 }); + return head.items.every((item) => !lockedIds.has(item.id)); + }); + + const unlockResult = await queueClient.unlockRequests(); + expect(unlockResult.unlockedCount).toBe(3); + } finally { + await queueClient.delete(); + } +}); + +test('locks can be taken, released and prolonged across a batch of requests', async () => { + const createdQueue = await createQueue('queue'); + const queueClient = client.requestQueue(createdQueue.id, { clientKey: randomId(10) }); + + try { + for (let i = 0; i < 15; i++) { + await queueClient.addRequest({ url: `http://test-lock.com/${i}`, uniqueKey: `http://test-lock.com/${i}` }); + } + + await ensureQueueIsPopulated(queueClient, 15); + + const lockResult = await queueClient.listAndLockHead({ limit: 10, lockSecs: 10 }); + expect(lockResult.items).toHaveLength(10); + + for (const lockedRequest of lockResult.items) { + expect(lockedRequest.lockExpiresAt).toBeDefined(); + } + + await queueClient.deleteRequestLock(lockResult.items[1].id); + await queueClient.deleteRequestLock(lockResult.items[2].id, { forefront: true }); + + const prolongResult = await queueClient.prolongRequestLock(lockResult.items[3].id, { lockSecs: 15 }); + expect(prolongResult.lockExpiresAt).toBeDefined(); + } finally { + await queueClient.delete(); + } +}); diff --git a/test/integration/run.test.ts b/test/integration/run.test.ts new file mode 100644 index 00000000..0fed972f --- /dev/null +++ b/test/integration/run.test.ts @@ -0,0 +1,331 @@ +import { beforeAll, expect, test } from 'vitest'; + +import { Log, LogLevel } from '@apify/log'; + +import type { ActorRun, ActorRunListItem, ApifyClient, RunClient } from 'apify-client'; +import { ApifyApiError } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { NO_LOG_REDIRECT, pollUntilCondition } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +/** + * Wait until the run has actually left `READY`, i.e. its container has started. + * + * Startup time varies by orders of magnitude - a second when the platform is warm, close to a minute + * when it is not - so this backs off instead of polling at a fixed rate. + */ +async function waitUntilStarted(runClient: RunClient): Promise { + return pollUntilCondition( + async () => runClient.get(), + (run) => run !== undefined && run.status !== 'READY', + { timeoutSecs: 120, backoffFactor: 2 }, + ); +} + +/** `'already finished'` errors are expected in the races below; anything else is a real failure. */ +function rethrowUnlessAlreadyFinished(err: unknown): void { + if (!(err instanceof ApifyApiError) || !err.message.includes('already finished')) throw err; +} + +test('runs().list() filters by a single status and by a list of statuses', async () => { + const createdRunIds: string[] = []; + + try { + // One run of each status the filter is about to ask for. + const actorClient = client.actor(HELLO_WORLD_ACTOR); + createdRunIds.push((await actorClient.call(undefined, NO_LOG_REDIRECT)).id); + createdRunIds.push((await actorClient.call(undefined, { timeout: 1, ...NO_LOG_REDIRECT })).id); + + const runCollection = actorClient.runs(); + + // A filtered page has to be non-empty for the `every` assertions below to mean anything, and a + // just-finished run takes a moment to reach the listing. + const multipleStatusRuns = await pollUntilCondition( + () => runCollection.list({ status: ['SUCCEEDED', 'TIMED-OUT'] }), + (page) => page.items.length > 0, + ); + expect(multipleStatusRuns.items.length).toBeGreaterThan(0); + expect(multipleStatusRuns.items.every((run) => ['SUCCEEDED', 'TIMED-OUT'].includes(run.status))).toBe(true); + + const singleStatusRuns = await pollUntilCondition( + () => runCollection.list({ status: 'SUCCEEDED' }), + (page) => page.items.length > 0, + ); + expect(singleStatusRuns.items.length).toBeGreaterThan(0); + expect(singleStatusRuns.items.every((run) => run.status === 'SUCCEEDED')).toBe(true); + } finally { + for (const runId of createdRunIds) { + await client.run(runId).delete(); + } + } +}); + +test('runs().list() accepts the date range both as a Date and as an ISO 8601 string', async () => { + // No runs are created here: the assertion is that the client serializes both forms into a request + // the API accepts, which holds whether or not the resulting page is empty. + const date = new Date(Date.UTC(2100, 0, 1)); + const isoDate = date.toISOString(); + + await expect(client.runs().list({ startedBefore: date, startedAfter: date })).resolves.toBeDefined(); + await expect(client.runs().list({ startedBefore: isoDate, startedAfter: isoDate })).resolves.toBeDefined(); +}); + +test('a finished run can be read back and then deleted', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + const retrievedRun = await runClient.get(); + expect(retrievedRun?.id).toBe(run.id); + expect(retrievedRun?.status).toBe('SUCCEEDED'); + + await runClient.delete(); + + await expect(runClient.get()).resolves.toBeUndefined(); +}); + +test('get() resolves to undefined for a run that does not exist', async () => { + await expect(client.run('NoNExIsTeNtRuNiD123').get()).resolves.toBeUndefined(); +}); + +test('dataset() addresses the default dataset of the run', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const dataset = await runClient.dataset().get(); + + expect(dataset?.id).toBe(run.defaultDatasetId); + } finally { + await runClient.delete(); + } +}); + +test('keyValueStore() addresses the default key-value store of the run', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const kvs = await runClient.keyValueStore().get(); + + expect(kvs?.id).toBe(run.defaultKeyValueStoreId); + } finally { + await runClient.delete(); + } +}); + +test('requestQueue() addresses the default request queue of the run', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const requestQueue = await runClient.requestQueue().get(); + + expect(requestQueue?.id).toBe(run.defaultRequestQueueId); + } finally { + await runClient.delete(); + } +}); + +test('getStreamedLog() streams the log of a real run', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + // Mock-server coverage pins how chunks are split into lines; this pins that the live stream + // reaches that code at all, which a mock can never show. + const lines: string[] = []; + const toLog = new Log({ level: LogLevel.DEBUG }); + toLog.info = (message: string) => { + lines.push(message); + }; + + const streamedLog = await runClient.getStreamedLog({ toLog, fromStart: true }); + expect(streamedLog).toBeDefined(); + + streamedLog!.start(); + await pollUntilCondition( + async () => lines.length, + (count) => count > 0, + { timeoutSecs: 30 }, + ); + await streamedLog!.stop(); + + expect(lines.length, 'the streamed log of a finished run yielded no lines').toBeGreaterThan(0); + } finally { + await runClient.delete(); + } +}); + +test('abort() stops a running Actor and the run settles in a terminal state', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).start(); + const runClient = client.run(run.id); + + try { + const abortedRun = await runClient.abort(); + // hello-world is short enough that it may already have succeeded before the abort landed. + expect(['ABORTING', 'ABORTED', 'SUCCEEDED']).toContain(abortedRun.status); + + const finalRun = await runClient.waitForFinish(); + expect(['ABORTED', 'SUCCEEDED']).toContain(finalRun.status); + } finally { + await runClient.waitForFinish(); + await runClient.delete(); + } +}); + +test('update() sets the status message of a run', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + const updatedRun = await runClient.update({ + statusMessage: 'Test status message', + isStatusMessageTerminal: true, + }); + + expect(updatedRun.statusMessage).toBe('Test status message'); + } finally { + await runClient.delete(); + } +}); + +test('resurrect() restarts a finished run, which then succeeds again', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + expect(run.status).toBe('SUCCEEDED'); + const runClient = client.run(run.id); + + try { + const resurrectedRun = await runClient.resurrect(); + expect(['READY', 'RUNNING', 'SUCCEEDED']).toContain(resurrectedRun.status); + + const finalRun = await runClient.waitForFinish(); + expect(finalRun.status).toBe('SUCCEEDED'); + } finally { + // The resurrected run may still be executing, and a running run cannot be deleted. + await runClient.waitForFinish(); + await runClient.delete(); + } +}); + +test('metamorph() transforms a started run into another Actor, keeping the run id', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).start(); + const runClient = client.run(run.id); + + try { + await waitUntilStarted(runClient); + + try { + const metamorphedRun = await runClient.metamorph(HELLO_WORLD_ACTOR, { + message: 'Hello from metamorph!', + }); + expect(metamorphedRun.id).toBe(run.id); + + await runClient.waitForFinish(); + } catch (err) { + // hello-world can finish before the metamorph lands; the API call was still exercised. + rethrowUnlessAlreadyFinished(err); + } + } finally { + await runClient.waitForFinish(); + await runClient.delete(); + } +}); + +test('reboot() restarts the container of a running Actor, keeping the run id', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).start(); + const runClient = client.run(run.id); + + try { + const currentRun = await waitUntilStarted(runClient); + + if (currentRun?.status === 'RUNNING') { + try { + const rebootedRun = await runClient.reboot(); + expect(rebootedRun.id).toBe(run.id); + } catch (err) { + // The run may finish between the status check above and the reboot call. + rethrowUnlessAlreadyFinished(err); + } + } + + await runClient.waitForFinish(); + } finally { + await runClient.waitForFinish(); + await runClient.delete(); + } +}); + +test('charge() reaches the API and is rejected for an Actor that is not pay-per-event', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + const runClient = client.run(run.id); + + try { + try { + await runClient.charge({ eventName: 'test-event', count: 1 }); + // If it succeeds, the reference Actor has become pay-per-event - also a valid outcome. + } catch (err) { + // Anything other than the API rejecting a non-PPE charge is a real failure. + if (!(err instanceof ApifyApiError) || ![400, 403, 404].includes(err.statusCode)) throw err; + } + } finally { + await runClient.delete(); + } +}); + +test('runs().list() returns the user run feed', async () => { + const runsPage = await client.runs().list({ limit: 10 }); + + expect(Array.isArray(runsPage.items)).toBe(true); + for (const run of runsPage.items) { + expect(run.id).toBeTruthy(); + expect(run.actId).toBeTruthy(); + } +}); + +test('runs().list({ desc: true }) returns the run feed newest first', async () => { + const runsPage = await client.runs().list({ limit: 10, desc: true }); + + // The user run feed is shared across parallel test workers, and a brand-new RUNNING run may + // briefly lack `startedAt`. Compare ordering only on the timestamps that are present. + const timestamps = runsPage.items + .map((run) => run.startedAt?.getTime()) + .filter((value): value is number => value !== undefined); + expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a)); +}); + +test('runs().list() is async-iterable and yields the user runs', async () => { + const collected: ActorRunListItem[] = []; + for await (const run of client.runs().list({ limit: 5 })) { + collected.push(run); + } + + expect(collected.length, 'the test account should have at least one run').toBeGreaterThanOrEqual(1); + for (const run of collected) { + expect(run.id).toBeTruthy(); + expect(run.actId).toBeTruthy(); + } +}); + +test('actor.runs().list() is async-iterable and yields only that Actor runs', async () => { + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + + try { + const collected: ActorRunListItem[] = []; + for await (const actorRun of client.actor(HELLO_WORLD_ACTOR).runs().list({ limit: 3, desc: true })) { + collected.push(actorRun); + } + + expect(collected.length).toBeGreaterThanOrEqual(1); + expect(collected.every((item) => item.actId === run.actId)).toBe(true); + } finally { + await client.run(run.id).delete(); + } +}); diff --git a/test/integration/schedule.test.ts b/test/integration/schedule.test.ts new file mode 100644 index 00000000..4235a690 --- /dev/null +++ b/test/integration/schedule.test.ts @@ -0,0 +1,171 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ApifyClient, Schedule } from 'apify-client'; +import { ScheduleActions } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { collectUntilPresent, getRandomResourceName } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +async function createSchedule(cronExpression = '0 0 * * *'): Promise { + return client.schedules().create({ + cronExpression, + isEnabled: false, + isExclusive: false, + name: getRandomResourceName('schedule'), + }); +} + +test('create() stores the cron expression and flags, and the schedule is retrievable by id', async () => { + const scheduleName = getRandomResourceName('schedule'); + const createdSchedule = await client.schedules().create({ + cronExpression: '0 0 * * *', + isEnabled: false, + isExclusive: false, + name: scheduleName, + }); + const scheduleClient = client.schedule(createdSchedule.id); + + try { + expect(createdSchedule.name).toBe(scheduleName); + expect(createdSchedule.cronExpression).toBe('0 0 * * *'); + expect(createdSchedule.isEnabled).toBe(false); + expect(createdSchedule.isExclusive).toBe(false); + + const retrievedSchedule = await scheduleClient.get(); + expect(retrievedSchedule?.id).toBe(createdSchedule.id); + expect(retrievedSchedule?.name).toBe(scheduleName); + } finally { + await scheduleClient.delete(); + } +}); + +test('update() changes the name, cron expression and enabled flag, and they persist', async () => { + const newName = getRandomResourceName('schedule-updated'); + const createdSchedule = await createSchedule(); + const scheduleClient = client.schedule(createdSchedule.id); + + try { + const updatedSchedule = await scheduleClient.update({ + name: newName, + cronExpression: '0 12 * * *', + isEnabled: true, + }); + expect(updatedSchedule.name).toBe(newName); + expect(updatedSchedule.cronExpression).toBe('0 12 * * *'); + expect(updatedSchedule.isEnabled).toBe(true); + expect(updatedSchedule.id).toBe(createdSchedule.id); + + const retrievedSchedule = await scheduleClient.get(); + expect(retrievedSchedule?.name).toBe(newName); + expect(retrievedSchedule?.cronExpression).toBe('0 12 * * *'); + } finally { + await scheduleClient.delete(); + } +}); + +test('schedules().list() contains the freshly created schedules', async () => { + const first = await createSchedule('0 0 * * *'); + const second = await createSchedule('0 6 * * *'); + + try { + const collected = await collectUntilPresent( + () => client.schedules().list({ desc: true, limit: 100 }), + [first.id, second.id], + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + expect(collectedIds).toContain(first.id); + expect(collectedIds).toContain(second.id); + } finally { + await client.schedule(first.id).delete(); + await client.schedule(second.id).delete(); + } +}); + +test('schedules().list() iterates the user schedules across pages', async () => { + const createdIds: string[] = []; + + try { + for (let i = 0; i < 3; i++) { + const schedule = await createSchedule(); + createdIds.push(schedule.id); + } + + const collected = await collectUntilPresent( + () => client.schedules().list({ desc: true, limit: 50 }), + createdIds, + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + for (const createdId of createdIds) { + expect(collectedIds, `schedule ${createdId} is missing from the listing`).toContain(createdId); + } + } finally { + for (const id of createdIds) { + await client.schedule(id).delete(); + } + } +}); + +test('get() resolves to undefined for a deleted schedule', async () => { + const createdSchedule = await createSchedule(); + const scheduleClient = client.schedule(createdSchedule.id); + + await scheduleClient.delete(); + + await expect(scheduleClient.get()).resolves.toBeUndefined(); +}); + +test('get() resolves to undefined for a schedule that never existed', async () => { + await expect(client.schedule('NoNeXiStEnT').get()).resolves.toBeUndefined(); +}); + +test('getLog() works on a schedule that has never run', async () => { + const createdSchedule = await createSchedule(); + const scheduleClient = client.schedule(createdSchedule.id); + + try { + const log = await scheduleClient.getLog(); + + // The API answers with an array of log entries, empty for a schedule that never fired. + // `getLog` declares `Promise`, which does not match, hence the cast. + expect(Array.isArray(log as unknown)).toBe(true); + expect(log as unknown as unknown[]).toHaveLength(0); + } finally { + await scheduleClient.delete(); + } +}); + +test('create() accepts a RUN_ACTOR action, which round-trips through the API', async () => { + const actor = await client.actor(HELLO_WORLD_ACTOR).get(); + expect(actor).toBeDefined(); + + const createdSchedule = await client.schedules().create({ + cronExpression: '0 0 * * *', + isEnabled: false, + isExclusive: false, + name: getRandomResourceName('schedule'), + actions: [{ type: ScheduleActions.RunActor, actorId: actor!.id }], + }); + const scheduleClient = client.schedule(createdSchedule.id); + + try { + expect(createdSchedule.actions).toHaveLength(1); + const action = createdSchedule.actions[0]; + expect(action.type).toBe(ScheduleActions.RunActor); + expect(action).toHaveProperty('actorId', actor!.id); + + const retrieved = await scheduleClient.get(); + expect(retrieved?.actions).toHaveLength(1); + } finally { + await scheduleClient.delete(); + } +}); diff --git a/test/integration/store.test.ts b/test/integration/store.test.ts new file mode 100644 index 00000000..799a6297 --- /dev/null +++ b/test/integration/store.test.ts @@ -0,0 +1,117 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ActorStoreList, ApifyClient } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +test('store().list() returns public Actors', async () => { + const actorsList = await client.store().list({ limit: 10 }); + + expect(actorsList.items.length).toBeGreaterThan(0); +}); + +test('store().list() accepts a search term', async () => { + const storePage = await client.store().list({ limit: 5, search: 'web scraper' }); + + expect(Array.isArray(storePage.items)).toBe(true); +}); + +test('store().list() moves the window with offset', async () => { + const firstPage = await client.store().list({ limit: 5, offset: 0 }); + const secondPage = await client.store().list({ limit: 5, offset: 5 }); + + // The public store holds thousands of Actors, so both windows are always full. + expect(firstPage.items).toHaveLength(5); + expect(secondPage.items).toHaveLength(5); + expect(secondPage.items[0].id).not.toBe(firstPage.items[0].id); +}); + +// `expectItems` marks the models the public store actually carries Actors for. No public Actor is +// priced per dataset item, so that filter matching nothing is the correct answer, not a failure - +// there the assertion is only that the client serializes the filter into a request the API accepts. +test.for([ + { pricingModel: 'FREE', expectItems: true }, + { pricingModel: 'FLAT_PRICE_PER_MONTH', expectItems: true }, + { pricingModel: 'PRICE_PER_DATASET_ITEM', expectItems: false }, + { pricingModel: 'PAY_PER_EVENT', expectItems: true }, +])('store().list() filters by the $pricingModel pricing model', async ({ pricingModel, expectItems }) => { + const page = await client.store().list({ limit: 10, pricingModel }); + + if (expectItems) { + expect(page.items.length, `the store should list Actors priced as ${pricingModel}`).toBeGreaterThan(0); + } + for (const actor of page.items) { + if (actor.currentPricingInfo?.pricingModel) { + expect(actor.currentPricingInfo.pricingModel).toBe(pricingModel); + } + } +}); + +test('store().list() filters by username', async () => { + const page = await client.store().list({ limit: 10, username: 'apify' }); + + expect(page.items.length).toBeGreaterThan(0); + for (const actor of page.items) { + expect(actor.username).toBe('apify'); + } +}); + +test('store().list() sorts by popularity', async () => { + const page = await client.store().list({ limit: 10, sortBy: 'popularity' }); + + expect(page.items.length).toBeGreaterThan(0); + + // Popularity is a composite ranking rather than a sort on one field, so only the directional + // invariant holds: the top item has at least as many total users as the bottom one. + const totalUsers = page.items + .map((actor) => actor.stats.totalUsers) + .filter((total): total is number => total !== undefined); + expect(totalUsers.length, 'expected at least one item with populated stats.totalUsers').toBeGreaterThan(0); + expect(totalUsers[0]).toBeGreaterThanOrEqual(totalUsers.at(-1)!); +}); + +test('store().list() parses every item on a full first page', async () => { + const page = await client.store().list({ limit: 100 }); + + expect(page.items.length, 'the public store returned an empty page').toBeGreaterThan(0); + for (const item of page.items) { + expect(item.id).toBeTruthy(); + expect(item.name).toBeTruthy(); + expect(item.username).toBeTruthy(); + } +}); + +test('store().list() is async-iterable and yields distinct Actors', async () => { + const collected: ActorStoreList[] = []; + for await (const actor of client.store().list({ limit: 20 })) { + collected.push(actor); + } + + expect(collected.length).toBeGreaterThan(0); + expect(collected.length).toBeLessThanOrEqual(20); + + const seenIds = new Set(); + for (const actor of collected) { + expect(actor.id).toBeTruthy(); + expect(seenIds, `Actor ${actor.id} was yielded twice`).not.toContain(actor.id); + seenIds.add(actor.id); + } +}); + +test('store().list() iteration keeps the username filter across pages', async () => { + const collected: ActorStoreList[] = []; + for await (const actor of client.store().list({ limit: 15, username: 'apify' })) { + collected.push(actor); + } + + expect(collected.length).toBeGreaterThan(0); + for (const actor of collected) { + expect(actor.username).toBe('apify'); + } +}); diff --git a/test/integration/task.test.ts b/test/integration/task.test.ts new file mode 100644 index 00000000..d840f1c1 --- /dev/null +++ b/test/integration/task.test.ts @@ -0,0 +1,255 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ActorRunListItem, ApifyClient, Task } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { collectUntilPresent, getRandomResourceName } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; + +let client: ApifyClient; +let helloWorldActorId: string; + +beforeAll(async () => { + client = makeClient(); + const actor = await client.actor(HELLO_WORLD_ACTOR).get(); + expect(actor, `the reference Actor ${HELLO_WORLD_ACTOR} could not be resolved`).toBeDefined(); + helloWorldActorId = actor!.id; +}); + +async function createTask(input?: Record): Promise { + return client.tasks().create({ + actId: helloWorldActorId, + name: getRandomResourceName('task'), + ...(input ? { input } : {}), + }); +} + +test('create() stores the Actor id and name, and the task is retrievable by id', async () => { + const taskName = getRandomResourceName('task'); + const createdTask = await client.tasks().create({ actId: helloWorldActorId, name: taskName }); + const taskClient = client.task(createdTask.id); + + try { + expect(createdTask.name).toBe(taskName); + expect(createdTask.actId).toBe(helloWorldActorId); + + const retrievedTask = await taskClient.get(); + expect(retrievedTask?.id).toBe(createdTask.id); + expect(retrievedTask?.name).toBe(taskName); + } finally { + await taskClient.delete(); + } +}); + +test('update() changes the name and run options, and they persist', async () => { + const newName = getRandomResourceName('task-updated'); + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const updatedTask = await taskClient.update({ name: newName, options: { timeoutSecs: 300 } }); + expect(updatedTask.name).toBe(newName); + expect(updatedTask.id).toBe(createdTask.id); + expect(updatedTask.options?.timeoutSecs).toBe(300); + + const retrievedTask = await taskClient.get(); + expect(retrievedTask?.name).toBe(newName); + } finally { + await taskClient.delete(); + } +}); + +test('tasks().list() contains a freshly created task', async () => { + const createdTask = await createTask(); + + try { + const collected = await collectUntilPresent( + () => client.tasks().list({ desc: true, limit: 100 }), + [createdTask.id], + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + expect(collectedIds).toContain(createdTask.id); + } finally { + await client.task(createdTask.id).delete(); + } +}); + +test('tasks().list() iterates the user tasks across pages', async () => { + const createdIds: string[] = []; + + try { + for (let i = 0; i < 3; i++) { + const task = await createTask(); + createdIds.push(task.id); + } + + const collected = await collectUntilPresent(() => client.tasks().list({ desc: true, limit: 50 }), createdIds); + const collectedIds = new Set(collected.map((item) => item.id)); + + for (const createdId of createdIds) { + expect(collectedIds, `task ${createdId} is missing from the listing`).toContain(createdId); + } + } finally { + for (const id of createdIds) { + await client.task(id).delete(); + } + } +}); + +test('get() resolves to undefined for a deleted task', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + await taskClient.delete(); + + await expect(taskClient.get()).resolves.toBeUndefined(); +}); + +test('get() resolves to undefined for a task that never existed', async () => { + await expect(client.task('NoNeXiStEnTtAsK1').get()).resolves.toBeUndefined(); +}); + +test('getInput() returns the saved input and updateInput() replaces it', async () => { + const createdTask = await createTask({ message: 'Hello from test' }); + const taskClient = client.task(createdTask.id); + + try { + const retrievedInput = await taskClient.getInput(); + expect(retrievedInput).toMatchObject({ message: 'Hello from test' }); + + const updatedInput = await taskClient.updateInput({ message: 'Updated message' }); + expect(updatedInput).toMatchObject({ message: 'Updated message' }); + } finally { + await taskClient.delete(); + } +}); + +test('start() launches a run that reaches SUCCEEDED', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.start(); + expect(run.id).toBeTruthy(); + expect(run.actId).toBe(helloWorldActorId); + + const finishedRun = await client.run(run.id).waitForFinish(); + expect(finishedRun.status).toBe('SUCCEEDED'); + + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('start() lets a run input override the saved task input', async () => { + const createdTask = await createTask({ message: 'original' }); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.start({ message: 'overridden' }, { memory: 256 }); + expect(run.id).toBeTruthy(); + + await client.run(run.id).waitForFinish(); + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('call() waits for the run and resolves once it has SUCCEEDED', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.call(); + expect(run.id).toBeTruthy(); + expect(run.status).toBe('SUCCEEDED'); + + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('call() applies the build and memory overrides it is given', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.call(undefined, { build: 'latest', memory: 256, timeout: 120 }); + expect(run.status).toBe('SUCCEEDED'); + expect(run.options.memoryMbytes).toBe(256); + + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('runs().list() returns the runs of a task', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.call(); + + const runsPage = await taskClient.runs().list({ limit: 10 }); + expect(runsPage.items.length).toBeGreaterThanOrEqual(1); + + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('runs().list() is async-iterable and yields the run that was just made', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.call(); + + const collected: ActorRunListItem[] = []; + for await (const taskRun of taskClient.runs().list({ limit: 5 })) { + collected.push(taskRun); + } + + expect(collected.map((item) => item.id)).toContain(run.id); + + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('lastRun() resolves to the most recent run of a task', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const run = await taskClient.call(); + + const lastRun = await taskClient.lastRun().get(); + expect(lastRun?.id).toBe(run.id); + + await client.run(run.id).delete(); + } finally { + await taskClient.delete(); + } +}); + +test('webhooks().list() is empty for a newly created task', async () => { + const createdTask = await createTask(); + const taskClient = client.task(createdTask.id); + + try { + const webhooksPage = await taskClient.webhooks().list(); + + expect(webhooksPage.items).toHaveLength(0); + } finally { + await taskClient.delete(); + } +}); diff --git a/test/integration/user.test.ts b/test/integration/user.test.ts new file mode 100644 index 00000000..d3a4b399 --- /dev/null +++ b/test/integration/user.test.ts @@ -0,0 +1,52 @@ +import { beforeAll, expect, test } from 'vitest'; + +import { ApifyApiError, type ApifyClient } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +test('user().get() returns the authenticated user', async () => { + const user = await client.user().get(); + + expect(user.username).toBeTruthy(); +}); + +test('user().limits() returns the account limits and current usage', async () => { + const limits = await client.user().limits(); + + expect(limits).toBeDefined(); + expect(limits!.limits).toBeTypeOf('object'); + expect(limits!.current).toBeTypeOf('object'); + expect(limits!.monthlyUsageCycle.startAt).toBeInstanceOf(Date); +}); + +test('user().monthlyUsage() returns the current billing cycle usage', async () => { + const usage = await client.user().monthlyUsage(); + + expect(usage).toBeDefined(); + expect(usage!.usageCycle.startAt).toBeInstanceOf(Date); + expect(usage!.monthlyServiceUsage).toBeTypeOf('object'); + expect(Array.isArray(usage!.dailyServiceUsages)).toBe(true); +}); + +test('user().updateLimits() is accepted, or rejected with a client error the account does not allow', async () => { + const accountLimits = await client.user().limits(); + expect(accountLimits, 'the account limits could not be read').toBeDefined(); + + // Data retention is an account-wide setting shared with every other suite and with the parallel + // Node-version job, so the value it already has is written back rather than a new one. That still + // exercises the request end to end, without a concurrent run observing or restoring the change. + try { + await client.user().updateLimits({ dataRetentionDays: accountLimits!.limits.dataRetentionDays }); + } catch (err) { + // Free accounts reject changes to their limits outright, so a 400 or 403 is as valid an outcome + // here as success - anything else means the request itself was malformed. + expect(err).toBeInstanceOf(ApifyApiError); + expect([400, 403]).toContain((err as ApifyApiError).statusCode); + } +}); diff --git a/test/integration/webhook.test.ts b/test/integration/webhook.test.ts new file mode 100644 index 00000000..9ea7ecdd --- /dev/null +++ b/test/integration/webhook.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, expect, test } from 'vitest'; + +import { WEBHOOK_EVENT_TYPES } from '@apify/consts'; + +import type { ApifyClient, Webhook, WebhookDispatch } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; +import { collectUntilPresent, NO_LOG_REDIRECT, pollUntilCondition } from './_utils.js'; + +const HELLO_WORLD_ACTOR = 'apify/hello-world'; + +let client: ApifyClient; + +/** + * A finished `hello-world` run owned by this file, shared by every webhook it creates. + * + * Binding webhooks to a finished run keeps them from ever firing on their own, since a completed run + * emits no further events - the only dispatches are the ones a test asks for explicitly. The run is + * created here rather than borrowed from the listing, because the other test files start and delete + * runs of the same Actor in parallel and a borrowed one can disappear mid-test. + */ +let finishedRunId: string; + +beforeAll(async () => { + client = makeClient(); + const run = await client.actor(HELLO_WORLD_ACTOR).call(undefined, NO_LOG_REDIRECT); + finishedRunId = run.id; +}); + +afterAll(async () => { + if (finishedRunId) await client.run(finishedRunId).delete(); +}); + +async function createWebhook(runId: string, requestUrl = 'https://example.com/webhook'): Promise { + return client.webhooks().create({ + eventTypes: [WEBHOOK_EVENT_TYPES.ACTOR_RUN_SUCCEEDED], + requestUrl, + condition: { actorRunId: runId }, + isAdHoc: true, + }); +} + +test('webhooks().list() returns a page of the user webhooks', async () => { + const webhooksPage = await client.webhooks().list({ limit: 10 }); + + expect(Array.isArray(webhooksPage.items)).toBe(true); + expect(webhooksPage.limit).toBe(10); +}); + +test('webhooks().list() honours limit and offset', async () => { + const webhooksPage = await client.webhooks().list({ limit: 5, offset: 0 }); + + expect(Array.isArray(webhooksPage.items)).toBe(true); + expect(webhooksPage.limit).toBe(5); + expect(webhooksPage.offset).toBe(0); +}); + +test('a created webhook is retrievable by id', async () => { + const createdWebhook = await createWebhook(finishedRunId); + const webhookClient = client.webhook(createdWebhook.id); + + try { + expect(createdWebhook.eventTypes).toContain(WEBHOOK_EVENT_TYPES.ACTOR_RUN_SUCCEEDED); + expect(createdWebhook.condition).toEqual({ actorRunId: finishedRunId }); + + const retrievedWebhook = await webhookClient.get(); + expect(retrievedWebhook?.id).toBe(createdWebhook.id); + } finally { + await webhookClient.delete(); + } +}); + +test('update() changes the request URL of a webhook', async () => { + const createdWebhook = await createWebhook(finishedRunId); + const webhookClient = client.webhook(createdWebhook.id); + + try { + const updatedWebhook = await webhookClient.update({ + requestUrl: 'https://example.com/webhook-updated', + condition: { actorRunId: finishedRunId }, + }); + expect(updatedWebhook.requestUrl).toBe('https://example.com/webhook-updated'); + } finally { + await webhookClient.delete(); + } +}); + +test('test() creates a dispatch carrying a dummy payload', async () => { + const createdWebhook = await createWebhook(finishedRunId); + const webhookClient = client.webhook(createdWebhook.id); + + try { + const dispatch = await webhookClient.test(); + + expect(dispatch?.id).toBeTruthy(); + } finally { + await webhookClient.delete(); + } +}); + +test('dispatches().list() returns the dispatches of a webhook', async () => { + const createdWebhook = await createWebhook(finishedRunId); + const webhookClient = client.webhook(createdWebhook.id); + + try { + await webhookClient.test(); + + // The dispatch is created asynchronously, so it is not guaranteed to be listed right away. + const dispatches = await pollUntilCondition( + () => webhookClient.dispatches().list({ limit: 10 }), + (page) => page.items.length > 0, + ); + expect(dispatches.items.length).toBeGreaterThan(0); + } finally { + await webhookClient.delete(); + } +}); + +test('dispatches().list() is async-iterable', async () => { + const createdWebhook = await createWebhook(finishedRunId); + const webhookClient = client.webhook(createdWebhook.id); + + try { + await webhookClient.test(); + + await pollUntilCondition( + () => webhookClient.dispatches().list({ limit: 10 }), + (page) => page.items.length > 0, + ); + + const collected: WebhookDispatch[] = []; + for await (const dispatch of webhookClient.dispatches().list({ limit: 10 })) { + collected.push(dispatch); + } + + expect(collected.length).toBeGreaterThanOrEqual(1); + for (const dispatch of collected) { + expect(dispatch.id).toBeTruthy(); + } + } finally { + await webhookClient.delete(); + } +}); + +test('get() resolves to undefined for a deleted webhook', async () => { + const createdWebhook = await createWebhook(finishedRunId); + const webhookClient = client.webhook(createdWebhook.id); + + await webhookClient.delete(); + + await expect(webhookClient.get()).resolves.toBeUndefined(); +}); + +test('get() resolves to undefined for a webhook that never existed', async () => { + await expect(client.webhook('NoNeXiStEnTwH').get()).resolves.toBeUndefined(); +}); + +test('webhooks().list() iterates the user webhooks across pages', async () => { + const createdIds: string[] = []; + + try { + // Distinct request URLs, otherwise the API dedupes webhooks by event types, run ID and URL. + for (let i = 0; i < 3; i++) { + const webhook = await createWebhook(finishedRunId, `https://example.com/webhook?n=${i}`); + createdIds.push(webhook.id); + } + + expect(new Set(createdIds).size, 'the API deduplicated the created webhooks').toBe(3); + + const collected = await collectUntilPresent( + () => client.webhooks().list({ desc: true, limit: 50 }), + createdIds, + ); + const collectedIds = new Set(collected.map((item) => item.id)); + + for (const createdId of createdIds) { + expect(collectedIds, `webhook ${createdId} is missing from the listing`).toContain(createdId); + } + } finally { + for (const id of createdIds) { + await client.webhook(id).delete(); + } + } +}); diff --git a/test/integration/webhook_dispatch.test.ts b/test/integration/webhook_dispatch.test.ts new file mode 100644 index 00000000..9498bbc2 --- /dev/null +++ b/test/integration/webhook_dispatch.test.ts @@ -0,0 +1,61 @@ +import { beforeAll, expect, test } from 'vitest'; + +import type { ApifyClient, WebhookDispatch } from 'apify-client'; + +import { makeClient } from './_fixtures.js'; + +let client: ApifyClient; + +beforeAll(() => { + client = makeClient(); +}); + +test('webhookDispatches().list() returns a page of the user dispatches', async () => { + const dispatchesPage = await client.webhookDispatches().list({ limit: 10 }); + + expect(Array.isArray(dispatchesPage.items)).toBe(true); + expect(dispatchesPage.limit).toBe(10); +}); + +test('webhookDispatch().get() returns the dispatch a listing pointed at', async () => { + const dispatchesPage = await client.webhookDispatches().list({ limit: 1 }); + + if (dispatchesPage.items.length > 0) { + const dispatchId = dispatchesPage.items[0].id; + const dispatch = await client.webhookDispatch(dispatchId).get(); + expect(dispatch?.id).toBe(dispatchId); + } else { + // The account may have no dispatches at all, in which case there is only the negative case to check. + await expect(client.webhookDispatch('non-existent-id').get()).resolves.toBeUndefined(); + } +}); + +test('webhookDispatches().list() orders by createdAt and moves the window with offset', async () => { + const page = await client.webhookDispatches().list({ limit: 5, offset: 0, desc: true }); + expect(page.items.length).toBeLessThanOrEqual(5); + + const createdAts = page.items.map((dispatch) => dispatch.createdAt.getTime()); + expect(createdAts).toEqual([...createdAts].sort((a, b) => b - a)); + + // Ascending order for the offset check, so dispatches created by tests running in parallel cannot + // shift the pages between the two calls. + const ascPage = await client.webhookDispatches().list({ limit: 5, offset: 0, desc: false }); + if (ascPage.items.length === 5) { + const nextPage = await client.webhookDispatches().list({ limit: 5, offset: 5, desc: false }); + if (nextPage.items.length > 0) { + expect(nextPage.items[0].id).not.toBe(ascPage.items[0].id); + } + } +}); + +test('webhookDispatches().list() is async-iterable and respects the limit', async () => { + const collected: WebhookDispatch[] = []; + for await (const dispatch of client.webhookDispatches().list({ limit: 5 })) { + collected.push(dispatch); + } + + expect(collected.length).toBeLessThanOrEqual(5); + for (const dispatch of collected) { + expect(dispatch.id).toBeTruthy(); + } +}); diff --git a/vitest.config.mts b/vitest.config.mts index 74905e1c..8d7f0a56 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,18 +1,47 @@ import { resolve } from 'node:path'; -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; // eslint-disable-next-line import/no-default-export export default defineConfig({ - test: { - globals: true, - environment: 'node', - testTimeout: 20_000, - include: ['test/**/*.test.{js,ts}'], - }, resolve: { alias: { 'apify-client': resolve(__dirname, 'src'), }, }, + test: { + projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['test/**/*.test.{js,ts}'], + exclude: [...configDefaults.exclude, 'test/integration/**'], + globals: true, + environment: 'node', + testTimeout: 20_000, + }, + }, + { + extends: true, + test: { + name: 'integration', + include: ['test/integration/**/*.test.ts'], + globals: true, + environment: 'node', + // Actor runs and builds dominate the runtime of this tier. + testTimeout: 300_000, + hookTimeout: 300_000, + // Fixed worker count, so the concurrency the suite puts on the live API does not + // scale with the core count of whatever machine runs it. + maxWorkers: 8, + // A distinct group is required alongside `maxWorkers`: Vitest refuses to run two + // projects that share a group order but resolve to different worker counts. + sequence: { groupOrder: 1 }, + // No automatic retries - flakiness is handled by polling helpers, not by rerunning. + retry: 0, + }, + }, + ], + }, });