apify-test-tools 0.10.2-beta.4 → 0.10.2-beta.5

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 (90) hide show
  1. package/.github/cli/apify-test-tools.mjs +125 -127
  2. package/.github/scripts/run-with-apify-tokens.mjs +1 -1
  3. package/CHANGELOG.md +5 -0
  4. package/CONTRIBUTING.md +4 -1
  5. package/README.md +69 -71
  6. package/bin/affected-actors.ts +132 -91
  7. package/bin/build.ts +0 -2
  8. package/bin/diff-changes.ts +7 -6
  9. package/bin/diff-json-schema.ts +5 -6
  10. package/bin/git.ts +21 -55
  11. package/bin/main.ts +7 -97
  12. package/bin/slack.ts +14 -27
  13. package/bin/types.ts +6 -0
  14. package/dist/bin/affected-actors.d.ts +23 -22
  15. package/dist/bin/affected-actors.d.ts.map +1 -1
  16. package/dist/bin/affected-actors.js +108 -60
  17. package/dist/bin/affected-actors.js.map +1 -1
  18. package/dist/bin/build.d.ts +0 -1
  19. package/dist/bin/build.d.ts.map +1 -1
  20. package/dist/bin/build.js +0 -2
  21. package/dist/bin/build.js.map +1 -1
  22. package/dist/bin/diff-changes.d.ts +4 -3
  23. package/dist/bin/diff-changes.d.ts.map +1 -1
  24. package/dist/bin/diff-changes.js +4 -4
  25. package/dist/bin/diff-changes.js.map +1 -1
  26. package/dist/bin/diff-json-schema.d.ts +2 -2
  27. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  28. package/dist/bin/diff-json-schema.js +1 -4
  29. package/dist/bin/diff-json-schema.js.map +1 -1
  30. package/dist/bin/git.d.ts +6 -9
  31. package/dist/bin/git.d.ts.map +1 -1
  32. package/dist/bin/git.js +20 -48
  33. package/dist/bin/git.js.map +1 -1
  34. package/dist/bin/main.js +7 -77
  35. package/dist/bin/main.js.map +1 -1
  36. package/dist/bin/slack.d.ts +2 -7
  37. package/dist/bin/slack.d.ts.map +1 -1
  38. package/dist/bin/slack.js +12 -18
  39. package/dist/bin/slack.js.map +1 -1
  40. package/dist/bin/types.d.ts +5 -0
  41. package/dist/bin/types.d.ts.map +1 -1
  42. package/dist/test/bundle/cli-bundle.test.js +14 -16
  43. package/dist/test/bundle/cli-bundle.test.js.map +1 -1
  44. package/dist/test/e2e/cli-bundle.e2e.test.js +22 -63
  45. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -1
  46. package/dist/test/e2e/git-build-release.e2e.test.d.ts +2 -0
  47. package/dist/test/e2e/git-build-release.e2e.test.d.ts.map +1 -0
  48. package/dist/test/e2e/git-build-release.e2e.test.js +87 -0
  49. package/dist/test/e2e/git-build-release.e2e.test.js.map +1 -0
  50. package/dist/test/e2e/helpers.d.ts +37 -0
  51. package/dist/test/e2e/helpers.d.ts.map +1 -0
  52. package/dist/test/e2e/helpers.js +57 -0
  53. package/dist/test/e2e/helpers.js.map +1 -0
  54. package/dist/test/unit/bin/affected-actors-cli.test.d.ts +2 -0
  55. package/dist/test/unit/bin/affected-actors-cli.test.d.ts.map +1 -0
  56. package/dist/test/unit/bin/affected-actors-cli.test.js +161 -0
  57. package/dist/test/unit/bin/affected-actors-cli.test.js.map +1 -0
  58. package/dist/test/unit/bin/affected-actors.test.js +65 -78
  59. package/dist/test/unit/bin/affected-actors.test.js.map +1 -1
  60. package/dist/test/unit/bin/build-cli.test.js +5 -13
  61. package/dist/test/unit/bin/build-cli.test.js.map +1 -1
  62. package/dist/test/unit/bin/compiled-cli.d.ts +6 -0
  63. package/dist/test/unit/bin/compiled-cli.d.ts.map +1 -0
  64. package/dist/test/unit/bin/compiled-cli.js +16 -0
  65. package/dist/test/unit/bin/compiled-cli.js.map +1 -0
  66. package/dist/test/unit/bin/diff-changes.test.js +55 -55
  67. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  68. package/dist/test/unit/bin/diff.test.js +25 -28
  69. package/dist/test/unit/bin/diff.test.js.map +1 -1
  70. package/dist/test/unit/bin/git.test.js +29 -97
  71. package/dist/test/unit/bin/git.test.js.map +1 -1
  72. package/dist/test/unit/bin/slack.test.js +6 -27
  73. package/dist/test/unit/bin/slack.test.js.map +1 -1
  74. package/dist/test/unit/should-built-and-test.test.js +18 -21
  75. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  76. package/dist/tsconfig.tsbuildinfo +1 -1
  77. package/package.json +1 -1
  78. package/test/bundle/cli-bundle.test.ts +14 -24
  79. package/test/e2e/cli-bundle.e2e.test.ts +39 -84
  80. package/test/e2e/git-build-release.e2e.test.ts +113 -0
  81. package/test/e2e/helpers.ts +82 -0
  82. package/test/unit/bin/affected-actors-cli.test.ts +193 -0
  83. package/test/unit/bin/affected-actors.test.ts +83 -85
  84. package/test/unit/bin/build-cli.test.ts +6 -12
  85. package/test/unit/bin/compiled-cli.ts +17 -0
  86. package/test/unit/bin/diff-changes.test.ts +55 -55
  87. package/test/unit/bin/diff.test.ts +25 -28
  88. package/test/unit/bin/git.test.ts +37 -120
  89. package/test/unit/bin/slack.test.ts +6 -30
  90. package/test/unit/should-built-and-test.test.ts +19 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apify-test-tools",
