Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 30 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions test/integration/_fixtures.ts
Original file line number Diff line number Diff line change
@@ -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 } : {}) });
}
139 changes: 139 additions & 0 deletions test/integration/_utils.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<T>(
fn: () => Promise<T>,
condition: (value: T) => boolean = (value) => Boolean(value),
options: PollOptions = {},
): Promise<T> {
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<T extends { id: string }>(
iterableFactory: () => AsyncIterable<T>,
expectedIds: Iterable<string>,
): Promise<T[]> {
const expected = [...expectedIds];

const drain = async (): Promise<T[]> => {
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;
}
Loading
Loading