apify-test-tools 0.10.2-beta.0 → 0.10.2-beta.10

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 (187) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/actions/checkout-restore-dependencies/action.yaml +64 -27
  3. package/.github/cli/apify-test-tools.mjs +360 -0
  4. package/.github/scripts/check-major-tag-refs.mjs +1 -1
  5. package/.github/scripts/mask-config-secrets.sh +17 -0
  6. package/.github/scripts/run-with-apify-tokens.mjs +37 -12
  7. package/.github/workflows/_check_code.yaml +25 -0
  8. package/.github/workflows/_move_major_tag.yaml +3 -81
  9. package/.github/workflows/_tests.yaml +34 -0
  10. package/.github/workflows/_update_release_metadata.yaml +0 -12
  11. package/.github/workflows/manual_move_major_tag.yaml +3 -3
  12. package/.github/workflows/manual_release_stable.yaml +3 -13
  13. package/.github/workflows/on_master.yaml +9 -5
  14. package/.github/workflows/on_pull_request.yaml +3 -48
  15. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +13 -2
  16. package/.github/workflows/public_platform-tests.yaml +24 -1
  17. package/.github/workflows/public_pr-build-test.yaml +62 -49
  18. package/.github/workflows/public_push-build-latest.yaml +41 -8
  19. package/.husky/pre-commit +5 -0
  20. package/.prettierignore +3 -0
  21. package/CHANGELOG.md +17 -0
  22. package/CONTRIBUTING.md +105 -21
  23. package/README.md +218 -597
  24. package/bin/affected-actors.ts +198 -0
  25. package/bin/build-from-local.ts +65 -13
  26. package/bin/build.ts +123 -41
  27. package/bin/diff-changes.ts +55 -25
  28. package/bin/diff-json-schema.ts +5 -6
  29. package/bin/git.ts +69 -99
  30. package/bin/main.ts +96 -122
  31. package/bin/slack.ts +43 -10
  32. package/bin/stdin-input.ts +65 -0
  33. package/bin/test-report.ts +6 -9
  34. package/bin/types.ts +16 -0
  35. package/dist/bin/affected-actors.d.ts +46 -0
  36. package/dist/bin/affected-actors.d.ts.map +1 -0
  37. package/dist/bin/affected-actors.js +135 -0
  38. package/dist/bin/affected-actors.js.map +1 -0
  39. package/dist/bin/build-from-local.d.ts +12 -3
  40. package/dist/bin/build-from-local.d.ts.map +1 -1
  41. package/dist/bin/build-from-local.js +34 -12
  42. package/dist/bin/build-from-local.js.map +1 -1
  43. package/dist/bin/build.d.ts +30 -7
  44. package/dist/bin/build.d.ts.map +1 -1
  45. package/dist/bin/build.js +85 -26
  46. package/dist/bin/build.js.map +1 -1
  47. package/dist/bin/diff-changes.d.ts +12 -4
  48. package/dist/bin/diff-changes.d.ts.map +1 -1
  49. package/dist/bin/diff-changes.js +31 -20
  50. package/dist/bin/diff-changes.js.map +1 -1
  51. package/dist/bin/diff-json-schema.d.ts +2 -2
  52. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  53. package/dist/bin/diff-json-schema.js +1 -4
  54. package/dist/bin/diff-json-schema.js.map +1 -1
  55. package/dist/bin/git.d.ts +23 -21
  56. package/dist/bin/git.d.ts.map +1 -1
  57. package/dist/bin/git.js +60 -83
  58. package/dist/bin/git.js.map +1 -1
  59. package/dist/bin/main.js +82 -93
  60. package/dist/bin/main.js.map +1 -1
  61. package/dist/bin/slack.d.ts +14 -4
  62. package/dist/bin/slack.d.ts.map +1 -1
  63. package/dist/bin/slack.js +39 -6
  64. package/dist/bin/slack.js.map +1 -1
  65. package/dist/bin/stdin-input.d.ts +17 -0
  66. package/dist/bin/stdin-input.d.ts.map +1 -0
  67. package/dist/bin/stdin-input.js +54 -0
  68. package/dist/bin/stdin-input.js.map +1 -0
  69. package/dist/bin/test-report.d.ts.map +1 -1
  70. package/dist/bin/test-report.js +6 -8
  71. package/dist/bin/test-report.js.map +1 -1
  72. package/dist/bin/types.d.ts +14 -0
  73. package/dist/bin/types.d.ts.map +1 -1
  74. package/dist/lib/extend-expect.d.ts.map +1 -1
  75. package/dist/lib/extend-expect.js +37 -218
  76. package/dist/lib/extend-expect.js.map +1 -1
  77. package/dist/lib/lib.d.ts +1 -2
  78. package/dist/lib/lib.d.ts.map +1 -1
  79. package/dist/lib/lib.js +15 -22
  80. package/dist/lib/lib.js.map +1 -1
  81. package/dist/lib/run-checks.d.ts +14 -0
  82. package/dist/lib/run-checks.d.ts.map +1 -0
  83. package/dist/lib/run-checks.js +82 -0
  84. package/dist/lib/run-checks.js.map +1 -0
  85. package/dist/lib/run-test-result.d.ts +3 -6
  86. package/dist/lib/run-test-result.d.ts.map +1 -1
  87. package/dist/lib/run-test-result.js +41 -54
  88. package/dist/lib/run-test-result.js.map +1 -1
  89. package/dist/test/bundle/cli-bundle.test.d.ts +2 -0
  90. package/dist/test/bundle/cli-bundle.test.d.ts.map +1 -0
  91. package/dist/test/bundle/cli-bundle.test.js +190 -0
  92. package/dist/test/bundle/cli-bundle.test.js.map +1 -0
  93. package/dist/test/e2e/cli-bundle.e2e.test.d.ts +2 -0
  94. package/dist/test/e2e/cli-bundle.e2e.test.d.ts.map +1 -0
  95. package/dist/test/e2e/cli-bundle.e2e.test.js +138 -0
  96. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -0
  97. package/dist/test/e2e/git-build-release.e2e.test.d.ts +2 -0
  98. package/dist/test/e2e/git-build-release.e2e.test.d.ts.map +1 -0
  99. package/dist/test/e2e/git-build-release.e2e.test.js +87 -0
  100. package/dist/test/e2e/git-build-release.e2e.test.js.map +1 -0
  101. package/dist/test/e2e/helpers.d.ts +37 -0
  102. package/dist/test/e2e/helpers.d.ts.map +1 -0
  103. package/dist/test/e2e/helpers.js +57 -0
  104. package/dist/test/e2e/helpers.js.map +1 -0
  105. package/dist/test/unit/bin/affected-actors-cli.test.d.ts +2 -0
  106. package/dist/test/unit/bin/affected-actors-cli.test.d.ts.map +1 -0
  107. package/dist/test/unit/bin/affected-actors-cli.test.js +167 -0
  108. package/dist/test/unit/bin/affected-actors-cli.test.js.map +1 -0
  109. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  110. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  111. package/dist/test/unit/bin/affected-actors.test.js +141 -0
  112. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  113. package/dist/test/unit/bin/build-cli.test.d.ts +2 -0
  114. package/dist/test/unit/bin/build-cli.test.d.ts.map +1 -0
  115. package/dist/test/unit/bin/build-cli.test.js +44 -0
  116. package/dist/test/unit/bin/build-cli.test.js.map +1 -0
  117. package/dist/test/unit/bin/build-from-local.test.js +65 -1
  118. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  119. package/dist/test/unit/bin/build.test.js +223 -3
  120. package/dist/test/unit/bin/build.test.js.map +1 -1
  121. package/dist/test/unit/bin/compiled-cli.d.ts +6 -0
  122. package/dist/test/unit/bin/compiled-cli.d.ts.map +1 -0
  123. package/dist/test/unit/bin/compiled-cli.js +16 -0
  124. package/dist/test/unit/bin/compiled-cli.js.map +1 -0
  125. package/dist/test/unit/bin/diff-changes.test.js +205 -85
  126. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  127. package/dist/test/unit/bin/diff.test.js +25 -28
  128. package/dist/test/unit/bin/diff.test.js.map +1 -1
  129. package/dist/test/unit/bin/git.test.js +69 -152
  130. package/dist/test/unit/bin/git.test.js.map +1 -1
  131. package/dist/test/unit/bin/slack.test.d.ts +2 -0
  132. package/dist/test/unit/bin/slack.test.d.ts.map +1 -0
  133. package/dist/test/unit/bin/slack.test.js +71 -0
  134. package/dist/test/unit/bin/slack.test.js.map +1 -0
  135. package/dist/test/unit/bin/stdin-input.test.d.ts +2 -0
  136. package/dist/test/unit/bin/stdin-input.test.d.ts.map +1 -0
  137. package/dist/test/unit/bin/stdin-input.test.js +50 -0
  138. package/dist/test/unit/bin/stdin-input.test.js.map +1 -0
  139. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts +2 -0
  140. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts.map +1 -0
  141. package/dist/test/unit/github-scripts/mask-config-secrets.test.js +50 -0
  142. package/dist/test/unit/github-scripts/mask-config-secrets.test.js.map +1 -0
  143. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts +2 -0
  144. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts.map +1 -0
  145. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js +102 -0
  146. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js.map +1 -0
  147. package/dist/test/unit/run-checks.test.d.ts +2 -0
  148. package/dist/test/unit/run-checks.test.d.ts.map +1 -0
  149. package/dist/test/unit/run-checks.test.js +62 -0
  150. package/dist/test/unit/run-checks.test.js.map +1 -0
  151. package/dist/test/unit/run-test-result.test.d.ts +2 -0
  152. package/dist/test/unit/run-test-result.test.d.ts.map +1 -0
  153. package/dist/test/unit/run-test-result.test.js +34 -0
  154. package/dist/test/unit/run-test-result.test.js.map +1 -0
  155. package/dist/test/unit/should-built-and-test.test.js +41 -38
  156. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  157. package/dist/tsconfig.tsbuildinfo +1 -1
  158. package/docs/cli.md +373 -0
  159. package/docs/lib.md +271 -0
  160. package/docs/workflows.md +367 -0
  161. package/lib/extend-expect.ts +48 -258
  162. package/lib/lib.ts +24 -28
  163. package/lib/run-checks.ts +101 -0
  164. package/lib/run-test-result.ts +45 -55
  165. package/package.json +15 -3
  166. package/test/bundle/cli-bundle.test.ts +235 -0
  167. package/test/e2e/cli-bundle.e2e.test.ts +207 -0
  168. package/test/e2e/git-build-release.e2e.test.ts +113 -0
  169. package/test/e2e/helpers.ts +82 -0
  170. package/test/unit/bin/affected-actors-cli.test.ts +201 -0
  171. package/test/unit/bin/affected-actors.test.ts +182 -0
  172. package/test/unit/bin/build-cli.test.ts +55 -0
  173. package/test/unit/bin/build-from-local.test.ts +71 -0
  174. package/test/unit/bin/build.test.ts +279 -3
  175. package/test/unit/bin/compiled-cli.ts +17 -0
  176. package/test/unit/bin/diff-changes.test.ts +229 -85
  177. package/test/unit/bin/diff.test.ts +25 -28
  178. package/test/unit/bin/git.test.ts +85 -176
  179. package/test/unit/bin/slack.test.ts +84 -0
  180. package/test/unit/bin/stdin-input.test.ts +61 -0
  181. package/test/unit/github-scripts/mask-config-secrets.test.ts +61 -0
  182. package/test/unit/github-scripts/run-with-apify-tokens.test.ts +115 -0
  183. package/test/unit/run-checks.test.ts +76 -0
  184. package/test/unit/run-test-result.test.ts +44 -0
  185. package/test/unit/should-built-and-test.test.ts +46 -39
  186. package/.github/scripts/check-package-version-bump.mjs +0 -74
  187. package/.github/workflows-package-version +0 -1
