apify-test-tools 0.10.2-beta.7 → 0.10.2-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/.github/actions/checkout-restore-dependencies/action.yaml +13 -0
  2. package/.github/cli/apify-test-tools.mjs +1 -1
  3. package/.github/scripts/check-major-tag-refs.mjs +1 -1
  4. package/.github/scripts/mask-config-secrets.sh +17 -0
  5. package/.github/scripts/run-with-apify-tokens.mjs +36 -11
  6. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +6 -2
  7. package/.github/workflows/public_platform-tests.yaml +23 -0
  8. package/.github/workflows/public_pr-build-test.yaml +30 -1
  9. package/.github/workflows/public_push-build-latest.yaml +20 -3
  10. package/CHANGELOG.md +1 -0
  11. package/CONTRIBUTING.md +99 -8
  12. package/README.md +218 -699
  13. package/bin/git.ts +1 -1
  14. package/dist/bin/git.js +1 -1
  15. package/dist/bin/git.js.map +1 -1
  16. package/dist/lib/extend-expect.d.ts.map +1 -1
  17. package/dist/lib/extend-expect.js +37 -218
  18. package/dist/lib/extend-expect.js.map +1 -1
  19. package/dist/lib/lib.d.ts +1 -2
  20. package/dist/lib/lib.d.ts.map +1 -1
  21. package/dist/lib/lib.js +15 -22
  22. package/dist/lib/lib.js.map +1 -1
  23. package/dist/lib/run-checks.d.ts +14 -0
  24. package/dist/lib/run-checks.d.ts.map +1 -0
  25. package/dist/lib/run-checks.js +82 -0
  26. package/dist/lib/run-checks.js.map +1 -0
  27. package/dist/lib/run-test-result.d.ts +3 -6
  28. package/dist/lib/run-test-result.d.ts.map +1 -1
  29. package/dist/lib/run-test-result.js +41 -54
  30. package/dist/lib/run-test-result.js.map +1 -1
  31. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts +2 -0
  32. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts.map +1 -0
  33. package/dist/test/unit/github-scripts/mask-config-secrets.test.js +50 -0
  34. package/dist/test/unit/github-scripts/mask-config-secrets.test.js.map +1 -0
  35. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts +2 -0
  36. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts.map +1 -0
  37. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js +102 -0
  38. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js.map +1 -0
  39. package/dist/test/unit/run-checks.test.d.ts +2 -0
  40. package/dist/test/unit/run-checks.test.d.ts.map +1 -0
  41. package/dist/test/unit/run-checks.test.js +62 -0
  42. package/dist/test/unit/run-checks.test.js.map +1 -0
  43. package/dist/test/unit/run-test-result.test.d.ts +2 -0
  44. package/dist/test/unit/run-test-result.test.d.ts.map +1 -0
  45. package/dist/test/unit/run-test-result.test.js +34 -0
  46. package/dist/test/unit/run-test-result.test.js.map +1 -0
  47. package/dist/tsconfig.tsbuildinfo +1 -1
  48. package/docs/cli.md +358 -0
  49. package/docs/lib.md +271 -0
  50. package/docs/workflows.md +366 -0
  51. package/lib/extend-expect.ts +48 -258
  52. package/lib/lib.ts +24 -28
  53. package/lib/run-checks.ts +101 -0
  54. package/lib/run-test-result.ts +45 -55
  55. package/package.json +1 -1
  56. package/test/unit/github-scripts/mask-config-secrets.test.ts +61 -0
  57. package/test/unit/github-scripts/run-with-apify-tokens.test.ts +115 -0
  58. package/test/unit/run-checks.test.ts +76 -0
  59. package/test/unit/run-test-result.test.ts +44 -0
