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

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 (77) hide show
  1. package/.github/cli/apify-test-tools.mjs +140 -140
  2. package/.github/scripts/run-with-apify-tokens.mjs +1 -1
  3. package/.github/workflows/public_pr-build-test.yaml +25 -25
  4. package/.github/workflows/public_push-build-latest.yaml +20 -4
  5. package/CHANGELOG.md +2 -0
  6. package/README.md +119 -14
  7. package/bin/affected-actors.ts +157 -0
  8. package/bin/build.ts +55 -38
  9. package/bin/diff-changes.ts +25 -17
  10. package/bin/git.ts +44 -44
  11. package/bin/main.ts +132 -106
  12. package/bin/slack.ts +24 -0
  13. package/bin/stdin-input.ts +65 -0
  14. package/dist/bin/affected-actors.d.ts +45 -0
  15. package/dist/bin/affected-actors.d.ts.map +1 -0
  16. package/dist/bin/affected-actors.js +87 -0
  17. package/dist/bin/affected-actors.js.map +1 -0
  18. package/dist/bin/build.d.ts +12 -5
  19. package/dist/bin/build.d.ts.map +1 -1
  20. package/dist/bin/build.js +45 -23
  21. package/dist/bin/build.js.map +1 -1
  22. package/dist/bin/diff-changes.d.ts +8 -2
  23. package/dist/bin/diff-changes.d.ts.map +1 -1
  24. package/dist/bin/diff-changes.js +16 -16
  25. package/dist/bin/diff-changes.js.map +1 -1
  26. package/dist/bin/git.d.ts +19 -14
  27. package/dist/bin/git.d.ts.map +1 -1
  28. package/dist/bin/git.js +37 -35
  29. package/dist/bin/git.js.map +1 -1
  30. package/dist/bin/main.js +110 -86
  31. package/dist/bin/main.js.map +1 -1
  32. package/dist/bin/slack.d.ts +8 -0
  33. package/dist/bin/slack.d.ts.map +1 -1
  34. package/dist/bin/slack.js +18 -0
  35. package/dist/bin/slack.js.map +1 -1
  36. package/dist/bin/stdin-input.d.ts +17 -0
  37. package/dist/bin/stdin-input.d.ts.map +1 -0
  38. package/dist/bin/stdin-input.js +54 -0
  39. package/dist/bin/stdin-input.js.map +1 -0
  40. package/dist/test/bundle/cli-bundle.test.js +36 -6
  41. package/dist/test/bundle/cli-bundle.test.js.map +1 -1
  42. package/dist/test/e2e/cli-bundle.e2e.test.js +16 -9
  43. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -1
  44. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  45. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  46. package/dist/test/unit/bin/affected-actors.test.js +154 -0
  47. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  48. package/dist/test/unit/bin/build-cli.test.d.ts +2 -0
  49. package/dist/test/unit/bin/build-cli.test.d.ts.map +1 -0
  50. package/dist/test/unit/bin/build-cli.test.js +52 -0
  51. package/dist/test/unit/bin/build-cli.test.js.map +1 -0
  52. package/dist/test/unit/bin/build.test.js +61 -4
  53. package/dist/test/unit/bin/build.test.js.map +1 -1
  54. package/dist/test/unit/bin/diff-changes.test.js +78 -49
  55. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  56. package/dist/test/unit/bin/git.test.js +39 -54
  57. package/dist/test/unit/bin/git.test.js.map +1 -1
  58. package/dist/test/unit/bin/slack.test.js +39 -1
  59. package/dist/test/unit/bin/slack.test.js.map +1 -1
  60. package/dist/test/unit/bin/stdin-input.test.d.ts +2 -0
  61. package/dist/test/unit/bin/stdin-input.test.d.ts.map +1 -0
  62. package/dist/test/unit/bin/stdin-input.test.js +50 -0
  63. package/dist/test/unit/bin/stdin-input.test.js.map +1 -0
  64. package/dist/test/unit/should-built-and-test.test.js +23 -17
  65. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/package.json +1 -1
  68. package/test/bundle/cli-bundle.test.ts +59 -6
  69. package/test/e2e/cli-bundle.e2e.test.ts +26 -11
  70. package/test/unit/bin/affected-actors.test.ts +184 -0
  71. package/test/unit/bin/build-cli.test.ts +61 -0
  72. package/test/unit/bin/build.test.ts +71 -3
  73. package/test/unit/bin/diff-changes.test.ts +87 -49
  74. package/test/unit/bin/git.test.ts +44 -55
  75. package/test/unit/bin/slack.test.ts +44 -1
  76. package/test/unit/bin/stdin-input.test.ts +61 -0
  77. package/test/unit/should-built-and-test.test.ts +27 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apify-test-tools",