@@ -4,10 +4,7 @@ import { beforeEach, describe, expect, it, test, vi } from 'vitest';
4
4
  import { isCosmeticOnlyJsonSchemaChange } from '../../../bin/diff-json-schema.js';
5
5
  import * as Utils from '../../../bin/utils.js';
6
6
 
7
- const commits = [
8
- { sha: 'Commit1', author: '', date: '', message: '' },
9
- { sha: 'Commit3', author: '', date: '', message: '' },
10
- ];
7
+ const range = { fromRef: 'Commit0', toRef: 'Commit3' };
11
8
 
12
9
  describe('isCosmeticOnlyJsonSchemaChange', () => {
13
10
  let gitCommandSpy: MockInstance;
@@ -23,7 +20,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
23
20
  test('returns true when nothing changed', async () => {
24
21
  const json = JSON.stringify({ type: 'string', title: 'My field' });
25
22
  mockGitCalls(json, json);
26
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
23
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
27
24
  });
28
25
 
29
26
  test('returns true when only title changes', async () => {
@@ -31,7 +28,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
31
28
  JSON.stringify({ type: 'string', title: 'Old title' }),
32
29
  JSON.stringify({ type: 'string', title: 'New title' }),
33
30
  );
34
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
31
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
35
32
  });
36
33
 
37
34
  test('returns true when only description changes', async () => {
@@ -39,7 +36,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
39
36
  JSON.stringify({ type: 'string', description: 'Old desc' }),
40
37
  JSON.stringify({ type: 'string', description: 'New desc' }),
41
38
  );
42
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
39
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
43
40
  });
