Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d1fdf93
feat: add configurable test triggers (runWhen) and alerts per test/suite
claude Apr 4, 2026
4757eb7
refactor: redesign trigger config API — config-first with hierarchica…
claude Apr 4, 2026
ff86fa3
feat: pass alerts config through task.meta to report-tests
claude Apr 4, 2026
50b2e43
test: add unit tests for trigger filtering, config merging, and alerts
claude Apr 5, 2026
a2a8e2e
refactor: separate failedAssertions from per-channel notification lists
claude Apr 5, 2026
ca6263a
Restructure trigger/alert config: nested triggers+options, field-by-f…
claude Apr 5, 2026
d8c6af1
Unify naming: configStack/mergeInheritedConfigs → triggersStack/merge…
claude Apr 5, 2026
d97068b
Switch runWhen to opt-out semantics: unset fields default to true
claude Apr 5, 2026
58da6bd
Move runWhen defaults to DEFAULT_TRIGGERS config object instead of ch…
claude Apr 5, 2026
6277f29
Polish: dedup DEFAULT_TRIGGERS reference, fix JSDoc examples for opt-…
claude Apr 5, 2026
0c7b919
Add BACKWARD_COMPATIBLE_HOURLY_DIR: hourly defaults to false, auto-en…
claude Apr 6, 2026
157798f
Remove getMergedTriggers wrapper, inline mergeInheritedTriggers at ca…
claude Apr 6, 2026
7d407e5
refactor(lib): split lib.ts into consts, utils, and core API modules
claude Apr 6, 2026
29984a8
refactor(lib): inline getCallerFile into getEffectiveDefaults
claude Apr 6, 2026
323d44f
feat(consts): add alerts to DEFAULT_TRIGGERS with Required<AlertsConfig>
claude Apr 6, 2026
656ed88
fix(lib): preserve alerts in getEffectiveDefaults hourly override path
claude Apr 6, 2026
95b343a
docs(readme): update for trigger/alert config API, drop migration sec…
claude Apr 6, 2026
d7e8985
docs(readme): mark core directory as legacy, remove backward_compatib…
claude Apr 6, 2026
b1f34ea
Merge branch 'master' into claude/configurable-test-triggers-li7wc
metalwarrior665 Apr 10, 2026
b55b450
revert refactoring
metalwarrior665 Apr 10, 2026
c087fd8
polish tests
metalwarrior665 Apr 10, 2026
c0b51e0
Merge branch 'master' into claude/configurable-test-triggers-li7wc
metalwarrior665 Apr 10, 2026
5f81b13
fix after conflict resolution
metalwarrior665 Apr 10, 2026
c3ebc2e
Merge branch 'master' into claude/configurable-test-triggers-li7wc
metalwarrior665 Apr 16, 2026
300432e
fix problems after conflict merge
metalwarrior665 Apr 16, 2026
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
216 changes: 99 additions & 117 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,27 @@
## Getting Started

1. Install the package `npm i -D apify-test-tools`
- because it uses [annotate](https://vitest.dev/guide/test-context.html#annotate), `vitest` version to be at least `3.2.0`
- make sure that `target` and `module` in your `tsconfig.json`'s `compilerOptions` are set to `ES2022`
2. create test directories: `mkdir -p test/platform/core`
- core (hourly) tests should go to `test/platform/core`
- daily tests should go to `test/platform`
3. setup github worklows TODO
- requires [`vitest`](https://vitest.dev/) `>= 3.2.0` (uses [`annotate`](https://vitest.dev/guide/test-context.html#annotate))
- set `target` and `module` to `ES2022` in your `tsconfig.json` `compilerOptions`
2. Create a test directory: `mkdir -p test/platform`
3. Set up GitHub workflows (see below)

File structure:

```
google-maps
├── actors
── src
── src
└── test
├── unit
└── platform
├── core <- Core tests need to be inside core directory
├── core <- Legacy: hourly tests (see "Hourly tests" section)
│ └── core.test.ts
├── some.test.ts <- Other tests can be defined anywhere inside platform directory
├── some.test.ts
└── some-other.test.ts
```

## Github worklows
## GitHub Workflows

There should be 4 GH workflow files in `.github/workflows`.

Expand All @@ -45,8 +43,6 @@ on:
jobs:
platformTestsCore:
uses: apify-store/github-actions-source/.github/workflows/platform-tests.yaml@new_master
with:
subtest: core
secrets: inherit
```

Expand Down Expand Up @@ -97,153 +93,139 @@ jobs:
secrets: inherit
```

## Differences in writing tests
## Writing Tests

---
### Basic usage

### Test structure

To run the tests concurrently, we had to start the run outside of `it` and then call `await` inside. This is now no longer needed and everything can be inside `it` aka `testActor`.
```ts
import { describe, testActor } from 'apify-test-tools';

Before:
describe('google-maps', () => {
testActor(actorId, 'smoke test', async ({ run, expect }) => {
const result = await run({ input: { query: 'London Eye' } });

```ts
({ it, xit, run, expect, expectAsync, input, describe }: TestSpecInputs) => {
describe('test', () => {
{
const runPromise = run({ actorId, input })
it('actor test 1', async () => {
const runResult = await runPromise;

// your checks
});
}

{
const runPromise = run({ actorId, input })
it('actor test 2', async () => {
const runResult = await runPromise;

// your checks
});
}
});
})
await expect(result).toFinishWith({
datasetItemCount: { min: 1, max: 10 },
});
});
});
```

After:
`testActor` provides a `run` function that calls the actor built in the current CI run, and extends `expect` with custom matchers (e.g. `toFinishWith`).

```ts
import { describe, testActor } from 'apify-test-tools';