@@ -0,0 +1,61 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import process from 'node:process';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ const SCRIPT = fileURLToPath(new URL('../../../.github/scripts/mask-config-secrets.sh', import.meta.url));
8
+
9
+ const run = (configSecrets?: string) =>
10
+ spawnSync('bash', [SCRIPT], {
11
+ encoding: 'utf8',
12
+ env: { PATH: process.env.PATH, ...(configSecrets === undefined ? {} : { CONFIG_SECRETS: configSecrets }) },
13
+ });
14
+
15
+ const masks = (stdout: string) => stdout.split('\n').filter((line) => line !== '');
16
+
17
+ describe('mask-config-secrets.sh', () => {
18
+ it('registers every value as a mask', () => {
19
+ const { status, stdout, stderr } = run(JSON.stringify({ TOKEN_ONE: 'one', TOKEN_TWO: 'two' }));
20
+ expect(status, stderr).toBe(0);
21
+ expect(masks(stdout)).toStrictEqual(['::add-mask::one', '::add-mask::two']);
22
+ });
23
+
24
+ it('registers a multi-line value line by line, skipping empty lines', () => {
25
+ const { status, stdout, stderr } = run(
26
+ JSON.stringify({ DEPLOY_KEY: '-----BEGIN KEY-----\nabc\n\n-----END KEY-----\n' }),
27
+ );
28
+ expect(status, stderr).toBe(0);
29
+ expect(masks(stdout)).toStrictEqual([
30
+ '::add-mask::-----BEGIN KEY-----',
31
+ '::add-mask::abc',
32
+ '::add-mask::-----END KEY-----',
33
+ ]);
34
+ });
35
+
36
+ it('skips values that are not strings', () => {
37
+ const { status, stdout, stderr } = run(JSON.stringify({ TOKEN_ONE: 'one', COUNT: 1 }));
38
+ expect(status, stderr).toBe(0);
39
+ expect(masks(stdout)).toStrictEqual(['::add-mask::one']);
40
+ });
41
+
42
+ it.each([
43
+ ['is unset', undefined],
44
+ ['is empty', ''],
45
+ ])('does nothing when CONFIG_SECRETS %s', (_, configSecrets) => {
46
+ const { status, stdout, stderr } = run(configSecrets);
47
+ expect(status, stderr).toBe(0);
48
+ expect(stdout).toBe('');
49
+ });
50
+
51
+ it.each([
52
+ ['is not JSON', 'apify_api_xyzzy'],
53
+ ['is a JSON string', '"apify_api_xyzzy"'],
54
+ ['is a JSON array', '["apify_api_xyzzy"]'],
55
+ ])('fails without printing the value when the secret %s', (_, configSecrets) => {
56
+ const { status, stdout, stderr } = run(configSecrets);
57
+ expect(status).toBe(1);
58
+ expect(stdout).toContain('::error::The CONFIG_SECRETS secret must be a JSON object.');
59
+ expect(stdout + stderr).not.toContain('xyzzy');
60
+ });
61
+ });
@@ -0,0 +1,115 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
9
+
10
+ const SCRIPT = fileURLToPath(new URL('../../../.github/scripts/run-with-apify-tokens.mjs', import.meta.url));
11
+
12
+ // Three Actors, each with its own token, so a test can leave any of them out.
13
+ const CONFIG = {
14
+ actors: [
15
+ { actorFullName: 'team/one', tokenEnvVar: 'TOKEN_ONE' },
16
+ { actorFullName: 'team/two', tokenEnvVar: 'TOKEN_TWO' },
17
+ { actorFullName: 'team/three', tokenEnvVar: 'TOKEN_THREE' },
18
+ ],
19
+ };
20
+
21
+ // The command the wrapper runs prints what it was handed, so a test sees exactly what got through.
22
+ const PRINT_ENV = `console.log(JSON.stringify({
23
+ TOKEN_ONE: process.env.TOKEN_ONE,
24
+ TOKEN_TWO: process.env.TOKEN_TWO,
25
+ TOKEN_THREE: process.env.TOKEN_THREE,
26
+ NPM_TOKEN: process.env.NPM_TOKEN,
27
+ WORKFLOW_SECRETS: process.env.WORKFLOW_SECRETS,
28
+ }))`;
29
+
30
+ let cwd: string;
31
+
32
+ // `workflowSecrets` is what the step's `toJSON(secrets)` holds inside the reusable workflow.
33
+ const run = (workflowSecrets: Record<string, string>) => {
34
+ const result = spawnSync(process.execPath, [SCRIPT, process.execPath, '-e', PRINT_ENV], {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ env: { PATH: process.env.PATH, WORKFLOW_SECRETS: JSON.stringify(workflowSecrets) },
38
+ });
39
+ return { ...result, env: result.status === 0 ? JSON.parse(result.stdout) : undefined };
40
+ };
41
+
42
+ describe('run-with-apify-tokens.mjs', () => {
43
+ beforeAll(async () => {
44
+ cwd = await mkdtemp(path.join(os.tmpdir(), 'run-with-apify-tokens-'));
45
+ await writeFile(path.join(cwd, 'apify-test-tools.config.json'), JSON.stringify(CONFIG));
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await rm(cwd, { recursive: true, force: true });
50
+ });
51
+
52
+ it('passes on inherited secrets, as `secrets: inherit` sends them', () => {
53
+ const { status, stderr, env } = run({
54
+ TOKEN_ONE: 'one',
55
+ TOKEN_TWO: 'two',
56
+ TOKEN_THREE: 'three',
57
+ NPM_TOKEN: 'npm',
58
+ });
59
+ expect(status, stderr).toBe(0);
60
+ expect(env).toStrictEqual({ TOKEN_ONE: 'one', TOKEN_TWO: 'two', TOKEN_THREE: 'three' });
61
+ });
62
+
63
+ it('unpacks the tokens from the CONFIG_SECRETS secret', () => {
64
+ const { status, stderr, env } = run({
65
+ CONFIG_SECRETS: JSON.stringify({ TOKEN_ONE: 'one', TOKEN_TWO: 'two', TOKEN_THREE: 'three' }),
66
+ NPM_TOKEN: 'npm',
67
+ });
68
+ expect(status, stderr).toBe(0);
69
+ expect(env).toStrictEqual({ TOKEN_ONE: 'one', TOKEN_TWO: 'two', TOKEN_THREE: 'three' });
70
+ });
71
+
72
+ it('passes on only the config names when CONFIG_SECRETS holds every secret of the caller', () => {
73
+ const { status, stderr, env } = run({
74
+ CONFIG_SECRETS: JSON.stringify({
75
+ TOKEN_ONE: 'one',
76
+ TOKEN_TWO: 'two',
77
+ TOKEN_THREE: 'three',
78
+ NPM_TOKEN: 'npm',
79
+ github_token: 'gh',
80
+ }),
81
+ });
82
+ expect(status, stderr).toBe(0);
83
+ expect(env).toStrictEqual({ TOKEN_ONE: 'one', TOKEN_TWO: 'two', TOKEN_THREE: 'three' });
84
+ });
85
+
86
+ it('prefers CONFIG_SECRETS over an inherited secret of the same name', () => {
87
+ const { status, stderr, env } = run({
88
+ CONFIG_SECRETS: JSON.stringify({ TOKEN_ONE: 'from-config-secrets' }),
89
+ TOKEN_ONE: 'inherited',
90
+ TOKEN_TWO: 'inherited',
91
+ });
92
+ expect(status, stderr).toBe(0);
93
+ expect(env).toStrictEqual({ TOKEN_ONE: 'from-config-secrets', TOKEN_TWO: 'inherited' });
94
+ });
95
+
96
+ it('warns about a token the config declares but nothing passes, and runs anyway', () => {
97
+ const { status, stderr, env } = run({
98
+ CONFIG_SECRETS: JSON.stringify({ TOKEN_ONE: 'one', TOKEN_TWO: 'two' }),
99
+ });
100
+ expect(status, stderr).toBe(0);
101
+ expect(stderr).toContain('declares TOKEN_THREE, but no such secret was passed');
102
+ expect(env).toStrictEqual({ TOKEN_ONE: 'one', TOKEN_TWO: 'two' });
103
+ });
104
+
105
+ it.each([
106
+ ['is not JSON', 'apify_api_xyzzy', 'The CONFIG_SECRETS secret is not valid JSON.'],
107
+ ['is a JSON string', '"apify_api_xyzzy"', 'The CONFIG_SECRETS secret must be a JSON object.'],
108
+ ['is a JSON array', '["apify_api_xyzzy"]', 'The CONFIG_SECRETS secret must be a JSON object.'],
109
+ ])('fails without printing the value when CONFIG_SECRETS %s', (_, packed, message) => {
110
+ const { status, stdout, stderr } = run({ CONFIG_SECRETS: packed });
111
+ expect(status).toBe(1);
112
+ expect(stderr).toContain(message);
113
+ expect(stdout + stderr).not.toContain('xyzzy');
114
+ });
115
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { checkRun } from '../../lib/run-checks.js';
4
+ import type { RunTestResult } from '../../lib/run-test-result.js';
5
+
6
+ const fakeRun = (overrides: { status?: string; log?: string; stats?: object; chargedEventCounts?: object } = {}) =>
7
+ ({
8
+ status: overrides.status ?? 'SUCCEEDED',
9
+ getRunInfo: async () => ({
10
+ stats: { durationMillis: 5_000 },
11
+ chargedEventCounts: overrides.chargedEventCounts,
12
+ }),
13
+ getDataset: async () => ({ items: new Array(10) }),
14
+ getStatistics: async () => ({
15
+ requestsFailed: 0,
16
+ requestsRetries: 1,
17
+ requestRetryHistogram: [5, 1],
18
+ ...overrides.stats,
19
+ }),
20
+ getLog: async () => overrides.log ?? 'all good',
21
+ }) as unknown as RunTestResult;
22
+
23
+ const failures = async (run: RunTestResult, options: Parameters<typeof checkRun>[1]) =>
24
+ (await checkRun(run, options)).filter(({ pass }) => !pass).map(({ name }) => name);
25
+
26
+ describe('checkRun()', () => {
27
+ it('passes a run matching the defaults', async () => {
28
+ expect(await failures(fakeRun(), { datasetItemCount: 10 })).toEqual([]);
29
+ });
30
+
31
+ it('reports every failing check at once', async () => {
32
+ const run = fakeRun({ status: 'FAILED', log: 'TypeError: x', stats: { requestsFailed: 2 } });
33
+ expect(await failures(run, { datasetItemCount: { min: 11 } })).toEqual([
34
+ 'status',
35
+ 'datasetItemCount',
36
+ 'failedRequests',
37
+ 'forbiddenLogs',
38
+ ]);
39
+ });
40
+
41
+ it('disables a check with null', async () => {
42
+ const run = fakeRun({ stats: { requestsFailed: 2 } });
43
+ expect(await failures(run, { datasetItemCount: 10, failedRequests: null })).toEqual([]);
44
+ });
45
+
46
+ it('treats explicitly undefined options as disabled checks', async () => {
47
+ const options = { datasetItemCount: 10, duration: undefined, maxRetriesPerRequest: undefined };
48
+ expect(await failures(fakeRun(), options)).toEqual([]);
49
+ });
50
+
51
+ it('checks the longest retry chain only when asked to', async () => {
52
+ expect(await failures(fakeRun(), { datasetItemCount: 10, maxRetriesPerRequest: 0 })).toEqual([
53
+ 'maxRetriesPerRequest',
54
+ ]);
55
+ expect(await failures(fakeRun(), { datasetItemCount: 10, maxRetriesPerRequest: 1 })).toEqual([]);
56
+ });
57
+
58
+ describe('chargedEventCounts', () => {
59
+ const run = fakeRun({ chargedEventCounts: { start: 1, item: 7 } });
60
+
61
+ it('passes matching counts and intervals', async () => {
62
+ const options = { datasetItemCount: 10, chargedEventCounts: { start: 1, item: { min: 5 } } };
63
+ expect(await failures(run, options)).toEqual([]);
64
+ });
65
+
66
+ it('expects unlisted charged events to not happen', async () => {
67
+ const options = { datasetItemCount: 10, chargedEventCounts: { start: 1 } as Record<string, number> };
68
+ expect(await failures(run, options)).toEqual(['chargedEventCounts.item']);
69
+ });
70
+
71
+ it('fails an expected event that was never charged', async () => {
72
+ const options = { datasetItemCount: 10, chargedEventCounts: { start: 1, item: 7, extra: { min: 0 } } };
73
+ expect(await failures(run, options)).toEqual(['chargedEventCounts.extra']);
74
+ });
75
+ });
76
+ });
@@ -0,0 +1,44 @@
1
+ import type { ActorRun, ApifyClient } from 'apify-client';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ import { RunTestResult } from '../../lib/run-test-result.js';
5
+
6
+ const run = { id: 'run', status: 'SUCCEEDED', defaultDatasetId: 'ds', defaultKeyValueStoreId: 'kvs' } as ActorRun;
7
+
8
+ describe('RunTestResult', () => {
9
+ it('fetches the log once, even when empty or requested concurrently', async () => {
10
+ const get = vi.fn(async () => '');
11
+ const client = { run: () => ({ log: () => ({ get }) }) } as unknown as ApifyClient;
12
+ const result = new RunTestResult(client, run);
13
+
14
+ await Promise.all([result.getLog(), result.getLog()]);
15
+ await result.getLog();
16
+
17
+ expect(get).toHaveBeenCalledTimes(1);
18
+ });
19
+
20
+ it('retries after a failed fetch', async () => {
21
+ const get = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce('log');
22
+ const client = { run: () => ({ log: () => ({ get }) }) } as unknown as ApifyClient;
23
+ const result = new RunTestResult(client, run);
24
+
25
+ await expect(result.getLog()).rejects.toThrow('boom');
26
+ expect(await result.getLog()).toBe('log');
27
+ });
28
+
29
+ it('pages through the dataset instead of listing it in one response', async () => {
30
+ const total = 25_000;
31
+ const listItems = vi.fn(async ({ offset, limit }: { offset: number; limit: number }) => ({
32
+ total,
33
+ items: Array.from({ length: Math.min(limit, total - offset) }, (_, i) => offset + i),
34
+ }));
35
+ const client = { dataset: () => ({ listItems }) } as unknown as ApifyClient;
36
+ const result = new RunTestResult(client, run);
37
+
38
+ const { items } = await result.getDataset<number>();
39
+
40
+ expect(items).toHaveLength(total);
41
+ expect(items[24_999]).toBe(24_999);
42
+ expect(listItems).toHaveBeenCalledTimes(25);
43
+ });
44
+ });