44
41
 
45
42
  test('returns true when only example changes', async () => {
@@ -47,7 +44,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
47
44
  JSON.stringify({ type: 'string', example: 'foo' }),
48
45
  JSON.stringify({ type: 'string', example: 'bar' }),
49
46
  );
50
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
47
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
51
48
  });
52
49
 
53
50
  test('returns true when only enumTitles changes', async () => {
@@ -55,7 +52,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
55
52
  JSON.stringify({ type: 'string', enum: ['a', 'b'], enumTitles: ['Option A', 'Option B'] }),
56
53
  JSON.stringify({ type: 'string', enum: ['a', 'b'], enumTitles: ['Choice A', 'Choice B'] }),
57
54
  );
58
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
55
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
59
56
  });
60
57
 
61
58
  test('returns true when only sectionCaption changes', async () => {
@@ -63,12 +60,12 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
63
60
  JSON.stringify({ sectionCaption: 'Old caption' }),
64
61
  JSON.stringify({ sectionCaption: 'New caption' }),
65
62
  );
66
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
63
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
67
64
  });
68
65
 
69
66
  test('returns true when only sectionDescription changes', async () => {
70
67
  mockGitCalls(JSON.stringify({ sectionDescription: 'Old' }), JSON.stringify({ sectionDescription: 'New' }));
71
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
68
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
72
69
  });
