apify-test-tools 0.10.1-beta.2 → 0.10.1-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 (147) hide show
  1. package/.github/actions/checkout-restore-dependencies/action.yaml +4 -3
  2. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +7 -2
  3. package/.github/workflows/public_platform-tests.yaml +0 -1
  4. package/.github/workflows-package-version +1 -1
  5. package/CHANGELOG.md +6 -0
  6. package/CONTRIBUTING.md +13 -3
  7. package/README.md +2 -2
  8. package/bin/build-from-local.ts +8 -7
  9. package/bin/build.ts +22 -21
  10. package/bin/diff-changes.ts +11 -10
  11. package/bin/diff-json-schema.ts +14 -12
  12. package/bin/git.ts +89 -47
  13. package/bin/logger.ts +23 -0
  14. package/bin/main.ts +44 -38
  15. package/bin/output.ts +6 -0
  16. package/bin/slack.ts +8 -7
  17. package/bin/test-report.ts +22 -21
  18. package/bin/types.ts +0 -1
  19. package/bin/utils/config/actor-json.ts +84 -0
  20. package/bin/utils/config/load-config.ts +46 -46
  21. package/bin/utils/config/parser.ts +33 -28
  22. package/bin/utils/config/structures/base.ts +7 -0
  23. package/bin/utils/json-file.ts +11 -14
  24. package/bin/utils/path/repo-relative.ts +156 -0
  25. package/bin/utils/path/schema.ts +18 -0
  26. package/bin/utils.ts +63 -44
  27. package/dist/bin/build-from-local.d.ts +1 -1
  28. package/dist/bin/build-from-local.d.ts.map +1 -1
  29. package/dist/bin/build-from-local.js +8 -7
  30. package/dist/bin/build-from-local.js.map +1 -1
  31. package/dist/bin/build.d.ts.map +1 -1
  32. package/dist/bin/build.js +22 -21
  33. package/dist/bin/build.js.map +1 -1
  34. package/dist/bin/diff-changes.d.ts +1 -1
  35. package/dist/bin/diff-changes.d.ts.map +1 -1
  36. package/dist/bin/diff-changes.js +9 -8
  37. package/dist/bin/diff-changes.js.map +1 -1
  38. package/dist/bin/diff-json-schema.d.ts +1 -1
  39. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  40. package/dist/bin/diff-json-schema.js +13 -12
  41. package/dist/bin/diff-json-schema.js.map +1 -1
  42. package/dist/bin/git.d.ts +10 -10
  43. package/dist/bin/git.d.ts.map +1 -1
  44. package/dist/bin/git.js +78 -37
  45. package/dist/bin/git.js.map +1 -1
  46. package/dist/bin/logger.d.ts +11 -0
  47. package/dist/bin/logger.d.ts.map +1 -0
  48. package/dist/bin/logger.js +20 -0
  49. package/dist/bin/logger.js.map +1 -0
  50. package/dist/bin/main.d.ts +1 -1
  51. package/dist/bin/main.js +44 -38
  52. package/dist/bin/main.js.map +1 -1
  53. package/dist/bin/output.d.ts +3 -0
  54. package/dist/bin/output.d.ts.map +1 -0
  55. package/dist/bin/output.js +6 -0
  56. package/dist/bin/output.js.map +1 -0
  57. package/dist/bin/slack.d.ts.map +1 -1
  58. package/dist/bin/slack.js +8 -7
  59. package/dist/bin/slack.js.map +1 -1
  60. package/dist/bin/test-report.d.ts.map +1 -1
  61. package/dist/bin/test-report.js +22 -21
  62. package/dist/bin/test-report.js.map +1 -1
  63. package/dist/bin/types.d.ts +0 -1
  64. package/dist/bin/types.d.ts.map +1 -1
  65. package/dist/bin/utils/config/actor-json.d.ts +14 -0
  66. package/dist/bin/utils/config/actor-json.d.ts.map +1 -0
  67. package/dist/bin/utils/config/actor-json.js +60 -0
  68. package/dist/bin/utils/config/actor-json.js.map +1 -0
  69. package/dist/bin/utils/config/load-config.d.ts +12 -2
  70. package/dist/bin/utils/config/load-config.d.ts.map +1 -1
  71. package/dist/bin/utils/config/load-config.js +32 -31
  72. package/dist/bin/utils/config/load-config.js.map +1 -1
  73. package/dist/bin/utils/config/parser.d.ts +4 -12
  74. package/dist/bin/utils/config/parser.d.ts.map +1 -1
  75. package/dist/bin/utils/config/parser.js +24 -23
  76. package/dist/bin/utils/config/parser.js.map +1 -1
  77. package/dist/bin/utils/config/structures/base.d.ts +5 -0
  78. package/dist/bin/utils/config/structures/base.d.ts.map +1 -1
  79. package/dist/bin/utils/config/structures/base.js.map +1 -1
  80. package/dist/bin/utils/json-file.d.ts +3 -2
  81. package/dist/bin/utils/json-file.d.ts.map +1 -1
  82. package/dist/bin/utils/json-file.js +8 -13
  83. package/dist/bin/utils/json-file.js.map +1 -1
  84. package/dist/bin/utils/path/repo-relative.d.ts +35 -0
  85. package/dist/bin/utils/path/repo-relative.d.ts.map +1 -0
  86. package/dist/bin/utils/path/repo-relative.js +137 -0
  87. package/dist/bin/utils/path/repo-relative.js.map +1 -0
  88. package/dist/bin/utils/path/schema.d.ts +7 -0
  89. package/dist/bin/utils/path/schema.d.ts.map +1 -0
  90. package/dist/bin/utils/path/schema.js +16 -0
  91. package/dist/bin/utils/path/schema.js.map +1 -0
  92. package/dist/bin/utils.d.ts +14 -6
  93. package/dist/bin/utils.d.ts.map +1 -1
  94. package/dist/bin/utils.js +52 -36
  95. package/dist/bin/utils.js.map +1 -1
  96. package/dist/test/unit/bin/build-from-local.test.js +4 -40
  97. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  98. package/dist/test/unit/bin/diff-changes.test.js +97 -96
  99. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  100. package/dist/test/unit/bin/diff.test.js +52 -48
  101. package/dist/test/unit/bin/diff.test.js.map +1 -1
  102. package/dist/test/unit/bin/git-command.test.d.ts +2 -0
  103. package/dist/test/unit/bin/git-command.test.d.ts.map +1 -0
  104. package/dist/test/unit/bin/git-command.test.js +54 -0
  105. package/dist/test/unit/bin/git-command.test.js.map +1 -0
  106. package/dist/test/unit/bin/git.test.js +211 -94
  107. package/dist/test/unit/bin/git.test.js.map +1 -1
  108. package/dist/test/unit/bin/logger.test.d.ts +2 -0
  109. package/dist/test/unit/bin/logger.test.d.ts.map +1 -0
  110. package/dist/test/unit/bin/logger.test.js +44 -0
  111. package/dist/test/unit/bin/logger.test.js.map +1 -0
  112. package/dist/test/unit/bin/logging.test.d.ts +2 -0
  113. package/dist/test/unit/bin/logging.test.d.ts.map +1 -0
  114. package/dist/test/unit/bin/logging.test.js +43 -0
  115. package/dist/test/unit/bin/logging.test.js.map +1 -0
  116. package/dist/test/unit/bin/utils/config/load-config.test.js +47 -25
  117. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  118. package/dist/test/unit/bin/utils/config/parser.test.js +64 -19
  119. package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
  120. package/dist/test/unit/bin/utils/files.test.js +16 -27
  121. package/dist/test/unit/bin/utils/files.test.js.map +1 -1
  122. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts +2 -0
  123. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts.map +1 -0
  124. package/dist/test/unit/bin/utils/path/repo-relative.test.js +196 -0
  125. package/dist/test/unit/bin/utils/path/repo-relative.test.js.map +1 -0
  126. package/dist/test/unit/bin/utils/path/schema.test.d.ts +2 -0
  127. package/dist/test/unit/bin/utils/path/schema.test.d.ts.map +1 -0
  128. package/dist/test/unit/bin/utils/path/schema.test.js +52 -0
  129. package/dist/test/unit/bin/utils/path/schema.test.js.map +1 -0
  130. package/dist/test/unit/should-built-and-test.test.js +40 -38
  131. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  132. package/dist/tsconfig.tsbuildinfo +1 -1
  133. package/eslint.config.mjs +9 -1
  134. package/package.json +5 -5
  135. package/test/unit/bin/build-from-local.test.ts +4 -54
  136. package/test/unit/bin/diff-changes.test.ts +97 -96
  137. package/test/unit/bin/diff.test.ts +53 -48
  138. package/test/unit/bin/git-command.test.ts +71 -0
  139. package/test/unit/bin/git.test.ts +246 -91
  140. package/test/unit/bin/logger.test.ts +55 -0
  141. package/test/unit/bin/logging.test.ts +52 -0
  142. package/test/unit/bin/utils/config/load-config.test.ts +63 -26
  143. package/test/unit/bin/utils/config/parser.test.ts +81 -27
  144. package/test/unit/bin/utils/files.test.ts +19 -33
  145. package/test/unit/bin/utils/path/repo-relative.test.ts +250 -0
  146. package/test/unit/bin/utils/path/schema.test.ts +65 -0
  147. package/test/unit/should-built-and-test.test.ts +40 -38