3
- "version": "0.10.2-beta.2",
3
+ "version": "0.10.2-beta.4",
4
4
  "type": "module",
5
5
  "description": "TBD",
6
6
  "repository": {
@@ -34,11 +34,18 @@ const writeRepoFile = async (file: string, content: string) => {
34
34
  await writeFile(path.join(repoDir, file), content);
35
35
  };
36
36
 
37
- const runCli = (...args: string[]) => {
38
- const result = spawnSync(process.execPath, [bundlePath, ...args], { cwd: repoDir, env: cliEnv, encoding: 'utf8' });
37
+ const runCliWithInput = (input: string, ...args: string[]) => {
38
+ const result = spawnSync(process.execPath, [bundlePath, ...args], {
39
+ cwd: repoDir,
40
+ env: cliEnv,
41
+ encoding: 'utf8',
42
+ input,
43
+ });
39
44
  return { status: result.status, stdout: result.stdout, stderr: result.stderr };
40
45
  };
41
46
 
47
+ const runCli = (...args: string[]) => runCliWithInput('', ...args);
48
+
42
49
  const gitRange = ['--source-branch', 'feature', '--target-branch', 'main'];
43
50
 
44
51
  describe('CLI bundle', () => {
@@ -91,7 +98,9 @@ describe('CLI bundle', () => {
91
98
  'get-affected-actors',
92
99
  'report-tests',
93
100
  'build',
101
+ 'build-for-test',
94
102
  'release',
103
+ 'notify-release',
95
104
  'build-from-local',
96
105
  'delete-old-builds',
97
106
  ]) {
@@ -130,19 +139,63 @@ describe('CLI bundle', () => {
130
139
  });
131
140
 
132
141
  it('get-affected-actors returns only the Actor whose files changed', () => {
133
- const { status, stdout, stderr } = runCli('get-affected-actors', ...gitRange);
142
+ // `feature` is checked out, and the range always ends at HEAD
143
+ const { status, stdout, stderr } = runCli('get-affected-actors', '--base', 'main');
134
144
  expect(status, stderr).toBe(0);
135
- expect(JSON.parse(stdout)).toMatchObject([{ actorFullName: 'tester/foo', folder: 'actors/foo' }]);
145
+ expect(JSON.parse(stdout)).toMatchObject({
146
+ version: 1,
147
+ head: headSha,
148
+ functionallyChangedActors: [{ actorFullName: 'tester/foo' }],
149
+ // The root README isn't in tester/bar's folder, so it doesn't count for it
150
+ cosmeticallyChangedActors: [],
151
+ });
136
152
  });
137
153
 
138
154
  it('build --dry-run with nothing to build needs no token', () => {
139
- const { status, stdout, stderr } = runCli('build', ...gitRange, '--actors', 'tester/bar', '--dry-run');
155
+ const { status, stdout, stderr } = runCliWithInput(
156
+ JSON.stringify({ actors: [] }),
157
+ 'build',
158
+ '--version-number',
159
+ '0.99',
160
+ '--dry-run',
161
+ );
140
162
  expect(status, stderr).toBe(0);
141
163
  expect(JSON.parse(stdout)).toEqual({ dryRun: true, builds: [] });
142
164
  });
143
165
 
144
166
  it('build --dry-run fails cleanly when the Actor token is missing', () => {
145
- const { status, stderr } = runCli('build', ...gitRange, '--dry-run');
167
+ const { status, stderr } = runCliWithInput(
168
+ JSON.stringify({ actors: [{ actorFullName: 'tester/foo' }] }),
169
+ 'build',
170
+ '--version-number',
171
+ '0.99',
172
+ '--dry-run',
173
+ );
174
+ expect(status).toBe(1);
175
+ expect(stderr).toContain('Env var FOO_TOKEN is not set (needed for actor "tester/foo")');
176
+ });
177
+
178
+ it('build fails on empty input instead of building nothing', () => {
179
+ const { status, stderr } = runCli('build', '--version-number', '0.99', '--dry-run');
180
+ expect(status).toBe(1);
181
+ expect(stderr).toContain('got nothing');
182
+ });
183
+
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
+ );
193
+ 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');
146
199
  expect(status).toBe(1);
147
200
  expect(stderr).toContain('Env var FOO_TOKEN is not set (needed for actor "tester/foo")');
148
201
  });
@@ -57,17 +57,20 @@ const writeRepoFile = async (file: string, content: string) => {
57
57
  // retries an unreachable API for minutes, so a hang is killed here and fails with a clear error.
58
58
  const PROCESS_TIMEOUT_MS = 8 * 60_000;
59
59
 
60
- const runCli = (...args: string[]) => {
60
+ const runCliWithInput = (input: string, ...args: string[]) => {
61
61
  const result = spawnSync(process.execPath, [bundlePath, ...args], {
62
62
  cwd: repoDir,
63
63
  env: cliEnv,
64
64
  encoding: 'utf8',
65
65
  timeout: PROCESS_TIMEOUT_MS,
66
+ input,
66
67
  });
67
68
  const error = result.error ? `\n${result.error.message}` : '';
68
69
  return { status: result.status, stdout: result.stdout, stderr: result.stderr + error };
69
70
  };
70
71
 
72
+ const runCli = (...args: string[]) => runCliWithInput('', ...args);
73
+
71
74
  interface ActorBuild {
72
75
  buildId: string;
73
76
  actorRawId: string;
@@ -117,11 +120,28 @@ describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 },
117
120
  if (repoDir) await rm(repoDir, { recursive: true, force: true });
118
121
  });
119
122
 
120
- it('build --dry-run plans a test build of the changed Actor only', () => {
121
- const { status, stdout, stderr } = runCli(
123
+ it('build --dry-run plans a test build of the Actors it is given', () => {
124
+ const { status, stdout, stderr } = runCliWithInput(
125
+ JSON.stringify({ actors: [{ actorFullName: ACTORS[0] }] }),
122
126
  'build',
123
- '--source-branch',
124
- 'feature',
127
+ '--version-number',
128
+ '0.99',
129
+ '--repo-url',
130
+ REPO_URL,
131
+ '--dry-run',
132
+ );
133
+ expect(status, stderr).toBe(0);
134
+ expect(JSON.parse(stdout)).toEqual({
135
+ dryRun: true,
136
+ builds: [
137
+ { actorFullName: ACTORS[0], versionNumber: '0.99', gitRepoUrl: `${REPO_URL}#feature:actors/dummy-1` },
138
+ ],
139
+ });
140
+ });
141
+
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',
125
145
  '--target-branch',
126
146
  'main',
127
147
  '--repo-url',
@@ -132,12 +152,7 @@ describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 },
132
152
  expect(JSON.parse(stdout)).toEqual({
133
153
  dryRun: true,
134
154
  builds: [
135
- {
136
- actorFullName: ACTORS[0],
137
- versionNumber: '0.99',
138
- gitRepoUrl: `${REPO_URL}#feature:actors/dummy-1`,
139
- useDockerCache: false,
140
- },
155
+ { actorFullName: ACTORS[0], versionNumber: '0.99', gitRepoUrl: `${REPO_URL}#feature:actors/dummy-1` },
141
156
  ],
142
157
  });
143
158
  });
@@ -0,0 +1,184 @@
1
+ import type { MockInstance } from 'vitest';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { getAffectedActors, toAffectedActorsDocument } from '../../../bin/affected-actors.js';
5
+ import * as DiffChanges from '../../../bin/diff-changes.js';
6
+ import * as Git from '../../../bin/git.js';
7
+ import type { ActorConfig, Commit } from '../../../bin/types.js';
8
+ import * as LoadConfig from '../../../bin/utils/config/load-config.js';
9
+
10
+ // Everything that reads the configs is mocked, so only the identity and name of an Actor matter here
11
+ const actorA = { actorFullName: 'owner/a' } as ActorConfig;
12
+ const actorB = { actorFullName: 'owner/b' } as ActorConfig;
13
+
14
+ const headSha = 'f'.repeat(40);
15
+ const sinceSha = '5'.repeat(40);
16
+ const commit = (sha: string): Commit => ({ sha, author: 'Dev<dev@example.com>', date: 'Date', message: 'msg' });
17
+ const commits = [commit('1'.repeat(40)), commit('2'.repeat(40))];
18
+ const changedFiles = ['src/main.ts', 'CHANGELOG.md'];
19
+
20
+ const selection = { actors: [], ignore: [] };
21
+
22
+ describe('getAffectedActors', () => {
23
+ let changedActorsSpy: MockInstance;
24
+
25
+ beforeEach(() => {
26
+ vi.spyOn(LoadConfig, 'readConfigFile').mockResolvedValue([actorA, actorB]);
27
+ changedActorsSpy = vi
28
+ .spyOn(DiffChanges, 'getChangedActors')
29
+ .mockResolvedValue({ functional: [actorA], cosmetic: [actorB] });
30
+ vi.spyOn(Git, 'resolveCommitSha').mockResolvedValue(headSha);
31
+ vi.spyOn(Git, 'getChangedFilesBetween').mockResolvedValue(changedFiles);
32
+ vi.spyOn(Git, 'getChangelogAdditions').mockResolvedValue('- Added foo');
33
+ });
34
+
35
+ it('requires --base or --since', async () => {
36
+ const options = { ...selection };
37
+ await expect(getAffectedActors(options)).rejects.toThrow('Pass --base, --since, or both');
38
+ // An empty --since (e.g. from an empty cache file) counts as not passed
39
+ await expect(getAffectedActors({ ...options, since: '' })).rejects.toThrow('Pass --base, --since, or both');
40
+ });
41
+
42
+ describe('with --base', () => {
43
+ let getCommitsSpy: MockInstance;
44
+
45
+ beforeEach(() => {
46
+ vi.spyOn(Git, 'hasMergeFromTarget').mockResolvedValue(false);
47
+ getCommitsSpy = vi.spyOn(Git, 'getCommits').mockResolvedValue(commits);
48
+ });
49
+
50
+ it('inspects the branch commits after --since', async () => {
51
+ const result = await getAffectedActors({
52
+ base: 'origin/master',
53
+ since: sinceSha,
54
+ ...selection,
55
+ });
56
+
57
+ expect(result).toStrictEqual({
58
+ headSha,
59
+ commits,
60
+ changedFiles,
61
+ changelog: '- Added foo',
62
+ functionallyChangedActors: [actorA],
63
+ cosmeticallyChangedActors: [actorB],
64
+ });
65
+ expect(getCommitsSpy).toHaveBeenCalledWith({
66
+ sourceBranch: 'HEAD',
67
+ targetBranch: 'origin/master',
68
+ baseCommit: sinceSha,
69
+ });
70
+ // From the parent of the oldest commit to the newest one, for the files and the changelog alike
71
+ expect(Git.getChangedFilesBetween).toHaveBeenCalledWith(`${commits[0].sha}~`, commits[1].sha);
72
+ expect(Git.getChangelogAdditions).toHaveBeenCalledWith(`${commits[0].sha}~`, commits[1].sha, changedFiles);
73
+ expect(changedActorsSpy).toHaveBeenCalledWith({
74
+ filepathsChanged: changedFiles,
75
+ actorConfigs: [actorA, actorB],
76
+ commits,
77
+ });
78
+ });
79
+
80
+ describe('when the branch merged --base in', () => {
81
+ beforeEach(() => {
82
+ vi.spyOn(Git, 'hasMergeFromTarget').mockResolvedValue(true);
83
+ vi.spyOn(Git, 'getBranchOnlyChangedFiles').mockResolvedValue(['own-file']);
84
+ });
85
+
86
+ const getWithOwnChanges = async (own: DiffChanges.ChangedActors) => {
87
+ changedActorsSpy.mockImplementation(async ({ filepathsChanged }) =>
88
+ filepathsChanged[0] === 'own-file' ? own : { functional: [actorA], cosmetic: [actorB] },
89
+ );
90
+ return getAffectedActors({ base: 'master', since: sinceSha, ...selection });
91
+ };
92
+
93
+ it("classifies the branch's own files against the whole branch, ignoring --since", async () => {
94
+ await getWithOwnChanges({ functional: [actorA], cosmetic: [] });
95
+ expect(changedActorsSpy).toHaveBeenCalledWith({
96
+ filepathsChanged: ['own-file'],
97
+ actorConfigs: [actorA, actorB],
98
+ commits,
99
+ });
100
+ expect(getCommitsSpy).toHaveBeenCalledWith({ sourceBranch: 'HEAD', targetBranch: 'master' });
101
+ });
102
+
103
+ it('reports everything when the branch has functional changes of its own', async () => {
104
+ const result = await getWithOwnChanges({ functional: [actorA], cosmetic: [] });
105
+ expect(result.functionallyChangedActors).toStrictEqual([actorA]);
106
+ expect(result.cosmeticallyChangedActors).toStrictEqual([actorB]);
107
+ });
108
+
109
+ it('reports only cosmetic Actors when its own changes are only cosmetic', async () => {
110
+ const result = await getWithOwnChanges({ functional: [], cosmetic: [actorB] });
111
+ expect(result.functionallyChangedActors).toStrictEqual([]);
112
+ expect(result.cosmeticallyChangedActors).toStrictEqual([actorB]);
113
+ });
114
+
115
+ it('reports nothing when it has no changes of its own', async () => {
116
+ const result = await getWithOwnChanges({ functional: [], cosmetic: [] });
117
+ expect(result.functionallyChangedActors).toStrictEqual([]);
118
+ expect(result.cosmeticallyChangedActors).toStrictEqual([]);
119
+ // The range is still reported
120
+ expect(result.commits).toStrictEqual(commits);
121
+ });
122
+ });
123
+ });
124
+
125
+ describe('with --since only', () => {
126
+ let resolveBaseSpy: MockInstance;
127
+ let commitsBetweenSpy: MockInstance;
128
+
129
+ beforeEach(() => {
130
+ resolveBaseSpy = vi.spyOn(Git, 'resolveReleaseBaseCommit').mockResolvedValue(sinceSha);
131
+ commitsBetweenSpy = vi.spyOn(Git, 'getCommitsBetween').mockResolvedValue(commits);
132
+ });
133
+
134
+ it('inspects everything after --since up to --head', async () => {
135
+ const result = await getAffectedActors({ since: 'HEAD~2', ...selection });
136
+
137
+ expect(result).toStrictEqual({
138
+ headSha,
139
+ commits,
140
+ changedFiles,
141
+ changelog: '- Added foo',
142
+ functionallyChangedActors: [actorA],
143
+ cosmeticallyChangedActors: [actorB],
144
+ });
145
+ expect(resolveBaseSpy).toHaveBeenCalledWith('HEAD~2');
146
+ expect(commitsBetweenSpy).toHaveBeenCalledWith(sinceSha, headSha);
147
+ expect(Git.getChangedFilesBetween).toHaveBeenCalledWith(sinceSha, headSha);
148
+ expect(Git.getChangelogAdditions).toHaveBeenCalledWith(sinceSha, headSha, changedFiles);
149
+ });
150
+
151
+ it('has no commits when head is the --since commit', async () => {
152
+ resolveBaseSpy.mockResolvedValue(headSha);
153
+
154
+ const result = await getAffectedActors({ since: headSha, ...selection });
155
+
156
+ expect(result.commits).toStrictEqual([]);
157
+ // Empty `git log x..x` output doesn't parse as a commit, so it's never run
158
+ expect(commitsBetweenSpy).not.toHaveBeenCalled();
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('toAffectedActorsDocument', () => {
164
+ it('keeps only the Actor names, the config file stays the source of truth for the rest', () => {
165
+ expect(
166
+ toAffectedActorsDocument({
167
+ headSha,
168
+ commits,
169
+ changedFiles: ['src/main.ts'],
170
+ changelog: null,
171
+ functionallyChangedActors: [actorA],
172
+ cosmeticallyChangedActors: [actorB],
173
+ }),
174
+ ).toStrictEqual({
175
+ version: 1,
176
+ head: headSha,
177
+ commits,
178
+ changedFiles: ['src/main.ts'],
179
+ changelog: null,
180
+ functionallyChangedActors: [{ actorFullName: 'owner/a' }],
181
+ cosmeticallyChangedActors: [{ actorFullName: 'owner/b' }],
182
+ });
183
+ });
184
+ });
@@ -0,0 +1,61 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { chmod, mkdtemp, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
8
+
9
+ const projectRoot = fileURLToPath(new URL('../../../', import.meta.url));
10
+ const compilerPath = fileURLToPath(new URL('../../../node_modules/typescript/bin/tsc', import.meta.url));
11
+ let cliPath: string;
12
+ let buildDir: string | undefined;
13
+
14
+ const run = (command: string[], input: string, env: NodeJS.ProcessEnv = process.env) =>
15
+ spawnSync(cliPath, command, { encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], env });
16
+ const runBuild = (input: string, args: string[] = ['--version-number', '0.99']) => run(['build', ...args], input);
17
+
18
+ describe('build and notify-release CLI', () => {
19
+ beforeAll(async () => {
20
+ buildDir = await mkdtemp(path.join(projectRoot, '.cli-test-'));
21
+ execFileSync(process.execPath, [compilerPath, '--outDir', buildDir], { cwd: projectRoot });
22
+ cliPath = path.join(buildDir, 'bin/main.js');
23
+ await chmod(cliPath, 0o755);
24
+ });
25
+
26
+ afterAll(async () => {
27
+ if (buildDir) await rm(buildDir, { recursive: true, force: true });
28
+ });
29
+
30
+ it('builds nothing for an empty list and prints an empty build list', () => {
31
+ const result = runBuild('{"actors":[]}');
32
+ expect(result.status, result.stderr).toBe(0);
33
+ expect(JSON.parse(result.stdout)).toStrictEqual([]);
34
+ });
35
+
36
+ it('fails on empty stdin instead of building nothing', () => {
37
+ const result = runBuild('');
38
+ expect(result.status).toBe(1);
39
+ expect(result.stderr).toContain('Expected the Actors to build as JSON on stdin, got nothing');
40
+ expect(result.stdout).toBe('');
41
+ });
42
+
43
+ it('rejects a malformed --version-number', () => {
44
+ const result = runBuild('{"actors":[]}', ['--version-number', 'latest']);
45
+ expect(result.status).toBe(1);
46
+ expect(result.stderr).toContain('--version-number must look like 0.1 or be "default"');
47
+ });
48
+
49
+ it('requires --version-number', () => {
50
+ const result = runBuild('{"actors":[]}', []);
51
+ expect(result.status).toBe(1);
52
+ expect(result.stderr).toContain('Missing required argument: version-number');
53
+ });
54
+
55
+ it('notify-release announces nothing when there are no new commits', () => {
56
+ const document = { commits: [], changedFiles: [], changelog: null, functionallyChangedActors: [] };
57
+ const result = run(['notify-release'], JSON.stringify(document));
58
+ expect(result.status, result.stderr).toBe(0);
59
+ expect(result.stderr).toContain('No new commits, so there is no release to announce');
60
+ });
61
+ });
@@ -7,11 +7,15 @@ import { ActorSourceType, ApifyClient } from 'apify-client';
7
7
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
 
9
9
  import {
10
+ ApifyBuilder,
10
11
  assertRepoUrlMatchesDefaultVersion,
12
+ buildActors,
11
13
  deleteOldBuilds,
12
14
  resolveDefaultVersion,
13
15
  runBuilds,
14
16
  } from '../../../bin/build.js';
17
+ import * as Git from '../../../bin/git.js';
18
+ import { logger } from '../../../bin/logger.js';
15
19
  import type { ActorConfig } from '../../../bin/types.js';
16
20
  import { ExistingDir, ExistingFile, RelativeDir } from '../../../bin/utils/path/repo-relative.js';
17
21
 
@@ -84,6 +88,71 @@ describe('assertRepoUrlMatchesDefaultVersion', () => {
84
88
  });
85
89
  });
86
90
 
91
+ describe('runBuilds version number', () => {
92
+ // Only the name and folder of the Actor matter here, the builder is stubbed
93
+ const actorConfig = { actorFullName: ACTOR, folder: { isRoot: () => true } } as unknown as ActorConfig;
94
+ // Restored after each test, so the stub never reaches the tests that run the real builder
95
+ afterEach(() => {
96
+ vi.mocked(ApifyBuilder.fromActorConfig).mockRestore();
97
+ });
98
+
99
+ const dryRunBuild = async (versionNumber: string, actorInfo = makeActorInfo()) => {
100
+ vi.spyOn(ApifyBuilder, 'fromActorConfig').mockReturnValue({
101
+ getActorInfo: async () => actorInfo,
102
+ } as unknown as ApifyBuilder);
103
+ const result = await runBuilds({
104
+ actorConfigs: [actorConfig],
105
+ versionNumber,
106
+ repoUrl: REPO_URL,
107
+ shouldVerifyRepoUrl: false,
108
+ branch: 'master',
109
+ dryRun: true,
110
+ });
111
+ if (Array.isArray(result)) throw new Error('A dry run must return a plan, not builds');
112
+ return result.builds[0].versionNumber;
113
+ };
114
+
115
+ it('builds the version it is given', async () => {
116
+ await expect(dryRunBuild('0.99')).resolves.toBe('0.99');
117
+ });
118
+
119
+ it("resolves default to the version the Actor's default build tag points to", async () => {
120
+ await expect(dryRunBuild('default')).resolves.toBe('0.1');
121
+ });
122
+
123
+ it('warns when the default version does not give its builds the default build tag', async () => {
124
+ const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
125
+ await dryRunBuild('default');
126
+ // The fixture's version 0.1 has no build tag of its own
127
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('users keep running the previous build'));
128
+ });
129
+
130
+ it('does not warn when the default version gives its builds the default build tag', async () => {
131
+ const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
132
+ const versions = [{ versionNumber: '0.1', buildTag: 'latest', sourceType: ActorSourceType.GitRepo }];
133
+ await dryRunBuild('default', makeActorInfo({ versions } as Partial<ApifyClientTypes.Actor>));
134
+ expect(warnSpy).not.toHaveBeenCalled();
135
+ });
136
+ });
137
+
138
+ describe('buildActors', () => {
139
+ it('builds nothing and needs no branch or repository when there are no Actors', async () => {
140
+ const branchSpy = vi.spyOn(Git, 'getCurrentBranch');
141
+ const repoSpy = vi.spyOn(Git, 'resolveRepoUrl');
142
+
143
+ await expect(buildActors({ actorConfigs: [], versionNumber: '0.99', dryRun: false })).resolves.toStrictEqual(
144
+ [],
145
+ );
146
+ // A dry run keeps the dry-run shape even with nothing to build
147
+ await expect(buildActors({ actorConfigs: [], versionNumber: '0.99', dryRun: true })).resolves.toStrictEqual({
148
+ dryRun: true,
149
+ builds: [],
150
+ });
151
+ expect(branchSpy).not.toHaveBeenCalled();
152
+ expect(repoSpy).not.toHaveBeenCalled();
153
+ });
154
+ });
155
+
87
156
  describe('dry run', () => {
88
157
  const TOKEN_ENV_VAR = 'APIFY_TOKEN_OWNER';
89
158
  const originalCwd = process.cwd();
@@ -150,7 +219,7 @@ describe('dry run', () => {
150
219
  shouldVerifyRepoUrl: true,
151
220
  branch: 'feature',
152
221
  dryRun: true,
153
- useDockerCache: false,
222
+ versionNumber: '0.99',
154
223
  });
155
224
 
156
225
  it('should return a build plan without creating versions or builds', async () => {
@@ -161,7 +230,6 @@ describe('dry run', () => {
161
230
  actorFullName: ACTOR,
162
231
  versionNumber: '0.99',
163
232
  gitRepoUrl: `${REPO_URL}#feature:actors/a`,
164
- useDockerCache: false,
165
233
  },
166
234
  ],
167
235
  });
@@ -182,7 +250,7 @@ describe('dry run', () => {
182
250
  shouldVerifyRepoUrl: true,
183
251
  branch: 'feature',
184
252
  dryRun: true,
185
- useDockerCache: false,
253
+ versionNumber: '0.99',
186
254
  }),
187
255
  ).rejects.toThrow('Repository mismatch');
188
256
  });