73
70
 
74
71
  test('returns false when a functional field (type) changes', async () => {
@@ -76,17 +73,17 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
76
73
  JSON.stringify({ type: 'string', title: 'My field' }),
77
74
  JSON.stringify({ type: 'integer', title: 'My field' }),
78
75
  );
79
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
76
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
80
77
  });
81
78
 
82
79
  test('returns false when a functional field is added', async () => {
83
80
  mockGitCalls(JSON.stringify({ title: 'My field' }), JSON.stringify({ title: 'My field', type: 'string' }));
84
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
81
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
85
82
  });
86
83
 
87
84
  test('returns false when a functional field is removed', async () => {
88
85
  mockGitCalls(JSON.stringify({ type: 'string', title: 'My field' }), JSON.stringify({ title: 'My field' }));
89
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
86
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
90
87
  });
91
88
 
92
89
  test('returns true when non-functional fields change at nested level (input schema properties)', async () => {
@@ -104,7 +101,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
104
101
  },
105
102
  }),
106
103
  );
107
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
104
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
108
105
  });
109
106
 
110
107
  test('returns false when a functional nested field changes alongside non-functional ones', async () => {
@@ -118,7 +115,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
118
115
  properties: { myField: { type: 'integer', title: 'New title' } },
119
116
  }),
120
117
  );
121
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
118
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
122
119
  });
123
120
 
124
121
  test('returns false when a new property is added to input schema', async () => {
@@ -126,7 +123,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
126
123
  JSON.stringify({ properties: { fieldA: { type: 'string' } } }),
127
124
  JSON.stringify({ properties: { fieldA: { type: 'string' }, fieldB: { type: 'integer' } } }),
128
125
  );
129
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
126
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
130
127
  });
131
128
 
132
129
  test('returns false when a property is removed from input schema', async () => {
@@ -134,27 +131,27 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
134
131
  JSON.stringify({ properties: { fieldA: { type: 'string' }, fieldB: { type: 'integer' } } }),
135
132
  JSON.stringify({ properties: { fieldA: { type: 'string' } } }),
136
133
  );
137
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
134
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
138
135
  });
139
136
 
140
137
  test('returns false when invalid JSON in old content', async () => {
141
138
  mockGitCalls('not json', '{}');
142
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
139
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
143
140
  });
144
141
 
145
142
  test('returns false when invalid JSON in new content', async () => {
146
143
  mockGitCalls('{}', 'not json');
147
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
144
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
148
145
  });
149
146
 
150
147
  test('returns false when git cannot read a schema version', async () => {
151
148
  gitCommandSpy.mockRejectedValueOnce(new Error('missing schema'));
152
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
149
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
153
150
  });
154
151
 
155
152
  test('returns false when array content changes (not under a non-functional key)', async () => {
156
153
  mockGitCalls(JSON.stringify({ enum: [1, 2, 3] }), JSON.stringify({ enum: [1, 2, 4] }));
157
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
154
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
158
155
  });
159
156
 
160
157
  test('returns true when example array changes (non-functional key)', async () => {
@@ -162,7 +159,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
162
159
  JSON.stringify({ type: 'array', example: [1, 2, 3] }),
163
160
  JSON.stringify({ type: 'array', example: [4, 5, 6] }),
164
161
  );
165
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
162
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
166
163
  });
167
164
 
168
165
  test('returns true for a realistic actor.json with only title/description change', async () => {
@@ -182,7 +179,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
182
179
  version: '1.0',
183
180
  }),