3
- "version": "0.10.2-beta.4",
3
+ "version": "0.10.2-beta.5",
4
4
  "type": "module",
5
5
  "description": "TBD",
6
6
  "repository": {
@@ -98,8 +98,6 @@ describe('CLI bundle', () => {
98
98
  'get-affected-actors',
99
99
  'report-tests',
100
100
  'build',
101
- 'build-for-test',
102
- 'release',
103
101
  'notify-release',
104
102
  'build-from-local',
105
103
  'delete-old-builds',
@@ -181,35 +179,27 @@ describe('CLI bundle', () => {
181
179
  expect(stderr).toContain('got nothing');
182
180
  });
183
181
 
184
- it('build-for-test --dry-run with nothing to build needs no token', () => {
185
- const { status, stdout, stderr } = runCli(
186
- 'build-for-test',
187
- '--target-branch',
188
- 'main',
189
- '--actors',
190
- 'tester/bar',
191
- '--dry-run',
192
- );
182
+ it('get-affected-actors --since HEAD reports nothing', () => {
183
+ const { status, stdout, stderr } = runCli('get-affected-actors', '--since', headSha);
193
184
  expect(status, stderr).toBe(0);
194
- expect(JSON.parse(stdout)).toEqual({ dryRun: true, builds: [] });
195
- });
196
-
197
- it('build-for-test --dry-run fails cleanly when the Actor token is missing', () => {
198
- const { status, stderr } = runCli('build-for-test', '--target-branch', 'main', '--dry-run');
199
- expect(status).toBe(1);
200
- expect(stderr).toContain('Env var FOO_TOKEN is not set (needed for actor "tester/foo")');
185
+ expect(JSON.parse(stdout)).toMatchObject({
186
+ commits: [],
187
+ functionallyChangedActors: [],
188
+ cosmeticallyChangedActors: [],
189
+ });
201
190
  });
202
191
 
203
- it('release --dry-run stops when HEAD is the base commit', () => {
204
- const { status, stderr } = runCli('release', '--base-commit', headSha, '--dry-run');
192
+ it('notify-release announces nothing, and needs no token, when there are no new commits', () => {
193
+ const affected = runCli('get-affected-actors', '--since', headSha).stdout;
194
+ const { status, stderr } = runCliWithInput(affected, 'notify-release', '--release-slack-channel', '#releases');
205
195
  expect(status, stderr).toBe(0);
206
- expect(stderr).toContain(`HEAD is the base commit ${headSha}, there is nothing new to release`);
196
+ expect(stderr).toContain('No new commits, so there is no release to announce');
207
197
  });
208
198
 
209
- it('release --dry-run with nothing to build skips Slack without a token', () => {
210
- const { status, stderr } = runCli('release', '--base-commit', baseSha, '--actors', 'tester/bar', '--dry-run');
199
+ it('notify-release skips Slack without a channel', () => {
200
+ const affected = runCli('get-affected-actors', '--since', baseSha).stdout;
201
+ const { status, stderr } = runCliWithInput(affected, 'notify-release', '--dry-run');
211
202
  expect(status, stderr).toBe(0);
212
- expect(stderr).toContain('{"dryRun":true,"builds":[]}');
213
203
  expect(stderr).toContain('Skipping slack notification');
214
204
  });
215
205
 
@@ -1,48 +1,37 @@
1
- import { execFileSync, spawnSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import process from 'node:process';
6
- import { fileURLToPath } from 'node:url';
7
6
 
8
7
  import { ApifyClient } from 'apify-client';
9
8
  import { afterAll, beforeAll, describe, expect, it } from 'vitest';
10
9
 
10
+ import type { ActorBuild } from './helpers.js';
11
+ import {
12
+ cliEnv,
13
+ gitEnv,
14
+ projectRoot,
15
+ runCli as runCliIn,
16
+ runCliWithInput as runCliWithInputIn,
17
+ runProcess,
18
+ token,
19
+ TOKEN_ENV_VAR,
20
+ } from './helpers.js';
21
+
11
22
  // End-to-end run of the shipped CLI bundle against two dummy Actors in an isolated sandbox account:
12
23
  // dry-run plans read the real Actors, `build-from-local` really builds them, the lib runs them
13
24
  // through `testActor` on exactly those builds (the ACTOR_BUILDS handoff), `report-tests` reads the
14
- // result, and `delete-old-builds` cleans up. Git builds (`build`/`release` without --dry-run) are
15
- // not covered: the Apify builder would have to clone the fixture repo, which only exists locally.
25
+ // result, and `delete-old-builds` cleans up. Git builds of a real repository are in
26
+ // git-build-release.e2e.test.ts.
16
27
  //
17
28
  // Runs with `npm run test:e2e`, kept out of `npm test`. Skipped without the token, e.g. on fork PRs.
18
- const TOKEN_ENV_VAR = 'E2E_TEST_APIFY_TOKEN_DBYPODOIHUZABNQTR';
19
- const token = process.env[TOKEN_ENV_VAR];
20
-
21
29
  const ACTORS = ['test-tools-testing-sandbox/dummy-for-testing-1', 'test-tools-testing-sandbox/dummy-for-testing-2'];
22
30
  // Only used as the source URL in dry-run plans; nothing is cloned from it.
23
31
  const REPO_URL = 'https://github.com/apify/apify-test-tools-e2e-fixture';
24
32
 
25
- const projectRoot = fileURLToPath(new URL('../../', import.meta.url));
26
- const bundlePath = path.join(projectRoot, '.github/cli/apify-test-tools.mjs');
27
33
  const vitestPath = path.join(projectRoot, 'node_modules/vitest/vitest.mjs');
28
34
 
29
- // Proxy and CA settings pass through so the API is reachable behind a proxy; no other variable does.
30
- const networkEnv = Object.fromEntries(
31
- ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy', 'NODE_EXTRA_CA_CERTS']
32
- .filter((name) => process.env[name] !== undefined)
33
- .map((name) => [name, process.env[name]!]),
34
- );
35
- const baseEnv = { PATH: process.env.PATH ?? '', HOME: os.tmpdir() };
36
- // The CLI gets only the sandbox token, under the name the fixture config points at.
37
- const cliEnv = { ...baseEnv, ...networkEnv, [TOKEN_ENV_VAR]: token ?? '' };
38
- const gitEnv = {
39
- ...baseEnv,
40
- GIT_AUTHOR_NAME: 'Tester',
41
- GIT_AUTHOR_EMAIL: 'tester@example.com',
42
- GIT_COMMITTER_NAME: 'Tester',
43
- GIT_COMMITTER_EMAIL: 'tester@example.com',
44
- };
45
-
46
35
  let repoDir: string;
47
36
  let baseSha: string;
48
37
 
@@ -53,30 +42,8 @@ const writeRepoFile = async (file: string, content: string) => {
53
42
  await writeFile(path.join(repoDir, file), content);
54
43
  };
55
44
 
56
- // Spawned processes are blocking, so vitest's own timeouts can't stop one that hangs. apify-client
57
- // retries an unreachable API for minutes, so a hang is killed here and fails with a clear error.
58
- const PROCESS_TIMEOUT_MS = 8 * 60_000;
59
-
60
- const runCliWithInput = (input: string, ...args: string[]) => {
61
- const result = spawnSync(process.execPath, [bundlePath, ...args], {
62
- cwd: repoDir,
63
- env: cliEnv,
64
- encoding: 'utf8',
65
- timeout: PROCESS_TIMEOUT_MS,
66
- input,
67
- });
68
- const error = result.error ? `\n${result.error.message}` : '';
69
- return { status: result.status, stdout: result.stdout, stderr: result.stderr + error };
70
- };
71
-
72
- const runCli = (...args: string[]) => runCliWithInput('', ...args);
73
-
74
- interface ActorBuild {
75
- buildId: string;
76
- actorRawId: string;
77
- buildNumber: string;
78
- actorFullName: string;
79
- }
45
+ const runCli = async (...args: string[]) => runCliIn(repoDir, ...args);
46
+ const runCliWithInput = async (input: string, ...args: string[]) => runCliWithInputIn(repoDir, input, ...args);
80
47
 
81
48
  describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 }, () => {
82
49
  let builds: ActorBuild[];
@@ -120,8 +87,8 @@ describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 },
120
87
  if (repoDir) await rm(repoDir, { recursive: true, force: true });
121
88
  });
122
89
 
123
- it('build --dry-run plans a test build of the Actors it is given', () => {
124
- const { status, stdout, stderr } = runCliWithInput(
90
+ it('build --dry-run plans a test build of the Actors it is given', async () => {
91
+ const { status, stdout, stderr } = await runCliWithInput(
125
92
  JSON.stringify({ actors: [{ actorFullName: ACTORS[0] }] }),
126
93
  'build',
127
94
  '--version-number',
@@ -139,35 +106,28 @@ describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 },
139
106
  });
140
107
  });
141
108
 
142
- it('build-for-test --dry-run plans a test build of the changed Actor only', () => {
143
- const { status, stdout, stderr } = runCli(
144
- 'build-for-test',
145
- '--target-branch',
146
- 'main',
109
+ it('a release pipeline plans a build of the default version of every changed Actor', async () => {
110
+ const affected = await runCli('get-affected-actors', '--since', baseSha);
111
+ expect(affected.status, affected.stderr).toBe(0);
112
+ const { functionallyChangedActors, cosmeticallyChangedActors } = JSON.parse(affected.stdout);
113
+
114
+ const { status, stderr } = await runCliWithInput(
115
+ JSON.stringify({ actors: [...functionallyChangedActors, ...cosmeticallyChangedActors] }),
116
+ 'build',
117
+ '--version-number',
118
+ 'default',
147
119
  '--repo-url',
148
120
  REPO_URL,
149
121
  '--dry-run',
150
122
  );
151
123
  expect(status, stderr).toBe(0);
152
- expect(JSON.parse(stdout)).toEqual({
153
- dryRun: true,
154
- builds: [
155
- { actorFullName: ACTORS[0], versionNumber: '0.99', gitRepoUrl: `${REPO_URL}#feature:actors/dummy-1` },
156
- ],
157
- });
158
- });
159
-
160
- it('release --dry-run plans a build of the default version and tag', () => {
161
- const { status, stderr } = runCli('release', '--base-commit', baseSha, '--repo-url', REPO_URL, '--dry-run');
162
- expect(status, stderr).toBe(0);
163
124
  expect(stderr).toContain(
164
125
  `[DRY RUN] Would build:\n ${ACTORS[0]} (0.0) from ${REPO_URL}#feature:actors/dummy-1`,
165
126
  );
166
- expect(stderr).toContain('Skipping slack notification');
167
127
  });
168
128
 
169
- it('build-from-local --dry-run plans both Actors from the working tree', () => {
170
- const { status, stdout, stderr } = runCli('build-from-local', '--dry-run');
129
+ it('build-from-local --dry-run plans both Actors from the working tree', async () => {
130
+ const { status, stdout, stderr } = await runCli('build-from-local', '--dry-run');
171
131
  expect(status, stderr).toBe(0);
172
132
  expect(JSON.parse(stdout)).toMatchObject({
173
133
  dryRun: true,
@@ -175,8 +135,8 @@ describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 },
175
135
  });
176
136
  });
177
137
 
178
- it('build-from-local builds both Actors', { timeout: 10 * 60_000 }, () => {
179
- const { status, stdout, stderr } = runCli('build-from-local');
138
+ it('build-from-local builds both Actors', { timeout: 10 * 60_000 }, async () => {
139
+ const { status, stdout, stderr } = await runCli('build-from-local');
180
140
  expect(status, stderr).toBe(0);
181
141
  builds = JSON.parse(stdout);
182
142
  expect(builds.map((build) => build.actorFullName)).toEqual(ACTORS);
@@ -206,20 +166,15 @@ describe('sandbox dummies', () => {${actorTests}
206
166
  `,
207
167
  );
208
168
 
209
- const vitest = spawnSync(
169
+ const vitest = await runProcess(
210
170
  process.execPath,
211
171
  [vitestPath, 'run', 'test/platform', '--reporter=json', '--outputFile=./test-output.json'],
212
172
  {
213
173
  cwd: repoDir,
214
- encoding: 'utf8',
215
- timeout: PROCESS_TIMEOUT_MS,
216
- env: { ...cliEnv, TESTER_APIFY_TOKEN: token, ACTOR_BUILDS: JSON.stringify(builds) },
174
+ env: { ...cliEnv, TESTER_APIFY_TOKEN: token!, ACTOR_BUILDS: JSON.stringify(builds) },
217
175
  },
218
176
  );
219
- expect(
220
- vitest.status,
221
- `${vitest.stdout}\n${vitest.stderr}${vitest.error ? `\n${vitest.error.message}` : ''}`,
222
- ).toBe(0);
177
+ expect(vitest.status, `${vitest.stdout}\n${vitest.stderr}`).toBe(0);
223
178
 
224
179
  // Each run used the build from ACTOR_BUILDS, not the Actor's default build.
225
180
  const report = JSON.parse(await readFile(path.join(repoDir, 'test-output.json'), 'utf8'));
@@ -235,15 +190,15 @@ describe('sandbox dummies', () => {${actorTests}
235
190
  );
236
191
  expect(runBuildIds.sort()).toEqual(builds.map((build) => build.buildId).sort());
237
192
 
238
- const { status, stderr } = runCli('report-tests', '--report-file', 'test-output.json', '--dry-run');
193
+ const { status, stderr } = await runCli('report-tests', '--report-file', 'test-output.json', '--dry-run');
239
194
  expect(status, stderr).toBe(0);
240
195
  expect(stderr).toContain(`PASSED: ${ACTORS.length}, FAILED: 0`);
241
196
  expect(stderr).toContain('https://console.apify.com/view/runs/');
242
197
  });
243
198
 
244
199
  // Last, so builds from earlier runs of this suite don't pile up in the sandbox.
245
- it('delete-old-builds keeps the default builds', () => {
246
- const { status, stderr } = runCli('delete-old-builds');
200
+ it('delete-old-builds keeps the default builds', async () => {
201
+ const { status, stderr } = await runCli('delete-old-builds');
247
202
  expect(status, stderr).toBe(0);
248
203
  for (const actorFullName of ACTORS) {
249
204
  expect(stderr).toContain(`[DELETE OLD BUILDS][${actorFullName}]: Skipping default build`);
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ import { ApifyClient } from 'apify-client';
7
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
8
+
9
+ import type { ActorBuild } from './helpers.js';
10
+ import { gitEnv, runCli as runCliIn, runCliWithInput as runCliWithInputIn, token } from './helpers.js';
11
+
12
+ // Real git builds of a real repository: the shipped CLI bundle builds an Actor from a clone of
13
+ // apify-store/testing-repo-for-github-actions, and the Apify builder clones the same repository
14
+ // (over SSH, with the sandbox org's deploy key installed on it).
15
+ //
16
+ // The repo's `e2e/base` and `e2e/feature` branches are fixtures and never change: `e2e/feature`
17
+ // adds one commit touching only dummy-for-testing-1. Like the reusable workflows, the commands run
18
+ // with `e2e/feature` checked out and compare it with `origin/e2e/base`. The release pipeline rebuilds
19
+ // dummy-for-testing-1 under `latest` on every run, which is fine for a sandbox Actor nobody else uses.
20
+ //
21
+ // Runs with `npm run test:e2e`, kept out of `npm test`. Skipped without the token, e.g. on fork PRs.
22
+ const FIXTURE_REPO = 'https://github.com/apify-store/testing-repo-for-github-actions';
23
+ const BASE_BRANCH = 'e2e/base';
24
+ const FEATURE_BRANCH = 'e2e/feature';
25
+ const CHANGED_ACTOR = 'test-tools-testing-sandbox/dummy-for-testing-1';
26
+ const CHANGED_ACTOR_FOLDER = 'actors/dummy-for-testing-1';
27
+ // The CLI builds from the SSH form of the origin URL, which is what lets a deploy key work.
28
+ const BUILD_SOURCE = `git@github.com:apify-store/testing-repo-for-github-actions#${FEATURE_BRANCH}:${CHANGED_ACTOR_FOLDER}`;
29
+
30
+ let repoDir: string;
31
+
32
+ const git = (...args: string[]) => execFileSync('git', args, { cwd: repoDir, env: gitEnv, encoding: 'utf8' }).trim();
33
+ const runCli = async (...args: string[]) => runCliIn(repoDir, ...args);
34
+ const runCliWithInput = async (input: string, ...args: string[]) => runCliWithInputIn(repoDir, input, ...args);
35
+
36
+ const getAffectedActors = async () => {
37
+ const { status, stdout, stderr } = await runCli('get-affected-actors', '--base', `origin/${BASE_BRANCH}`);
38
+ expect(status, stderr).toBe(0);
39
+ return JSON.parse(stdout);
40
+ };
41
+
42
+ describe.runIf(token)('CLI bundle building a real repository', { timeout: 2 * 60_000 }, () => {
43
+ const client = new ApifyClient({ token });
44
+
45
+ const versionSource = async (versionNumber: string) => {
46
+ const actor = await client.actor(CHANGED_ACTOR).get();
47
+ const version = actor?.versions.find(({ versionNumber: number }) => number === versionNumber);
48
+ return version && 'gitRepoUrl' in version ? version.gitRepoUrl : undefined;
49
+ };
50
+
51
+ // A full clone checked out on the feature branch, like actions/checkout with fetch-depth: 0.
52
+ beforeAll(async () => {
53
+ repoDir = await mkdtemp(path.join(os.tmpdir(), 'apify-cli-e2e-git-'));
54
+ execFileSync('git', ['clone', '-q', FIXTURE_REPO, repoDir], { env: gitEnv });
55
+ git('checkout', '-q', '-b', FEATURE_BRANCH, `origin/${FEATURE_BRANCH}`);
56
+ }, 5 * 60_000);
57
+
58
+ afterAll(async () => {
59
+ if (repoDir) await rm(repoDir, { recursive: true, force: true });
60
+ });
61
+
62
+ it('get-affected-actors finds only the Actor the feature branch changed', async () => {
63
+ expect(await getAffectedActors()).toMatchObject({
64
+ functionallyChangedActors: [{ actorFullName: CHANGED_ACTOR }],
65
+ cosmeticallyChangedActors: [],
66
+ });
67
+ });
68
+
69
+ it('build builds the changed Actor from the feature branch', { timeout: 10 * 60_000 }, async () => {
70
+ // What the PR workflow does: the functionally changed Actors, piped into a test build
71
+ const { functionallyChangedActors } = await getAffectedActors();
72
+ const { status, stdout, stderr } = await runCliWithInput(
73
+ JSON.stringify({ actors: functionallyChangedActors }),
74
+ 'build',
75
+ '--version-number',
76
+ '0.99',
77
+ );
78
+ expect(status, stderr).toBe(0);
79
+ const builds: ActorBuild[] = JSON.parse(stdout);
80
+ expect(builds).toMatchObject([
81
+ { actorFullName: CHANGED_ACTOR, buildNumber: expect.stringMatching(/^0\.99\.\d+$/) },
82
+ ]);
83
+
84
+ // The version builds the checked-out branch, as it is on the remote.
85
+ expect(await versionSource('0.99')).toBe(BUILD_SOURCE);
86
+ expect((await client.build(builds[0].buildId).get())?.status).toBe('SUCCEEDED');
87
+ });
88
+
89
+ it('the release pipeline rebuilds the default version of the changed Actor', { timeout: 10 * 60_000 }, async () => {
90
+ // What the release workflow does: every Actor changed since the last release, built into its default version
91
+ const baseSha = git('rev-parse', `origin/${BASE_BRANCH}`);
92
+ const affected = await runCli('get-affected-actors', '--since', baseSha);
93
+ expect(affected.status, affected.stderr).toBe(0);
94
+ const { functionallyChangedActors, cosmeticallyChangedActors } = JSON.parse(affected.stdout);
95
+
96
+ const { status, stderr } = await runCliWithInput(
97
+ JSON.stringify({ actors: [...functionallyChangedActors, ...cosmeticallyChangedActors] }),
98
+ 'build',
99
+ '--version-number',
100
+ 'default',
101
+ );
102
+ expect(status, stderr).toBe(0);
103
+ expect(stderr).toMatch(new RegExp(`\\[${CHANGED_ACTOR}\\]: 0\\.0\\.\\d+`));
104
+
105
+ // Releasing builds the default version from the branch being released.
106
+ expect(await versionSource('0.0')).toBe(BUILD_SOURCE);
107
+
108
+ // Without a channel the announcement is skipped, so it needs no Slack token
109
+ const notify = await runCliWithInput(affected.stdout, 'notify-release');
110
+ expect(notify.status, notify.stderr).toBe(0);
111
+ expect(notify.stderr).toContain('Skipping slack notification');
112
+ });
113
+ });
@@ -0,0 +1,82 @@
1
+ import { execFile, type ExecFileException } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { promisify } from 'node:util';
7
+
8
+ // Shared by the e2e suites. They run the shipped CLI bundle against Actors in the isolated
9
+ // `test-tools-testing-sandbox` org, whose token can reach nothing else.
10
+ export const TOKEN_ENV_VAR = 'E2E_TEST_APIFY_TOKEN_DBYPODOIHUZABNQTR';
11
+ export const token = process.env[TOKEN_ENV_VAR];
12
+
13
+ export const projectRoot = fileURLToPath(new URL('../../', import.meta.url));
14
+ export const bundlePath = path.join(projectRoot, '.github/cli/apify-test-tools.mjs');
15
+
16
+ // Proxy and CA settings pass through so the API is reachable behind a proxy; no other variable does.
17
+ const networkEnv = Object.fromEntries(
18
+ ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy', 'NODE_EXTRA_CA_CERTS']
19
+ .filter((name) => process.env[name] !== undefined)
20
+ .map((name) => [name, process.env[name]!]),
21
+ );
22
+ const baseEnv = { PATH: process.env.PATH ?? '', HOME: os.tmpdir() };
23
+ // The CLI gets only the sandbox token, under the name the fixture configs point at.
24
+ export const cliEnv = { ...baseEnv, ...networkEnv, [TOKEN_ENV_VAR]: token ?? '' };
25
+ export const gitEnv = {
26
+ ...baseEnv,
27
+ ...networkEnv,
28
+ GIT_AUTHOR_NAME: 'Tester',
29
+ GIT_AUTHOR_EMAIL: 'tester@example.com',
30
+ GIT_COMMITTER_NAME: 'Tester',
31
+ GIT_COMMITTER_EMAIL: 'tester@example.com',
32
+ };
33
+
34
+ // Processes are spawned asynchronously: a blocking spawn would stall the test worker for the length
35
+ // of a build, long enough for vitest's own worker RPC to time out. The kill timeout still applies on
36
+ // top of vitest's, since apify-client retries an unreachable API for minutes.
37
+ export const PROCESS_TIMEOUT_MS = 8 * 60_000;
38
+ const execFileAsync = promisify(execFile);
39
+
40
+ export interface ProcessResult {
41
+ status: number | null;
42
+ stdout: string;
43
+ stderr: string;
44
+ }
45
+
46
+ export const runProcess = async (
47
+ command: string,
48
+ args: string[],
49
+ { input = '', ...options }: { cwd: string; env: Record<string, string>; input?: string },
50
+ ): Promise<ProcessResult> => {
51
+ try {
52
+ const promise = execFileAsync(command, args, {
53
+ ...options,
54
+ encoding: 'utf8',
55
+ timeout: PROCESS_TIMEOUT_MS,
56
+ maxBuffer: 100 * 1024 * 1024,
57
+ });
58
+ // Closing stdin hands a command that reads it (e.g. `build`) the input, and one that doesn't never waits
59
+ promise.child.stdin?.end(input);
60
+ const { stdout, stderr } = await promise;
61
+ return { status: 0, stdout, stderr };
62
+ } catch (error) {
63
+ // A non-zero exit rejects too, with the output on the error.
64
+ const { code, stdout = '', stderr = '', message } = error as ExecFileException;
65
+ // Without an exit code (killed by the timeout, or never started) the message says why.
66
+ return typeof code === 'number'
67
+ ? { status: code, stdout, stderr }
68
+ : { status: null, stdout, stderr: `${stderr}\n${message}` };
69
+ }
70
+ };
71
+
72
+ export const runCliWithInput = async (cwd: string, input: string, ...args: string[]) =>
73
+ runProcess(process.execPath, [bundlePath, ...args], { cwd, env: cliEnv, input });
74
+
75
+ export const runCli = async (cwd: string, ...args: string[]) => runCliWithInput(cwd, '', ...args);
76
+
77
+ export interface ActorBuild {
78
+ buildId: string;
79
+ actorRawId: string;
80
+ buildNumber: string;
81
+ actorFullName: string;
82
+ }
@@ -0,0 +1,193 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { mkdir, 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
+
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
8
+
9
+ import { compileCli } from './compiled-cli.js';
10
+
11
+ // `get-affected-actors --base` on real git histories. Actors a and c share `src/`, b is standalone.
12
+ // `main` is the base branch and `feature` the PR branch, checked out like in the PR workflow.
13
+ const CONFIG = {
14
+ actors: [
15
+ {
16
+ folder: 'actors/a',
17
+ actorFullName: 'owner/actor-a',
18
+ tokenEnvVar: 'T',
19
+ overrideActorContext: ['actors/a', 'src'],
20
+ },
21
+ {
22
+ folder: 'actors/c',
23
+ actorFullName: 'owner/actor-c',
24
+ tokenEnvVar: 'T',
25
+ overrideActorContext: ['actors/c', 'src'],
26
+ },
27
+ { folder: 'actors/b', actorFullName: 'owner/actor-b', tokenEnvVar: 'T' },
28
+ ],
29
+ };
30
+
31
+ // Only what node and git need, so nothing from the developer's git config leaks in
32
+ const env = { PATH: process.env.PATH ?? '', HOME: os.tmpdir() };
33
+ const gitEnv = {
34
+ ...env,
35
+ GIT_AUTHOR_NAME: 'Tester',
36
+ GIT_AUTHOR_EMAIL: 'tester@example.com',
37
+ GIT_COMMITTER_NAME: 'Tester',
38
+ GIT_COMMITTER_EMAIL: 'tester@example.com',
39
+ };
40
+
41
+ let cliPath: string;
42
+ let cleanup: (() => Promise<void>) | undefined;
43
+ let repoDir: string;
44
+
45
+ const git = (...args: string[]) => execFileSync('git', args, { cwd: repoDir, env: gitEnv, encoding: 'utf8' }).trim();
46
+
47
+ const writeFiles = async (content: string, files: string[]) => {
48
+ for (const file of files) {
49
+ await mkdir(path.dirname(path.join(repoDir, file)), { recursive: true });
50
+ await writeFile(path.join(repoDir, file), `${content}\n`);
51
+ }
52
+ };
53
+
54
+ /** Commits a change to each file and returns the commit. */
55
+ const commit = async (message: string, ...files: string[]) => {
56
+ await writeFiles(message, files);
57
+ git('add', '-A');
58
+ git('commit', '-q', '-m', message);
59
+ return git('rev-parse', 'HEAD');
60
+ };
61
+
62
+ /** Commits on `main`, then checks `feature` out again. */
63
+ const commitOnMain = async (message: string, ...files: string[]) => {
64
+ git('checkout', '-q', 'main');
65
+ await commit(message, ...files);
66
+ git('checkout', '-q', 'feature');
67
+ };
68
+
69
+ const runGetAffectedActors = (...args: string[]) => {
70
+ const result = spawnSync(cliPath, ['get-affected-actors', ...args], { cwd: repoDir, env, encoding: 'utf8' });
71
+ expect(result.status, result.stderr).toBe(0);
72
+ return JSON.parse(result.stdout);
73
+ };
74
+
75
+ const getAffectedActors = (...args: string[]) => {
76
+ const document = runGetAffectedActors(...args);
77
+ const names = (actors: { actorFullName: string }[]) =>
78
+ actors.map(({ actorFullName }) => actorFullName.replace('owner/actor-', '')).sort();
79
+ return {
80
+ functional: names(document.functionallyChangedActors),
81
+ cosmetic: names(document.cosmeticallyChangedActors),
82
+ };
83
+ };
84
+
85
+ describe('get-affected-actors CLI on real git histories', () => {
86
+ beforeAll(async () => {
87
+ ({ cliPath, cleanup } = await compileCli());
88
+ });
89
+
90
+ afterAll(async () => {
91
+ await cleanup?.();
92
+ });
93
+
94
+ beforeEach(async () => {
95
+ repoDir = await mkdtemp(path.join(os.tmpdir(), 'apify-affected-actors-'));
96
+ git('init', '-q', '-b', 'main');
97
+ await writeFile(path.join(repoDir, 'apify-test-tools.config.json'), JSON.stringify(CONFIG));
98
+ for (const { folder } of CONFIG.actors) {
99
+ await mkdir(path.join(repoDir, folder, '.actor'), { recursive: true });
100
+ await writeFile(
101
+ path.join(repoDir, folder, '.actor/actor.json'),
102
+ JSON.stringify({ actorSpecification: 1, name: `actor-${path.basename(folder)}`, version: '0.1' }),
103
+ );
104
+ }
105
+ await commit('chore: init', 'src/shared.ts', 'actors/a/main.ts', 'actors/b/main.ts', 'actors/c/main.ts');
106
+ git('checkout', '-q', '-b', 'feature');
107
+ });
108
+
109
+ afterEach(async () => {
110
+ if (repoDir) await rm(repoDir, { recursive: true, force: true });
111
+ });
112
+
113
+ it("tests only what changed since the last validated commit, on the branch's own commits", async () => {
114
+ const validated = await commit('feat(a): validated change', 'actors/a/main.ts');
115
+ await commit('feat(c): new change, docs for a', 'actors/c/main.ts', 'actors/a/README.md');
116
+
117
+ // A is still the validated code, only its README changed since
118
+ expect(getAffectedActors('--base', 'main', '--since', validated)).toStrictEqual({
119
+ functional: ['c'],
120
+ cosmetic: ['a'],
121
+ });
122
+ });
123
+
124
+ it('tests nothing after merging cosmetic changes from base', async () => {
125
+ const validated = await commit('feat(a): validated change', 'actors/a/main.ts');
126
+ await commitOnMain('docs: readme and changelog', 'actors/a/README.md', 'CHANGELOG.md');
127
+ git('merge', '-q', '--no-edit', 'main');
128
+
129
+ expect(getAffectedActors('--base', 'main', '--since', validated)).toStrictEqual({
130
+ functional: [],
131
+ cosmetic: ['a'],
132
+ });
133
+ });
134
+
135
+ it("tests the Actors where base's functional change meets the branch's after a merge", async () => {
136
+ const validated = await commit('feat(a): validated change', 'actors/a/main.ts');
137
+ await commitOnMain('feat: shared change', 'src/shared.ts');
138
+ git('merge', '-q', '--no-edit', 'main');
139
+
140
+ // Base changed A and C. A can interact with the branch's change, C only has base's, which base validated
141
+ expect(getAffectedActors('--base', 'main', '--since', validated)).toStrictEqual({
142
+ functional: ['a'],
143
+ cosmetic: [],
144
+ });
145
+ });
146
+
147
+ it('leaves out Actors that only base changed', async () => {
148
+ await commit('feat: shared change', 'src/shared.ts');
149
+ await commitOnMain('feat(b): base change', 'actors/b/main.ts');
150
+ git('merge', '-q', '--no-edit', 'main');
151
+
152
+ expect(getAffectedActors('--base', 'main')).toStrictEqual({ functional: ['a', 'c'], cosmetic: [] });
153
+ });
154
+
155
+ it('counts code changed in the merge commit itself', async () => {
156
+ await commit('docs(a): readme', 'actors/a/README.md');
157
+ await commitOnMain('feat(b): base change', 'actors/b/main.ts');
158
+ git('merge', '-q', '--no-commit', '--no-ff', 'main');
159
+ await writeFiles('changed while merging', ['actors/c/main.ts']);
160
+ git('add', '-A');
161
+ git('commit', '-q', '--no-edit');
162
+
163
+ expect(getAffectedActors('--base', 'main')).toStrictEqual({ functional: ['c'], cosmetic: ['a'] });
164
+ });
165
+
166
+ it('tests nothing after a rebase onto base changes to other Actors', async () => {
167
+ const validated = await commit('feat(a): validated change', 'actors/a/main.ts');
168
+ await commitOnMain('feat(b): base change', 'actors/b/main.ts');
169
+ git('rebase', '-q', 'main');
170
+
171
+ expect(getAffectedActors('--base', 'main', '--since', validated)).toStrictEqual({
172
+ functional: [],
173
+ cosmetic: [],
174
+ });
175
+ });
176
+
177
+ it('checks the whole branch on a rerun or when --since is not in the history', async () => {
178
+ await commit('feat(a): validated change', 'actors/a/main.ts');
179
+ const head = await commit('docs(a): readme', 'actors/a/README.md');
180
+
181
+ const wholeBranch = { functional: ['a'], cosmetic: [] };
182
+ expect(getAffectedActors('--base', 'main', '--since', head)).toStrictEqual(wholeBranch);
183
+ expect(getAffectedActors('--base', 'main', '--since', 'e'.repeat(40))).toStrictEqual(wholeBranch);
184
+ });
185
+
186
+ it('reports nothing for an empty range', async () => {
187
+ const nothing = { commits: [], changedFiles: [], functionallyChangedActors: [], cosmeticallyChangedActors: [] };
188
+ // The branch has no commits of its own yet
189
+ expect(runGetAffectedActors('--base', 'main')).toMatchObject(nothing);
190
+ // A release with nothing new since the last one
191
+ expect(runGetAffectedActors('--since', git('rev-parse', 'HEAD'))).toMatchObject(nothing);
192
+ });
193
+ });