describe('test', () => {
testActor(actorId, 'actor test 1', async ({ expect, run }) => {
const runResult = await run({ input })
### Validating run results

// your checks
)};
```ts
await expect(result).toFinishWith({
// all fields below are optional and have sensible defaults
status: 'SUCCEEDED',
duration: { min: 600, max: 600_000 }, // ms
failedRequests: 0,
requestsRetries: { max: 3 },
forbiddenLogs: ['ReferenceError', 'TypeError'],

testActor(actorId, 'actor test 2', async ({ expect, run }) => {
const runResult = await run({ input })
// required — exact number or range
datasetItemCount: { min: 80, max: 120 },

// your checks
)};
})
// optional — PPE event counts; any omitted event is expected to be 0
chargedEventCounts: {
'actor-start': 1,
'place-scraped': { min: 9 },
},
});
```

`testActor` extends `expect` with couple of custom matchers (e.g. `toFinishWith`) and provides `run` function call the correct actor, based on it’s first parameter

---

### Validating basic run attributes
### Shared validation helpers

Before:
Create reusable helpers in e.g. `test/platform/utils.ts` and import them in test files:

```ts
await expectAsync(runResult).toHaveStatus('SUCCEEDED');
import { ExpectStatic } from 'apify-test-tools';

await expectAsync(runResult).withLog((log) => {
expect(log).not.toContain('ReferenceError');
expect(log).not.toContain('TypeError');
});
export const validatePlace = (expect: ExpectStatic, place: unknown) => {
expect(place.title, 'place title').toBeNonEmptyString();
expect(place.url, 'place url').toBeNonEmptyString();
};
```

await expectAsync(runResult).withStatistics((stats) => {
expect(stats.requestsRetries).withContext(runResult.format('Request retries')).toBeLessThan(3);
expect(stats.crawlerRuntimeMillis).withContext(runResult.format('Run time')).toBeWithinRange(600, 600_000);
});
## Trigger & Alert Configuration

await expectAsync(runResult).withDataset(({ dataset }) => {
expect(dataset.items?.length).withContext(runResult.format('Dataset cleanItemCount')).toBe(100);
});
```
Tests default to running on `daily` and `pullRequest` triggers, with Slack alerts enabled. Use the `triggers` option on `describe` or `testActor` to override.

After:
### Opting out of a trigger

```ts
await expect(runResult).toFinishWith({
datasetItemCount: 100,
});
// This suite only runs on daily and hourly, never on pull requests
describe({
name: 'google-maps',
triggers: { runWhen: { pullRequest: false } },
}, () => { ... });
```

You can also specify a range:
### Running only on specific triggers

```ts
await expect(runResult).toFinishWith({
datasetItemCount: { min: 80, max: 120 },
});
// This test only runs hourly (opt out of daily and pullRequest)
testActor(actorId, {
name: 'extended smoke',
triggers: { runWhen: { daily: false, pullRequest: false } },
}, async ({ run, expect }) => { ... });
```

Here is full example of what you can validate with `toFinishWith`
### Inheriting and overriding through the describe hierarchy

`triggers` are merged field-by-field from outer to inner — children only need to override what they want to change:

```ts
await expect(runResult).toFinishWith({
// These are default
status: 'SUCCEEDED',
duration: {
min: 600, // 0.6 sec
max: 600_000, // 10 min
describe(
{
name: 'google-maps',
triggers: { runWhen: { pullRequest: false } }, // disable PR runs for the whole suite
},
failedRequests: 0,
requestsRetries: { max: 3 },
forbiddenLogs: ['ReferenceError', 'TypeError'],

// only datasetItemCount is required
datasetItemCount: { min: 80, max: 120 },

// optional
chargedEventCounts: {
'actor-start': 1,
'place-scraped': 9,
() => {
testActor(actorId, 'smoke', async ({ run, expect }) => {
// inherits pullRequest: false from the describe above
});

testActor(
actorId,
{
name: 'extended',
triggers: { runWhen: { daily: false } }, // additionally disable daily
},
async ({ run, expect }) => {
// effective: pullRequest: false, daily: false → runs hourly only
},
);
},
});
);
```

---

### Custom validations

Before:
### Disabling Slack alerts

```ts
expect(place.title).withContext(runResult.format(`London Eye's title`)).toEqual('lastminute.com London Eye');
describe({
name: 'experimental',
triggers: { alerts: { slack: false } }, // failures won't ping Slack
}, () => { ... });
```

After:
### Hourly tests (core directory)

> **Legacy:** Tests inside `core/` were historically run hourly. This is supported for backward compatibility but new tests should opt in explicitly instead:

```ts
expect(place.title, `London Eye's title`).toEqual('lastminute.com London Eye');
testActor(actorId, {
name: 'smoke',
triggers: { runWhen: { hourly: true } },
}, async ({ run, expect }) => { ... });
```

---

### Custom validation functions

You can now create your own functions wrapping a common validation logic in e.g. `test/platform/utils.ts` and import it in test files.
### Reading the current trigger at runtime

```ts
import { ExpectStatic } from 'apify-test-tools'
import { getCurrentTrigger, TRIGGER_ENV_VAR } from 'apify-test-tools';

export const validateItem = (expect: ExpectStatic, item: any) {
expect(item.title, 'Item title').toBeString();
}
// Returns 'hourly' | 'daily' | 'pullRequest' | undefined
const trigger = getCurrentTrigger();
```
30 changes: 21 additions & 9 deletions bin/test-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ export const reportTestResults = async ({
}
}

const failedAssertions: { message: string; runLink: string; actorName: string }[] = [];
const failedAssertions: {
message: string;
runLink: string;
actorName: string;
alerts: JsonAssertionResult['meta']['alerts'];
}[] = [];

console.error();
console.error(`PASSED: ${passed.length}, FAILED: ${failed.length}`);
Expand All @@ -63,6 +68,7 @@ export const reportTestResults = async ({
message: message.split('\n')?.[0],
runLink: meta.runLink,
actorName: meta.actorName,
alerts: meta.alerts,
})),
);
}
Expand All @@ -80,27 +86,26 @@ export const reportTestResults = async ({
return;
}

if (failedAssertions.length === 0) {
// Default to true when alerts is not configured — backward-compatible
const slackAssertions = failedAssertions.filter(({ alerts }) => alerts?.slack !== false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a weird name 😄


if (slackAssertions.length === 0) {
return;
}

// TODO: add slack profiles
const total = failed.length + passed.length;
const jobLink = jobUrl ? ` Check <${jobUrl}|the job>.` : '';
let slackMessage = `\`${workflowName ?? '-'}\``;
slackMessage += `: has ${failedAssertions.length} failed assertions. Failing test suites: ${failed.length}/${total}.${jobLink}`;
slackMessage += `\n\n${failedAssertions[0].message} --- <${failedAssertions[0].runLink}|${failedAssertions[0].actorName}>`;
const blocks = failedAssertions
slackMessage += `: has ${slackAssertions.length} failed assertions. Failing test suites: ${failed.length}/${total}.${jobLink}`;
slackMessage += `\n\n${slackAssertions[0].message} --- <${slackAssertions[0].runLink}|${slackAssertions[0].actorName}>`;
const blocks = slackAssertions
.slice(1)
.map(({ message, runLink, actorName }) => `• ${message} --- <${runLink}|${actorName}>`);

console.error('SLACK:', slackMessage);
console.error('\tblocks:', blocks.join('\n\t\t'));

if (!reportSlackChannel) {
return;
}

if (!dryRun) {
const slackToken = getEnvVar('SLACK_TOKEN_TESTS_BOT');
await sendSlackMessage(reportSlackChannel, slackMessage, blocks, slackToken);
Expand All @@ -123,6 +128,13 @@ interface JsonAssertionResult {
runId: string;
runLink: string;
actorName: string;
/**
* Alerting config set by the test via `alerts` in `testActor`/`describe`.
* `undefined` means the test didn't opt in or out — treat as "notify" for
* backward compatibility.
* `slack: false` explicitly disables the Slack notification for that test.
*/
alerts?: { slack?: boolean };
};
duration?: Milliseconds | null;
failureMessages: string[] | null;
Expand Down
12 changes: 11 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
export { describe, testActor, testStandbyActor, ExpectStatic } from './lib/lib.js';
export { describe, testActor, testStandbyActor, ExpectStatic, getCurrentTrigger, TRIGGER_ENV_VAR } from './lib/lib.js';
export { RunTestResult } from './lib/run-test-result.js';
export type {
TriggerType,
RunWhenConfig,
AlertsConfig,
TriggerConfig,
DescribeConfig,
DescribeOptions,
TestActorConfig,
ActorOptions,
} from './lib/types.js';
Loading
Loading