184
181
  );
185
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
182
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
186
183
  });
187
184
 
188
185
  test('returns true when fields under properties are reordered', async () => {
@@ -196,7 +193,7 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
196
193
  properties: { fieldC: { type: 'boolean' }, fieldA: { type: 'string' }, fieldB: { type: 'integer' } },
197
194
  }),
198
195
  );
199
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(true);
196
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(true);
200
197
  });
201
198
 
202
199
  test('returns false for a realistic actor.json with version change', async () => {
@@ -204,14 +201,14 @@ describe('isCosmeticOnlyJsonSchemaChange', () => {
204
201
  JSON.stringify({ actorSpecification: 1, name: 'my-actor', title: 'My Actor', version: '1.0' }),
205
202
  JSON.stringify({ actorSpecification: 1, name: 'my-actor', title: 'My Actor', version: '1.1' }),
206
203
  );
207
- await expect(isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json')).resolves.toBe(false);
204
+ await expect(isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json')).resolves.toBe(false);
208
205
  });
209
206
 
210
207
  it('uses correct git refs: first-commit parent for old, last commit for new', async () => {
211
208
  const json = JSON.stringify({});
212
209
  mockGitCalls(json, json);
213
- await isCosmeticOnlyJsonSchemaChange(commits, 'actors/foo/actor.json');
214
- expect(gitCommandSpy).toHaveBeenCalledWith(['show', 'Commit1~:actors/foo/actor.json']);
210
+ await isCosmeticOnlyJsonSchemaChange(range, 'actors/foo/actor.json');
211
+ expect(gitCommandSpy).toHaveBeenCalledWith(['show', 'Commit0:actors/foo/actor.json']);
215
212
  expect(gitCommandSpy).toHaveBeenCalledWith(['show', 'Commit3:actors/foo/actor.json']);
216
213
  });
217
214
  });
@@ -2,15 +2,16 @@ import type { MockInstance } from 'vitest';
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  import {
5
- getBranchOnlyChangedFiles,
5
+ findCommitSha,
6
6
  getChangedFiles,
7
+ getChangedFilesBetween,
7
8
  getCommits,
9
+ getCommitsBetween,
8
10
  getCurrentBranch,
9
- getReleaseChanges,
10
11
  getRepoName,
11
- hasMergeFromTarget,
12
12
  normalizeRepoUrl,
13
13
  parseBaseCommit,
14
+ resolveCommitSha,
14
15
  resolveReleaseBaseCommit,
15
16
  resolveRepoUrl,
16
17
  } from '../../../bin/git.js';
@@ -123,9 +124,12 @@ describe('getChangedFiles', () => {
123
124
  expect(changedFiles).toStrictEqual(['file1.txt', 'folder/file2.txt']);
124
125
 
125
126
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
126
- expect(gitCommandSpy).toHaveBeenCalledWith(['diff', '--name-only', `${firstSha}~..${lastSha}`], {
127
- operation: 'get the list of changed files between commits',
128
- });
127
+ expect(gitCommandSpy).toHaveBeenCalledWith(
128
+ ['diff', '--name-only', '--no-renames', `${firstSha}~..${lastSha}`],
129
+ {
130
+ operation: 'get the list of changed files between commits',
131
+ },
132
+ );
129
133
  });
130
134
 
131
135
  it('should throw without running git when the commit list is empty', async () => {
@@ -146,7 +150,7 @@ describe('getChangedFiles', () => {
146
150
  expect(changedFiles).toStrictEqual(['file1.txt', 'folder/file2.txt']);
147
151
 
148
152
  expect(gitCommandSpy).toHaveBeenCalledTimes(1);
149
- expect(gitCommandSpy).toHaveBeenCalledWith(['diff', '--name-only', `${onlySha}~..${onlySha}`], {
153
+ expect(gitCommandSpy).toHaveBeenCalledWith(['diff', '--name-only', '--no-renames', `${onlySha}~..${onlySha}`], {
150
154
  operation: 'get the list of changed files between commits',
151
155
  });
152
156
  });
@@ -167,124 +171,6 @@ describe('getChangedFiles', () => {
167
171
  });
168
172
  });
169
173
 
170
- describe('hasMergeFromTarget', () => {
171
- const sourceBranch = 'feature-branch';
172
- const targetBranch = 'main';
173
- const mergeSha = 'f'.repeat(40);
174
- const branchParentSha = 'b'.repeat(40);
175
- const targetParentSha = 't'.repeat(40);
176
- const differentMergeBase = '0'.repeat(40);
177
-
178
- let gitCommandSpy: MockInstance;
179
-
180
- beforeEach(() => {
181
- gitCommandSpy = vi.spyOn(Utils, 'runGitCommand');
182
- });
183
-
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 '';
187
- return '';
188
- });
189
-
190
- await expect(hasMergeFromTarget(sourceBranch, targetBranch)).resolves.toBe(false);
191
- expect(gitCommandSpy).toHaveBeenCalledWith(
192
- ['log', '--merges', '--pretty=format:%H', `${targetBranch}..${sourceBranch}`],
193
- { operation: 'find merge commits on the source branch that are absent from the target branch' },
194
- );
195
- });
196
-
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;
203
- return '';
204
- });
205
-
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
- });
210
- });
211
-
212
- it('should return false when the merge commit parent is not reachable from targetBranch (unrelated branch merge)', async () => {
213
- const unrelatedSha = 'e'.repeat(40);
214
- gitCommandSpy.mockImplementation(async (args: string[]) => {
215
- if (args.includes('--merges')) return mergeSha;
216
- if (args.includes('--pretty=format:%P')) return `${branchParentSha} ${unrelatedSha}`;
217
- // merge-base returns something other than the parent — not an ancestor
218
- if (args[0] === 'merge-base') return differentMergeBase;
219
- return '';
220
- });
221
-
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
- );
256
- });
257
- });
258
-
259
- describe('getBranchOnlyChangedFiles', () => {
260
- const sourceBranch = 'feature-branch';
261
- const targetBranch = 'main';
262
-
263
- let gitCommandSpy: MockInstance;
264
-
265
- beforeEach(() => {
266
- gitCommandSpy = vi.spyOn(Utils, 'runGitCommand');
267
- });
268
-
269
- it('should return files touched by non-merge commits', async () => {
270
- gitCommandSpy.mockResolvedValue('README.md\n\nactors/foo_bar/src/main.ts\n');
271
-
272
- const result = await getBranchOnlyChangedFiles(sourceBranch, targetBranch);
273
-
274
- expect(result).toStrictEqual(['README.md', 'actors/foo_bar/src/main.ts']);
275
- expect(gitCommandSpy).toHaveBeenCalledWith(
276
- ['log', '--no-merges', '--name-only', '--pretty=format:', `${targetBranch}..${sourceBranch}`],
277
- { operation: 'list files changed by non-merge commits on the source branch' },
278
- );
279
- });
280
-
281
- it('should return empty array when there are no non-merge commits', async () => {
282
- gitCommandSpy.mockResolvedValue('');
283
-
284
- await expect(getBranchOnlyChangedFiles(sourceBranch, targetBranch)).resolves.toStrictEqual([]);
285
- });
286
- });
287
-
288
174
  const VALID_SHA = 'a'.repeat(40);