@@ -12,6 +12,7 @@ import {
12
12
  normalizeRepoUrl,
13
13
  parseBaseCommit,
14
14
  resolveReleaseBaseCommit,
15
+ resolveRepoUrl,
15
16
  } from '../../../bin/git.js';
16
17
  import * as Utils from '../../../bin/utils.js';
17
18
 
@@ -30,14 +31,12 @@ describe('getCommits', () => {
30
31
  let gitCommandSpy: MockInstance;
31
32
 
32
33
  beforeEach(() => {
33
- gitCommandSpy = vi
34
- .spyOn(Utils, 'spawnCommandInGhWorkspace')
35
- .mockReturnValue(`${commit3}\n${commit2}\n${commit1}`);
34
+ gitCommandSpy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue(`${commit3}\n${commit2}\n${commit1}`);
36
35
  });
37
36
 
38
- it('should return commits between source and target branches', () => {
37
+ it('should return commits between source and target branches', async () => {
39
38
  // Act
40
- const commits = getCommits({ sourceBranch, targetBranch });
39
+ const commits = await getCommits({ sourceBranch, targetBranch });
41
40
 
42
41
  // Assert
43
42
  expect(commits).toStrictEqual([
@@ -48,13 +47,14 @@ describe('getCommits', () => {
48
47
 
49
48
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
50
49
  expect(gitCommandSpy).toHaveBeenCalledWith(
51
- `git log --pretty=format:'%H»¦«%aN<%aE>»¦«%aD»¦«%s' main..feature-branch`,
50
+ ['log', '--pretty=format:%H»¦«%aN<%aE>»¦«%aD»¦«%s', 'main..feature-branch'],
51
+ { operation: 'list commits reachable from the source ref but absent from the target ref' },
52
52
  );
53
53
  });
54
54
 
55
- it('should return commits after the base commit if provided', () => {
55
+ it('should return commits after the base commit if provided', async () => {
56
56
  // Act
57
- const commits = getCommits({ sourceBranch, targetBranch, baseCommit: sha1 });
57
+ const commits = await getCommits({ sourceBranch, targetBranch, baseCommit: sha1 });
58
58
 
59
59
  // Assert
60
60
  expect(commits).toStrictEqual([
@@ -64,13 +64,14 @@ describe('getCommits', () => {
64
64
 
65
65
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
66
66
  expect(gitCommandSpy).toHaveBeenCalledWith(
67
- `git log --pretty=format:'%H»¦«%aN<%aE>»¦«%aD»¦«%s' main..feature-branch`,
67
+ ['log', '--pretty=format:%H»¦«%aN<%aE>»¦«%aD»¦«%s', 'main..feature-branch'],
68
+ { operation: 'list commits reachable from the source ref but absent from the target ref' },
68
69
  );
69
70
  });
70
71
 
71
- it('should ignore the base commit and return all commits when it is the branch HEAD (rerun or force push)', () => {
72
+ it('should ignore the base commit and return all commits when it is the branch HEAD (rerun or force push)', async () => {
72
73
  // Act
73
- const commits = getCommits({ sourceBranch, targetBranch, baseCommit: sha3 });
74
+ const commits = await getCommits({ sourceBranch, targetBranch, baseCommit: sha3 });
74
75
 
75
76
  // Assert
76
77
  expect(commits).toStrictEqual([
@@ -80,9 +81,9 @@ describe('getCommits', () => {
80
81
  ]);
81
82
  });
82
83
 
83
- it('should return all commits if base commit is not found', () => {
84
+ it('should return all commits if base commit is not found', async () => {
84
85
  // Act
85
- const commits = getCommits({ sourceBranch, targetBranch, baseCommit: 'a'.repeat(40) });
86
+ const commits = await getCommits({ sourceBranch, targetBranch, baseCommit: 'a'.repeat(40) });
86
87
 
87
88
  // Assert
88
89
  expect(commits).toStrictEqual([
@@ -93,7 +94,8 @@ describe('getCommits', () => {
93
94
 
94
95
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
95
96
  expect(gitCommandSpy).toHaveBeenCalledWith(
96
- `git log --pretty=format:'%H»¦«%aN<%aE>»¦«%aD»¦«%s' main..feature-branch`,
97
+ ['log', '--pretty=format:%H»¦«%aN<%aE>»¦«%aD»¦«%s', 'main..feature-branch'],
98
+ { operation: 'list commits reachable from the source ref but absent from the target ref' },
97
99
  );
98
100
  });
99
101
  });
@@ -102,10 +104,10 @@ describe('getChangedFiles', () => {
102
104
  let gitCommandSpy: MockInstance;
103
105
 
104
106
  beforeEach(() => {
105
- gitCommandSpy = vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockReturnValue('file1.txt\nfolder/file2.txt');
107
+ gitCommandSpy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue('file1.txt\nfolder/file2.txt');
106
108
  });
107
109
 
108
- it('should return changed files between commits', () => {
110
+ it('should return changed files between commits', async () => {
109
111
  // Arrange
110
112
  const firstSha = '1'.repeat(40);
111
113
  const lastSha = '3'.repeat(40);
@@ -115,46 +117,50 @@ describe('getChangedFiles', () => {
115
117
  ];
116
118
 
117
119
  // Act
118
- const changedFiles = getChangedFiles(commits);
120
+ const changedFiles = await getChangedFiles(commits);
119
121
 
120
122
  // Assert
121
123
  expect(changedFiles).toStrictEqual(['file1.txt', 'folder/file2.txt']);
122
124
 
123
125
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
124
- expect(gitCommandSpy).toHaveBeenCalledWith(`git diff --name-only ${firstSha}~..${lastSha}`);
126
+ expect(gitCommandSpy).toHaveBeenCalledWith(['diff', '--name-only', `${firstSha}~..${lastSha}`], {
127
+ operation: 'get the list of changed files between commits',
128
+ });
125
129
  });
126
130
 
127
- it('should throw without running git when the commit list is empty', () => {
131
+ it('should throw without running git when the commit list is empty', async () => {
128
132
  // Act & Assert
129
- expect(() => getChangedFiles([])).toThrow('Cannot get changed files: the commit list is empty');
133
+ await expect(getChangedFiles([])).rejects.toThrow('Cannot get changed files: the commit list is empty');
130
134
  expect(gitCommandSpy).not.toHaveBeenCalled();
131
135
  });
132
136
 
133
- it('should handle only one commit', () => {
137
+ it('should handle only one commit', async () => {
134
138
  // Arrange
135
139
  const onlySha = '1'.repeat(40);
136
140
  const commits = [{ sha: onlySha, author: '', date: '', message: '' }];
137
141
 
138
142
  // Act
139
- const changedFiles = getChangedFiles(commits);
143
+ const changedFiles = await getChangedFiles(commits);
140
144
 
141
145
  // Assert
142
146
  expect(changedFiles).toStrictEqual(['file1.txt', 'folder/file2.txt']);
143
147
 
144
148
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
145
- expect(gitCommandSpy).toHaveBeenCalledWith(`git diff --name-only ${onlySha}~..${onlySha}`);
149
+ expect(gitCommandSpy).toHaveBeenCalledWith(['diff', '--name-only', `${onlySha}~..${onlySha}`], {
150
+ operation: 'get the list of changed files between commits',
151
+ });
146
152
  });
147
153
 
148
- it('should return an empty list when the net diff is empty (e.g. a commit and its revert)', () => {
154
+ it('should return an empty list when the net diff is empty (e.g. a commit and its revert)', async () => {
149
155
  // Arrange
150
- gitCommandSpy.mockReturnValue('');
156
+ gitCommandSpy.mockResolvedValue('');
151
157
  const commits = [
152
158
  { sha: '1'.repeat(40), author: '', date: '', message: '' },
153
159
  { sha: '2'.repeat(40), author: '', date: '', message: '' },
154
160
  ];
155
161
 
156
162
  // Act
157
- const changedFiles = getChangedFiles(commits);
163
+ const changedFiles = await getChangedFiles(commits);
158
164
 
159
165
  // Assert
160
166
  expect(changedFiles).toStrictEqual([]);
@@ -167,49 +173,86 @@ describe('hasMergeFromTarget', () => {
167
173
  const mergeSha = 'f'.repeat(40);
168
174
  const branchParentSha = 'b'.repeat(40);
169
175
  const targetParentSha = 't'.repeat(40);
176
+ const differentMergeBase = '0'.repeat(40);
170
177
 
171
178
  let gitCommandSpy: MockInstance;
172
179
 
173
180
  beforeEach(() => {
174
- gitCommandSpy = vi.spyOn(Utils, 'spawnCommandInGhWorkspace');
181
+ gitCommandSpy = vi.spyOn(Utils, 'runGitCommand');
175
182
  });
176
183
 
177
- it('should return false when there are no merge commits on the branch', () => {
178
- gitCommandSpy.mockImplementation((cmd: string) => {
179
- if (cmd.includes('--merges')) return '';
184
+ it('should return false when there are no merge commits on the branch', async () => {
185
+ gitCommandSpy.mockImplementation(async (args: string[]) => {
186
+ if (args.includes('--merges')) return '';
180
187
  return '';
181
188
  });
182
189
 
183
- expect(hasMergeFromTarget(sourceBranch, targetBranch)).toBe(false);
190
+ await expect(hasMergeFromTarget(sourceBranch, targetBranch)).resolves.toBe(false);
184
191
  expect(gitCommandSpy).toHaveBeenCalledWith(
185
- `git log --merges --pretty=format:%H ${targetBranch}..${sourceBranch}`,
192
+ ['log', '--merges', '--pretty=format:%H', `${targetBranch}..${sourceBranch}`],
193
+ { operation: 'find merge commits on the source branch that are absent from the target branch' },
186
194
  );
187
195
  });
188
196
 
189
- it('should return true when a merge commit has a parent reachable from targetBranch', () => {
190
- gitCommandSpy.mockImplementation((cmd: string) => {
191
- if (cmd.includes('--merges')) return mergeSha;
192
- if (cmd.includes('--pretty=format:%P')) return `${branchParentSha} ${targetParentSha}`;
193
- if (cmd.startsWith(`git merge-base ${branchParentSha}`)) return branchParentSha; // not ancestor
194
- if (cmd.startsWith(`git merge-base ${targetParentSha}`)) return targetParentSha; // is ancestor
197
+ it('should return true when a merge commit has a parent reachable from targetBranch', async () => {
198
+ gitCommandSpy.mockImplementation(async (args: string[]) => {
199
+ if (args.includes('--merges')) return mergeSha;
200
+ if (args.includes('--pretty=format:%P')) return `${branchParentSha} ${targetParentSha}`;
201
+ if (args[0] === 'merge-base' && args[1] === branchParentSha) return differentMergeBase;
202
+ if (args[0] === 'merge-base' && args[1] === targetParentSha) return targetParentSha;
195
203
  return '';
196
204
  });
197
205
 
198
- expect(hasMergeFromTarget(sourceBranch, targetBranch)).toBe(true);
206
+ await expect(hasMergeFromTarget(sourceBranch, targetBranch)).resolves.toBe(true);
207
+ expect(gitCommandSpy).toHaveBeenCalledWith(['merge-base', targetParentSha, targetBranch], {
208
+ operation: 'check whether a merge parent is reachable from the target branch',
209
+ });
199
210
  });
200
211
 
201
- it('should return false when the merge commit parent is not reachable from targetBranch (unrelated branch merge)', () => {
212
+ it('should return false when the merge commit parent is not reachable from targetBranch (unrelated branch merge)', async () => {
202
213
  const unrelatedSha = 'e'.repeat(40);
203
- const differentMergeBase = '0'.repeat(40);
204
- gitCommandSpy.mockImplementation((cmd: string) => {
205
- if (cmd.includes('--merges')) return mergeSha;
206
- if (cmd.includes('--pretty=format:%P')) return `${branchParentSha} ${unrelatedSha}`;
214
+ gitCommandSpy.mockImplementation(async (args: string[]) => {
215
+ if (args.includes('--merges')) return mergeSha;
216
+ if (args.includes('--pretty=format:%P')) return `${branchParentSha} ${unrelatedSha}`;
207
217
  // merge-base returns something other than the parent — not an ancestor
208
- if (cmd.startsWith('git merge-base')) return differentMergeBase;
218
+ if (args[0] === 'merge-base') return differentMergeBase;
209
219
  return '';
210
220
  });
211
221
 
212
- expect(hasMergeFromTarget(sourceBranch, targetBranch)).toBe(false);
222
+ await expect(hasMergeFromTarget(sourceBranch, targetBranch)).resolves.toBe(false);
223
+ });
224
+
225
+ it('continues after merge-base reports unrelated histories', async () => {
226
+ gitCommandSpy.mockImplementation(async (args: string[]) => {
227
+ if (args.includes('--merges')) return mergeSha;
228
+ if (args.includes('--pretty=format:%P')) return `${branchParentSha} ${targetParentSha}`;
229
+ if (args[0] === 'merge-base' && args[1] === branchParentSha) {
230
+ throw new Utils.GitCommandError(args, Object.assign(new Error('no common ancestor'), { code: 1 }));
231
+ }
232
+ if (args[0] === 'merge-base' && args[1] === targetParentSha) return targetParentSha;
233
+ return '';
234
+ });
235
+
236
+ await expect(hasMergeFromTarget(sourceBranch, targetBranch)).resolves.toBe(true);
237
+ });
238
+
239
+ it('propagates unexpected Git failures', async () => {
240
+ gitCommandSpy.mockImplementation(
241
+ async (args: string[], options?: Parameters<typeof Utils.runGitCommand>[1]) => {
242
+ if (args.includes('--merges')) return mergeSha;
243
+ if (args.includes('--pretty=format:%P')) return `${branchParentSha} ${targetParentSha}`;
244
+ throw new Utils.GitCommandError(
245
+ args,
246
+ Object.assign(new Error('Git failed'), { code: 128 }),
247
+ options?.operation,
248
+ );
249
+ },
250
+ );
251
+
252
+ await expect(hasMergeFromTarget(sourceBranch, targetBranch)).rejects.toThrow(
253
+ 'Failed to check whether a merge parent is reachable from the target branch.\n' +
254
+ 'Command: git "merge-base"',
255
+ );
213
256
  });
214
257
  });
215
258
 
@@ -220,24 +263,25 @@ describe('getBranchOnlyChangedFiles', () => {
220
263
  let gitCommandSpy: MockInstance;
221
264
 
222
265
  beforeEach(() => {
223
- gitCommandSpy = vi.spyOn(Utils, 'spawnCommandInGhWorkspace');
266
+ gitCommandSpy = vi.spyOn(Utils, 'runGitCommand');
224
267
  });
225
268
 
226
- it('should return files touched by non-merge commits', () => {
227
- gitCommandSpy.mockReturnValue('README.md\n\nactors/foo_bar/src/main.ts\n');
269
+ it('should return files touched by non-merge commits', async () => {
270
+ gitCommandSpy.mockResolvedValue('README.md\n\nactors/foo_bar/src/main.ts\n');
228
271
 
229
- const result = getBranchOnlyChangedFiles(sourceBranch, targetBranch);
272
+ const result = await getBranchOnlyChangedFiles(sourceBranch, targetBranch);
230
273
 
231
274
  expect(result).toStrictEqual(['README.md', 'actors/foo_bar/src/main.ts']);
232
275
  expect(gitCommandSpy).toHaveBeenCalledWith(
233
- `git log --no-merges --name-only --pretty=format: ${targetBranch}..${sourceBranch}`,
276
+ ['log', '--no-merges', '--name-only', '--pretty=format:', `${targetBranch}..${sourceBranch}`],
277
+ { operation: 'list files changed by non-merge commits on the source branch' },
234
278
  );
235
279
  });
236
280
 
237
- it('should return empty array when there are no non-merge commits', () => {
238
- gitCommandSpy.mockReturnValue('');
281
+ it('should return empty array when there are no non-merge commits', async () => {
282
+ gitCommandSpy.mockResolvedValue('');
239
283
 
240
- expect(getBranchOnlyChangedFiles(sourceBranch, targetBranch)).toStrictEqual([]);
284
+ await expect(getBranchOnlyChangedFiles(sourceBranch, targetBranch)).resolves.toStrictEqual([]);
241
285
  });
242
286
  });
243
287
 
@@ -272,14 +316,43 @@ describe('parseBaseCommit', () => {
272
316
  });
273
317
 
274
318
  describe('getCurrentBranch', () => {
275
- it('should return the checked-out branch', () => {
276
- vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockReturnValue('master');
277
- expect(getCurrentBranch()).toBe('master');
319
+ it('should return the checked-out branch', async () => {
320
+ const gitCommandSpy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue('master');
321
+ await expect(getCurrentBranch()).resolves.toBe('master');
322
+ expect(gitCommandSpy).toHaveBeenCalledWith(['rev-parse', '--abbrev-ref', 'HEAD'], {
323
+ operation: 'determine the current branch for release',
324
+ });
278
325
  });
279
326
 
280
- it('should throw on a detached HEAD', () => {
281
- vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockReturnValue('HEAD');
282
- expect(() => getCurrentBranch()).toThrow('HEAD is detached');
327
+ it('should throw on a detached HEAD', async () => {
328
+ vi.spyOn(Utils, 'runGitCommand').mockResolvedValue('HEAD');
329
+ await expect(getCurrentBranch()).rejects.toThrow('HEAD is detached');
330
+ });
331
+ });
332
+
333
+ describe('resolveRepoUrl', () => {
334
+ it('reads and rewrites the origin URL when no URL is supplied', async () => {
335
+ const gitCommandSpy = vi
336
+ .spyOn(Utils, 'runGitCommand')
337
+ .mockResolvedValue('https://github.com/apify/example.git');
338
+
339
+ await expect(resolveRepoUrl(undefined)).resolves.toStrictEqual({
340
+ repoUrl: 'git@github.com:apify/example.git',
341
+ shouldVerifyRepoUrl: true,
342
+ });
343
+ expect(gitCommandSpy).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
344
+ operation: 'read the origin repository URL',
345
+ });
346
+ });
347
+
348
+ it('uses an explicit URL without reading Git', async () => {
349
+ const gitCommandSpy = vi.spyOn(Utils, 'runGitCommand');
350
+
351
+ await expect(resolveRepoUrl('git@github.com:other/example.git')).resolves.toStrictEqual({
352
+ repoUrl: 'git@github.com:other/example.git',
353
+ shouldVerifyRepoUrl: false,
354
+ });
355
+ expect(gitCommandSpy).not.toHaveBeenCalled();
283
356
  });
284
357
  });
285
358
 
@@ -312,37 +385,94 @@ describe('getRepoName', () => {
312
385
  describe('resolveReleaseBaseCommit', () => {
313
386
  const baseSha = 'b'.repeat(40);
314
387
 
315
- it('should return the base commit when it is an ancestor of HEAD', () => {
316
- vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockImplementation((cmd: string) => {
317
- if (cmd.startsWith('git rev-parse --verify')) return baseSha;
318
- if (cmd.startsWith('git merge-base')) return baseSha;
388
+ it('should return the base commit when it is an ancestor of HEAD', async () => {
389
+ const gitCommandSpy = vi.spyOn(Utils, 'runGitCommand').mockImplementation(async (args: string[]) => {
390
+ if (args[0] === 'rev-parse') return baseSha;
391
+ if (args[0] === 'merge-base') return baseSha;
319
392
  return '';
320
393
  });
321
- expect(resolveReleaseBaseCommit(baseSha)).toBe(baseSha);
394
+ await expect(resolveReleaseBaseCommit(baseSha)).resolves.toBe(baseSha);
395
+ expect(gitCommandSpy).toHaveBeenCalledWith(['rev-parse', '--verify', '--quiet', `${baseSha}^{commit}`], {
396
+ operation: 'verify the release base commit in local Git history',
397
+ });
398
+ expect(gitCommandSpy).toHaveBeenCalledWith(['merge-base', baseSha, 'HEAD'], {
399
+ operation: 'check whether the release base commit is an ancestor of HEAD',
400
+ });
322
401
  });
323
402
 
324
- it('should throw on the all-zeros SHA of a newly created branch', () => {
325
- const spy = vi.spyOn(Utils, 'spawnCommandInGhWorkspace');
326
- expect(() => resolveReleaseBaseCommit('0'.repeat(40))).toThrow('the branch was just created');
403
+ it('should throw on the all-zeros SHA of a newly created branch', async () => {
404
+ const spy = vi.spyOn(Utils, 'runGitCommand');
405
+ await expect(resolveReleaseBaseCommit('0'.repeat(40))).rejects.toThrow('the branch was just created');
327
406
  expect(spy).not.toHaveBeenCalled();
328
407
  });
329
408
 
330
- it('should throw when the base commit is missing from the local history', () => {
331
- vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockReturnValue('');
332
- expect(() => resolveReleaseBaseCommit(baseSha)).toThrow('is not in the local git history');
409
+ it('should throw when the base commit is missing from the local history', async () => {
410
+ const spy = vi
411
+ .spyOn(Utils, 'runGitCommand')
412
+ .mockRejectedValue(
413
+ new Utils.GitCommandError(
414
+ ['rev-parse', '--verify', '--quiet', `${baseSha}^{commit}`],
415
+ Object.assign(new Error('missing'), { code: 1 }),
416
+ ),
417
+ );
418
+ await expect(resolveReleaseBaseCommit(baseSha)).rejects.toThrow('is not in the local git history');
419
+ expect(spy).toHaveBeenCalledTimes(1);
420
+ });
421
+
422
+ it('should throw when rev-parse returns an empty result', async () => {
423
+ const spy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue('');
424
+ await expect(resolveReleaseBaseCommit(baseSha)).rejects.toThrow('is not in the local git history');
425
+ expect(spy).toHaveBeenCalledTimes(1);
333
426
  });
334
427
 
335
- it('should throw when the base commit is not an ancestor of HEAD (force push)', () => {
336
- vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockImplementation((cmd: string) => {
337
- if (cmd.startsWith('git rev-parse --verify')) return baseSha;
338
- if (cmd.startsWith('git merge-base')) return 'c'.repeat(40);
428
+ it('should throw when the base commit is not an ancestor of HEAD (force push)', async () => {
429
+ vi.spyOn(Utils, 'runGitCommand').mockImplementation(async (args: string[]) => {
430
+ if (args[0] === 'rev-parse') return baseSha;
431
+ if (args[0] === 'merge-base') return 'c'.repeat(40);
339
432
  return '';
340
433
  });
341
- expect(() => resolveReleaseBaseCommit(baseSha)).toThrow('is not an ancestor of HEAD');
434
+ await expect(resolveReleaseBaseCommit(baseSha)).rejects.toThrow('is not an ancestor of HEAD');
342
435
  });
343
436
 
344
- it('should throw on an invalid SHA', () => {
345
- expect(() => resolveReleaseBaseCommit('not-a-sha')).toThrow('Invalid base commit SHA');
437
+ it('should throw when merge-base reports no common ancestor', async () => {
438
+ vi.spyOn(Utils, 'runGitCommand').mockImplementation(async (args: string[]) => {
439
+ if (args[0] === 'rev-parse') return baseSha;
440
+ throw new Utils.GitCommandError(args, Object.assign(new Error('no common ancestor'), { code: 1 }));
441
+ });
442
+ await expect(resolveReleaseBaseCommit(baseSha)).rejects.toThrow('is not an ancestor of HEAD');
443
+ });
444
+
445
+ it('explains an unexpected merge-base failure', async () => {
446
+ vi.spyOn(Utils, 'runGitCommand').mockImplementation(async (args, options) => {
447
+ if (args[0] === 'rev-parse') return baseSha;
448
+ throw new Utils.GitCommandError(
449
+ args,
450
+ Object.assign(new Error('Git failed'), { code: 128 }),
451
+ options?.operation,
452
+ );
453
+ });
454
+ await expect(resolveReleaseBaseCommit(baseSha)).rejects.toThrow(
455
+ 'Failed to check whether the release base commit is an ancestor of HEAD.\n' +
456
+ `Command: git "merge-base" "${baseSha}" "HEAD"\nGit error: Git failed`,
457
+ );
458
+ });
459
+
460
+ it('should propagate unexpected Git failures', async () => {
461
+ vi.spyOn(Utils, 'runGitCommand').mockRejectedValue(
462
+ new Utils.GitCommandError(
463
+ ['rev-parse', '--verify', '--quiet', `${baseSha}^{commit}`],
464
+ Object.assign(new Error('Git failed'), { code: 128 }),
465
+ 'verify the release base commit in local Git history',
466
+ ),
467
+ );
468
+ await expect(resolveReleaseBaseCommit(baseSha)).rejects.toThrow(
469
+ 'Failed to verify the release base commit in local Git history.\n' +
470
+ `Command: git "rev-parse" "--verify" "--quiet" "${baseSha}^{commit}"\nGit error: Git failed`,
471
+ );
472
+ });
473
+
474
+ it('should throw on an invalid SHA', async () => {
475
+ await expect(resolveReleaseBaseCommit('not-a-sha')).rejects.toThrow('Invalid base commit SHA');
346
476
  });
347
477
  });
348
478
 
@@ -352,21 +482,21 @@ describe('getReleaseChanges', () => {
352
482
  const mergedBranchCommit = `${'1'.repeat(40)}»¦«Dev<dev@example.com>»¦«Date1»¦«feat: branch change`;
353
483
  const mergeCommit = `${headSha}»¦«Dev<dev@example.com>»¦«Date2»¦«Merge pull request #1`;
354
484
 
355
- let gitCommandSpy: MockInstance;
485
+ let gitRunSpy: MockInstance;
356
486
 
357
487
  beforeEach(() => {
358
- gitCommandSpy = vi.spyOn(Utils, 'spawnCommandInGhWorkspace').mockImplementation((cmd: string) => {
359
- if (cmd === 'git rev-parse HEAD') return headSha;
360
- if (cmd.startsWith('git log')) return `${mergeCommit}\n${mergedBranchCommit}`;
361
- if (cmd.startsWith('git diff --name-only')) return 'actors/foo/src/main.ts\nCHANGELOG.md';
362
- if (cmd === 'git')
488
+ gitRunSpy = vi.spyOn(Utils, 'runGitCommand').mockImplementation(async (args: string[]) => {
489
+ if (args[0] === 'rev-parse') return headSha;
490
+ if (args[0] === 'log') return `${mergeCommit}\n${mergedBranchCommit}`;
491
+ if (args.includes('--name-only')) return 'actors/foo/src/main.ts\nCHANGELOG.md';
492
+ if (args[0] === 'diff')
363
493
  return 'diff --git a/CHANGELOG.md b/CHANGELOG.md\n--- a/CHANGELOG.md\n+++ b/CHANGELOG.md\n@@ -1 +1,2 @@\n+- Added foo\n # Changelog';
364
494
  return '';
365
495
  });
366
496
  });
367
497
 
368
- it('should diff the range directly and return the commits oldest first', () => {
369
- const changes = getReleaseChanges(baseSha);
498
+ it('should diff the range directly and return the commits oldest first', async () => {
499
+ const changes = await getReleaseChanges(baseSha);
370
500
 
371
501
  expect(changes).toStrictEqual({
372
502
  commits: [
@@ -377,13 +507,38 @@ describe('getReleaseChanges', () => {
377
507
  changelog: '- Added foo',
378
508
  });
379
509
  // Not from the parent of the oldest commit, which may predate the base commit for merge commits
380
- expect(gitCommandSpy).toHaveBeenCalledWith(`git diff --name-only ${baseSha} HEAD`);
381
- expect(gitCommandSpy).toHaveBeenCalledWith(
382
- `git log --pretty=format:'%H»¦«%aN<%aE>»¦«%aD»¦«%s' ${baseSha}..HEAD`,
510
+ expect(gitRunSpy).toHaveBeenCalledWith(['rev-parse', 'HEAD'], {
511
+ operation: 'identify the current release commit',
512
+ });
513
+ expect(gitRunSpy).toHaveBeenCalledWith(['diff', '--name-only', baseSha, 'HEAD'], {
514
+ operation: 'list files changed since the last release',
515
+ });
516
+ expect(gitRunSpy).toHaveBeenCalledWith(
517
+ ['log', '--pretty=format:%H»¦«%aN<%aE>»¦«%aD»¦«%s', `${baseSha}..HEAD`],
518
+ { operation: 'list commits reachable from the source ref but absent from the target ref' },
383
519
  );
520
+ expect(gitRunSpy).toHaveBeenCalledWith(['diff', baseSha, 'HEAD', '--', 'CHANGELOG.md'], {
521
+ operation: 'read changelog changes since the last release',
522
+ });
384
523
  });
385
524
 
386
- it('should return null when HEAD is the base commit', () => {
387
- expect(getReleaseChanges(headSha)).toBeNull();
525
+ it('should return null when HEAD is the base commit', async () => {
526
+ await expect(getReleaseChanges(headSha)).resolves.toBeNull();
527
+ expect(gitRunSpy).toHaveBeenCalledOnce();
528
+ expect(gitRunSpy).toHaveBeenCalledWith(['rev-parse', 'HEAD'], {
529
+ operation: 'identify the current release commit',
530
+ });
531
+ });
532
+
533
+ it('skips the changelog diff when CHANGELOG.md did not change', async () => {
534
+ gitRunSpy.mockImplementation(async (args: string[]) => {
535
+ if (args[0] === 'rev-parse') return headSha;
536
+ if (args[0] === 'log') return `${mergeCommit}\n${mergedBranchCommit}`;
537
+ if (args.includes('--name-only')) return 'actors/foo/src/main.ts';
538
+ return '';
539
+ });
540
+
541
+ await expect(getReleaseChanges(baseSha)).resolves.toMatchObject({ changelog: null });
542
+ expect(gitRunSpy).not.toHaveBeenCalledWith(['diff', baseSha, 'HEAD', '--', 'CHANGELOG.md']);
388
543
  });
389
544
  });
@@ -0,0 +1,55 @@
1
+ import process from 'node:process';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { logger, logLevels } from '../../../bin/logger.js';
6
+ import { writeJson } from '../../../bin/output.js';
7
+
8
+ describe('logger and CLI output', () => {
9
+ beforeEach(() => {
10
+ logger.setLevel('info');
11
+ vi.spyOn(process.stderr, 'write').mockReturnValue(true);
12
+ vi.spyOn(process.stdout, 'write').mockReturnValue(true);
13
+ });
14
+
15
+ afterEach(() => {
16
+ logger.setLevel('info');
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ it.each(logLevels)('filters diagnostics at the %s level without writing to stdout', (level) => {
21
+ logger.setLevel(level);
22
+ logger.debug('debug');
23
+ logger.info('info');
24
+ logger.warn('warn');
25
+ logger.error('error');
26
+
27
+ expect(vi.mocked(process.stderr.write).mock.calls.map(([message]) => message)).toEqual(
28
+ logLevels.slice(logLevels.indexOf(level), -1).map((message) => `${message}\n`),
29
+ );
30
+ expect(process.stdout.write).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it('defaults to info and preserves formatting, multiple arguments and blank lines', () => {
34
+ logger.debug('hidden');
35
+ logger.info('actor %s: %d', 'owner/name', 3);
36
+ logger.warn('warning', { actor: 'owner/name' });
37
+ logger.error(new Error('failed'));
38
+ logger.info();
39
+
40
+ expect(process.stderr.write).toHaveBeenCalledTimes(4);
41
+ expect(process.stderr.write).toHaveBeenNthCalledWith(1, 'actor owner/name: 3\n');
42
+ expect(process.stderr.write).toHaveBeenNthCalledWith(2, "warning { actor: 'owner/name' }\n");
43
+ expect(process.stderr.write).toHaveBeenNthCalledWith(3, expect.stringContaining('Error: failed'));
44
+ expect(process.stderr.write).toHaveBeenNthCalledWith(4, '\n');
45
+ });
46
+
47
+ it('writes newline-terminated JSON to stdout even when logging is silent', () => {
48
+ logger.setLevel('silent');
49
+ logger.error('hidden');
50
+ writeJson([{ actor: 'owner/name' }]);
51
+
52
+ expect(process.stdout.write).toHaveBeenCalledExactlyOnceWith('[{"actor":"owner/name"}]\n');
53
+ expect(process.stderr.write).not.toHaveBeenCalled();
54
+ });
55
+ });
@@ -0,0 +1,52 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { chmod, 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 projectRoot = fileURLToPath(new URL('../../../', import.meta.url));
11
+ const compilerPath = fileURLToPath(new URL('../../../node_modules/typescript/bin/tsc', import.meta.url));
12
+ let cliPath: string;
13
+ let buildDir: string | undefined;
14
+ let gitStubDir: string | undefined;
15
+ const sha = '1'.repeat(40);
16
+
17
+ describe('CLI logging', () => {
18
+ beforeAll(async () => {
19
+ buildDir = await mkdtemp(path.join(projectRoot, '.cli-test-'));
20
+ execFileSync(process.execPath, [compilerPath, '--outDir', buildDir], { cwd: projectRoot });
21
+ cliPath = path.join(buildDir, 'bin/main.js');
22
+ await chmod(cliPath, 0o755);
23
+ gitStubDir = await mkdtemp(path.join(os.tmpdir(), 'apify-cli-logging-'));
24
+ const gitStub = path.join(gitStubDir, 'git');
25
+ await writeFile(
26
+ gitStub,
27
+ `#!/bin/sh\nprintf '%s' '${sha}»¦«Test<test@example.com>»¦«Tue, 06 Oct 2026 00:00:00 +0000»¦«feature'\n`,
28
+ );
29
+ await chmod(gitStub, 0o755);
30
+ });
31
+
32
+ afterAll(async () => {
33
+ if (buildDir) await rm(buildDir, { recursive: true, force: true });
34
+ if (gitStubDir) await rm(gitStubDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it.each(['info', 'silent'])('keeps JSON results intact with --log-level %s', (level) => {
38
+ const result = spawnSync(
39
+ cliPath,
40
+ ['get-commits', '--source-branch', 'feature', '--target-branch', 'base', '--log-level', level],
41
+ {
42
+ encoding: 'utf8',
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ env: { ...process.env, PATH: `${gitStubDir}${path.delimiter}${process.env.PATH ?? ''}` },
45
+ },
46
+ );
47
+ expect(result.status, result.stderr).toBe(0);
48
+ expect(result.stderr).toEqual(level === 'silent' ? '' : expect.stringContaining('Commits being returned:'));
49
+ expect(JSON.parse(result.stdout)).toMatchObject([{ sha, date: expect.any(String), message: 'feature' }]);
50
+ expect(result.stdout.endsWith('\n')).toBe(true);
51
+ });
52
+ });