289
175
  const VALID_JSON = JSON.stringify({ sha: VALID_SHA, author: 'test', date: 'now', message: 'msg' });
290
176
 
@@ -320,7 +206,7 @@ describe('getCurrentBranch', () => {
320
206
  const gitCommandSpy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue('master');
321
207
  await expect(getCurrentBranch()).resolves.toBe('master');
322
208
  expect(gitCommandSpy).toHaveBeenCalledWith(['rev-parse', '--abbrev-ref', 'HEAD'], {
323
- operation: 'determine the current branch for release',
209
+ operation: 'determine the checked-out branch',
324
210
  });
325
211
  });
326
212
 
@@ -474,71 +360,94 @@ describe('resolveReleaseBaseCommit', () => {
474
360
  it('should throw on an invalid SHA', async () => {
475
361
  await expect(resolveReleaseBaseCommit('not-a-sha')).rejects.toThrow('Invalid base commit SHA');
476
362
  });
363
+
364
+ it('checks ancestry against a custom head', async () => {
365
+ const gitCommandSpy = vi.spyOn(Utils, 'runGitCommand').mockImplementation(async (args: string[]) => {
366
+ if (args[0] === 'rev-parse') return baseSha;
367
+ if (args[0] === 'merge-base') return 'c'.repeat(40);
368
+ return '';
369
+ });
370
+ await expect(resolveReleaseBaseCommit(baseSha, 'origin/release')).rejects.toThrow(
371
+ 'is not an ancestor of origin/release',
372
+ );
373
+ expect(gitCommandSpy).toHaveBeenCalledWith(['merge-base', baseSha, 'origin/release'], {
374
+ operation: 'check whether the release base commit is an ancestor of origin/release',
375
+ });
376
+ });
477
377
  });
478
378
 
479
- describe('getReleaseChanges', () => {
480
- const baseSha = 'b'.repeat(40);
481
- const headSha = 'd'.repeat(40);
482
- const mergedBranchCommit = `${'1'.repeat(40)}»¦«Dev<dev@example.com>»¦«Date1»¦«feat: branch change`;
483
- const mergeCommit = `${headSha}»¦«Dev<dev@example.com>»¦«Date2»¦«Merge pull request #1`;
379
+ describe('getCommitsBetween', () => {
380
+ it('lists the commits after fromRef up to toRef, oldest first', async () => {
381
+ const older = `${'1'.repeat(40)}»¦«Dev<dev@example.com>»¦«Date1»¦«first`;
382
+ const newer = `${'2'.repeat(40)}»¦«Dev<dev@example.com>»¦«Date2»¦«second`;
383
+ const spy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue(`${newer}\n${older}`);
484
384
 
485
- let gitRunSpy: MockInstance;
385
+ const commits = await getCommitsBetween('base-sha', 'head-sha');
486
386
 
487
- beforeEach(() => {
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')
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';
494
- return '';
387
+ expect(commits.map((c) => c.message)).toStrictEqual(['first', 'second']);
388
+ expect(spy).toHaveBeenCalledWith(['log', '--pretty=format:%H»¦«%aN<%aE>»¦«%aD»¦«%s', 'base-sha..head-sha'], {
389
+ operation: 'list commits reachable from the source ref but absent from the target ref',
495
390
  });
496
391
  });
392
+ });
393
+ describe('empty commit ranges', () => {
394
+ // Real git on this repository's own history: HEAD..HEAD has no commits
395
+ beforeEach(() => {
396
+ vi.restoreAllMocks();
397
+ });
497
398
 
498
- it('should diff the range directly and return the commits oldest first', async () => {
499
- const changes = await getReleaseChanges(baseSha);
399
+ it('getCommitsBetween returns no commits', async () => {
400
+ await expect(getCommitsBetween('HEAD', 'HEAD')).resolves.toStrictEqual([]);
401
+ });
500
402
 
501
- expect(changes).toStrictEqual({
502
- commits: [
503
- { sha: '1'.repeat(40), author: 'Dev<dev@example.com>', date: 'Date1', message: 'feat: branch change' },
504
- { sha: headSha, author: 'Dev<dev@example.com>', date: 'Date2', message: 'Merge pull request #1' },
505
- ],
506
- changedFiles: ['actors/foo/src/main.ts', 'CHANGELOG.md'],
507
- changelog: '- Added foo',
508
- });
509
- // Not from the parent of the oldest commit, which may predate the base commit for merge commits
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' },
403
+ it('getCommits fails clearly, since the commands built on it need at least one commit', async () => {
404
+ await expect(getCommits({ sourceBranch: 'HEAD', targetBranch: 'HEAD' })).rejects.toThrow(
405
+ 'HEAD has no commits that are not on HEAD.',
519
406
  );
520
- expect(gitRunSpy).toHaveBeenCalledWith(['diff', baseSha, 'HEAD', '--', 'CHANGELOG.md'], {
521
- operation: 'read changelog changes since the last release',
522
- });
523
407
  });
408
+ });
409
+
410
+ describe('getChangedFilesBetween', () => {
411
+ it('diffs the two endpoints directly', async () => {
412
+ const spy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue('src/main.ts\nCHANGELOG.md');
524
413
 
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',
414
+ await expect(getChangedFilesBetween('base-sha', 'head-sha')).resolves.toStrictEqual([
415
+ 'src/main.ts',
416
+ 'CHANGELOG.md',
417
+ ]);
418
+ expect(spy).toHaveBeenCalledWith(['diff', '--name-only', '--no-renames', 'base-sha..head-sha'], {
419
+ operation: 'get the list of changed files between commits',
530
420
  });
531
421
  });
422
+ });
532
423
 
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 '';
424
+ describe('resolveCommitSha', () => {
425
+ it('resolves a ref to the commit it points to', async () => {
426
+ const sha = 'a'.repeat(40);
427
+ const spy = vi.spyOn(Utils, 'runGitCommand').mockResolvedValue(sha);
428
+ await expect(resolveCommitSha('origin/feature')).resolves.toBe(sha);
429
+ expect(spy).toHaveBeenCalledWith(['rev-parse', '--verify', 'origin/feature^{commit}'], {
430
+ operation: 'resolve origin/feature to a commit',
539
431
  });
432
+ });
433
+ });
434
+
435
+ describe('findCommitSha', () => {
436
+ // Real git on this repository's own history, since what matters is how git answers
437
+ beforeEach(() => {
438
+ vi.restoreAllMocks();
439
+ });
440
+
441
+ it('resolves a ref in the local history to its commit', async () => {
442
+ await expect(findCommitSha('HEAD')).resolves.toMatch(/^[0-9a-f]{40}$/);
443
+ });
444
+
445
+ it('returns undefined for a commit missing from the local history', async () => {
446
+ await expect(findCommitSha('d'.repeat(40))).resolves.toBeUndefined();
447
+ });
540
448
 
541
- await expect(getReleaseChanges(baseSha)).resolves.toMatchObject({ changelog: null });
542
- expect(gitRunSpy).not.toHaveBeenCalledWith(['diff', baseSha, 'HEAD', '--', 'CHANGELOG.md']);
449
+ it('rethrows other git failures', async () => {
450
+ vi.spyOn(Utils, 'runGitCommand').mockRejectedValue(new Error('not a git repository'));
451
+ await expect(findCommitSha('HEAD')).rejects.toThrow('not a git repository');
543
452
  });
544
453
  });
@@ -0,0 +1,84 @@
1
+ import { WebClient } from '@slack/web-api';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { notifyToSlack } from '../../../bin/slack.js';
5
+
6
+ vi.mock('@slack/web-api', () => ({ WebClient: vi.fn() }));
7
+
8
+ const TOKEN_ENV_VAR = 'SLACK_TOKEN_RELEASES_BOT';
9
+ const postMessage = vi.fn();
10
+ const authTest = vi.fn();
11
+
12
+ const notify = async (overrides: Partial<Parameters<typeof notifyToSlack>[0]> = {}) =>
13
+ notifyToSlack({
14
+ repoUrl: 'git@github.com:owner/repo',
15
+ changedFiles: ['src/main.ts'],
16
+ changelog: '- Fixed a bug',
17
+ commits: [{ sha: '1', author: 'Test<test@example.com>', date: '', message: 'fix: bug' }],
18
+ dryRun: true,
19
+ releaseSlackChannel: '#releases',
20
+ reportSlackChannel: '#notif',
21
+ ...overrides,
22
+ });
23
+
24
+ describe('notifyToSlack', () => {
25
+ beforeEach(() => {
26
+ vi.stubEnv(TOKEN_ENV_VAR, 'token');
27
+ vi.mocked(WebClient).mockImplementation(function () {
28
+ return { chat: { postMessage }, auth: { test: authTest } };
29
+ } as never);
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllEnvs();
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ it('should verify the token but post nothing in a dry run', async () => {
38
+ await notify();
39
+ expect(authTest).toHaveBeenCalledTimes(1);
40
+ expect(postMessage).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it('should still require the token in a dry run', async () => {
44
+ vi.stubEnv(TOKEN_ENV_VAR, '');
45
+ await expect(notify()).rejects.toThrow(`${TOKEN_ENV_VAR} not defined`);
46
+ });
47
+
48
+ it('should fail a dry run when Slack rejects the token', async () => {
49
+ authTest.mockRejectedValueOnce(new Error('An API error occurred: invalid_auth'));
50
+ await expect(notify()).rejects.toThrow(`${TOKEN_ENV_VAR} was rejected by Slack`);
51
+ });
52
+
53
+ it('should not need a token when no channel is passed', async () => {
54
+ vi.stubEnv(TOKEN_ENV_VAR, '');
55
+ await notify({ dryRun: false, releaseSlackChannel: undefined, reportSlackChannel: undefined });
56
+ expect(WebClient).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it('should post to both channels outside of a dry run', async () => {
60
+ await notify({ dryRun: false });
61
+ expect(authTest).not.toHaveBeenCalled();
62
+ expect(postMessage).toHaveBeenCalledTimes(2);
63
+ });
64
+
65
+ it('should announce nothing, and need no token, when there are no new commits', async () => {
66
+ vi.stubEnv(TOKEN_ENV_VAR, '');
67
+ await notify({ commits: [], dryRun: false });
68
+ expect(WebClient).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('should announce the release by the author of the newest commit, named after the repository', async () => {
72
+ const commit = (author: string, message: string) => ({ sha: '1', author, date: '', message });
73
+ await notify({
74
+ commits: [commit('Old<old@example.com>', 'fix: older'), commit('New<new@example.com>', 'feat: newer')],
75
+ repoUrl: 'git@github.com:owner/my-repo',
76
+ dryRun: false,
77
+ releaseSlackChannel: undefined,
78
+ });
79
+ expect(postMessage).toHaveBeenCalledWith({
80
+ channel: '#notif',
81
+ text: expect.stringContaining('*my-repo* – New release (by New)'),
82
+ });
83
+ });
84
+ });