apify-test-tools 0.6.0 → 0.6.2-beta.0

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 (108) hide show
  1. package/.github/workflows/_check_code.yaml +17 -19
  2. package/.github/workflows/_update_release_metadata.yaml +1 -1
  3. package/.github/workflows/manual_release_stable.yaml +1 -0
  4. package/.github/workflows/on_master.yaml +2 -0
  5. package/.husky/pre-commit +13 -0
  6. package/CHANGELOG.md +17 -3
  7. package/CONTRIBUTING.md +5 -0
  8. package/README.md +39 -43
  9. package/bin/build.ts +138 -87
  10. package/bin/diff-changes.ts +156 -0
  11. package/bin/diff-json-schema.ts +50 -0
  12. package/bin/git.ts +42 -11
  13. package/bin/github.ts +7 -6
  14. package/bin/main.ts +31 -13
  15. package/bin/slack.ts +6 -2
  16. package/bin/test-report.ts +55 -44
  17. package/bin/types.ts +56 -59
  18. package/bin/utils.ts +19 -131
  19. package/dist/bin/build.d.ts +1 -1
  20. package/dist/bin/build.d.ts.map +1 -1
  21. package/dist/bin/build.js +49 -31
  22. package/dist/bin/build.js.map +1 -1
  23. package/dist/bin/diff-changes.d.ts +16 -0
  24. package/dist/bin/diff-changes.d.ts.map +1 -0
  25. package/dist/bin/diff-changes.js +103 -0
  26. package/dist/bin/diff-changes.js.map +1 -0
  27. package/dist/bin/diff-json-schema.d.ts +7 -0
  28. package/dist/bin/diff-json-schema.d.ts.map +1 -0
  29. package/dist/bin/diff-json-schema.js +45 -0
  30. package/dist/bin/diff-json-schema.js.map +1 -0
  31. package/dist/bin/git.d.ts +8 -3
  32. package/dist/bin/git.d.ts.map +1 -1
  33. package/dist/bin/git.js +35 -10
  34. package/dist/bin/git.js.map +1 -1
  35. package/dist/bin/github.d.ts +1 -3
  36. package/dist/bin/github.d.ts.map +1 -1
  37. package/dist/bin/github.js +5 -5
  38. package/dist/bin/github.js.map +1 -1
  39. package/dist/bin/main.js +19 -7
  40. package/dist/bin/main.js.map +1 -1
  41. package/dist/bin/slack.d.ts +1 -1
  42. package/dist/bin/slack.d.ts.map +1 -1
  43. package/dist/bin/slack.js +1 -1
  44. package/dist/bin/slack.js.map +1 -1
  45. package/dist/bin/test-report.d.ts.map +1 -1
  46. package/dist/bin/test-report.js +9 -3
  47. package/dist/bin/test-report.js.map +1 -1
  48. package/dist/bin/types.d.ts +0 -1
  49. package/dist/bin/types.d.ts.map +1 -1
  50. package/dist/bin/utils.d.ts +0 -14
  51. package/dist/bin/utils.d.ts.map +1 -1
  52. package/dist/bin/utils.js +12 -95
  53. package/dist/bin/utils.js.map +1 -1
  54. package/dist/lib/extend-expect.d.ts.map +1 -1
  55. package/dist/lib/extend-expect.js +11 -11
  56. package/dist/lib/extend-expect.js.map +1 -1
  57. package/dist/lib/lib.d.ts +3 -2
  58. package/dist/lib/lib.d.ts.map +1 -1
  59. package/dist/lib/lib.js +7 -3
  60. package/dist/lib/lib.js.map +1 -1
  61. package/dist/lib/run-test-result.d.ts +1 -1
  62. package/dist/lib/run-test-result.d.ts.map +1 -1
  63. package/dist/lib/run-test-result.js.map +1 -1
  64. package/dist/lib/types.d.ts +2 -3
  65. package/dist/lib/types.d.ts.map +1 -1
  66. package/dist/lib/utils.d.ts +1 -1
  67. package/dist/lib/utils.d.ts.map +1 -1
  68. package/dist/lib/utils.js +4 -1
  69. package/dist/lib/utils.js.map +1 -1
  70. package/dist/test/unit/bin/diff-changes.test.d.ts +2 -0
  71. package/dist/test/unit/bin/diff-changes.test.d.ts.map +1 -0
  72. package/dist/test/unit/bin/diff-changes.test.js +159 -0
  73. package/dist/test/unit/bin/diff-changes.test.js.map +1 -0
  74. package/dist/test/unit/bin/diff.test.d.ts +2 -0
  75. package/dist/test/unit/bin/diff.test.d.ts.map +1 -0
  76. package/dist/test/unit/bin/diff.test.js +143 -0
  77. package/dist/test/unit/bin/diff.test.js.map +1 -0
  78. package/dist/test/unit/bin/git.test.js +48 -19
  79. package/dist/test/unit/bin/git.test.js.map +1 -1
  80. package/dist/test/unit/custom-matchers.test.js +3 -8
  81. package/dist/test/unit/custom-matchers.test.js.map +1 -1
  82. package/dist/test/unit/parse-commit.test.js +1 -1
  83. package/dist/test/unit/parse-commit.test.js.map +1 -1
  84. package/dist/test/unit/should-built-and-test.test.js +159 -39
  85. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  86. package/dist/tsconfig.tsbuildinfo +1 -1
  87. package/eslint.config.mjs +4 -2
  88. package/knip.json +1 -1
  89. package/lib/extend-expect.ts +21 -21
  90. package/lib/lib.ts +17 -17
  91. package/lib/run-test-result.ts +6 -2
  92. package/lib/types.ts +5 -8
  93. package/lib/utils.ts +13 -9
  94. package/package.json +61 -54
  95. package/test/tsconfig.json +2 -4
  96. package/test/unit/bin/diff-changes.test.ts +182 -0
  97. package/test/unit/bin/diff.test.ts +212 -0
  98. package/test/unit/bin/git.test.ts +59 -20
  99. package/test/unit/custom-matchers.test.ts +10 -9
  100. package/test/unit/parse-commit.test.ts +2 -1
  101. package/test/unit/should-built-and-test.test.ts +181 -40
  102. package/bin/consts.ts +0 -6
  103. package/dist/bin/consts.d.ts +0 -4
  104. package/dist/bin/consts.d.ts.map +0 -1
  105. package/dist/bin/consts.js +0 -6
  106. package/dist/bin/consts.js.map +0 -1
  107. package/example-github-events/pull-request.json +0 -523
  108. package/example-github-events/push.json +0 -216
package/bin/build.ts CHANGED
@@ -1,25 +1,36 @@
1
+ import type { Build } from 'apify-client';
2
+ import { ApifyClient } from 'apify-client';
3
+
1
4
  import { ACTOR_SOURCE_TYPES } from '@apify/consts';
2
- import { ApifyClient, Build } from 'apify-client';
5
+
3
6
  import type { ActorConfig, BuildData } from './types.js';
4
7
  import { getEnvVar } from './utils.js';
5
8
 
6
9
  type BuildPrActorOptions = {
7
- buildTag?: string
8
- versionNumber: string
9
- gitRepoUrl: string
10
- actorName: string
11
- }
10
+ buildTag?: string;
11
+ versionNumber: string;
12
+ gitRepoUrl: string;
13
+ actorName: string;
14
+ };
12
15
  class ApifyBuilder {
13
- // eslint-disable-next-line no-empty-function
14
- private constructor(private readonly apifyClient: ApifyClient, private readonly actorName: string) { }
16
+ private constructor(
17
+ private readonly apifyClient: ApifyClient,
18
+ private readonly actorName: string,
19
+ ) {}
15
20
 
16
21
  // Usually 'latest' but not necessarily (can be e.g. 'version-0')
17
- getDefaultVersionAndTag = async (): Promise<{ defaultBuildNumber: string, defaultVersionNumber: string, defaultBuildTag: string }> => {
22
+ getDefaultVersionAndTag = async (): Promise<{
23
+ defaultBuildNumber: string;
24
+ defaultVersionNumber: string;
25
+ defaultBuildTag: string;
26
+ }> => {
18
27
  const actorClient = this.apifyClient.actor(this.actorName);
19
28
  const actorInfo = await actorClient.get();
20
29
 
21
30
  if (!actorInfo) {
22
- throw new Error(`[${this.actorName}] not found. It is not published or we are missing token to access it privately or its name is misspelled`);
31
+ throw new Error(
32
+ `[${this.actorName}] not found. It is not published or we are missing token to access it privately or its name is misspelled`,
33
+ );
23
34
  }
24
35
 
25
36
  const defaultBuildTag = actorInfo.defaultRunOptions.build;
@@ -27,8 +38,10 @@ class ApifyBuilder {
27
38
 
28
39
  // We could technically allow this but in most cases this is accidentally set wrongly and there is a workaround
29
40
  if (defaultBuildTag.match(/\d+\.\d+\.\d+/)) {
30
- throw new Error(`[${this.actorName}] Default build is a build number, not a tag. While this could work, `
31
- + `we want to have a default as tag so this is often an accidental misconfiguration from the dev`);
41
+ throw new Error(
42
+ `[${this.actorName}] Default build is a build number, not a tag. While this could work, ` +
43
+ `we want to have a default as tag so this is often an accidental misconfiguration from the dev`,
44
+ );
32
45
  }
33
46
  // I reported that buildNumber should probably not be optional
34
47
  const defaultBuildNumber = actorInfo.taggedBuilds![defaultBuildTag].buildNumber!;
@@ -38,17 +51,15 @@ class ApifyBuilder {
38
51
  return { defaultBuildNumber, defaultVersionNumber, defaultBuildTag };
39
52
  };
40
53
 
41
- startActorBuild = async ({
42
- buildTag,
43
- versionNumber,
44
- gitRepoUrl,
45
- }: BuildPrActorOptions): Promise<BuildData> => {
54
+ startActorBuild = async ({ buildTag, versionNumber, gitRepoUrl }: BuildPrActorOptions): Promise<BuildData> => {
46
55
  const actorClient = this.apifyClient.actor(this.actorName);
47
56
  const actorInfo = await actorClient.get();
48
57
  if (!actorInfo) {
49
- throw new Error(`No actor named '${this.actorName}' was found on the platform. If this`
50
- + ' is unexpected, make sure the actor you are targeting is spelled the'
51
- + ' same as the folder in the repository.');
58
+ throw new Error(
59
+ `No actor named '${this.actorName}' was found on the platform. If this` +
60
+ ' is unexpected, make sure the actor you are targeting is spelled the' +
61
+ ' same as the folder in the repository.',
62
+ );
52
63
  }
53
64
 
54
65
  // NOTE: I couldn't find this type, so I had to extract it :(
@@ -83,8 +94,9 @@ class ApifyBuilder {
83
94
  const build = await this.apifyClient.build(buildId).waitForFinish();
84
95
  const versionNumber = build.buildNumber;
85
96
  if (build.status === 'FAILED' || build.status === 'TIMED-OUT') {
86
- const message = `[BUILD][${actorName}]: Build ${buildId} (${versionNumber}) failed. `
87
- + `Not continuing with other builds and tests.`;
97
+ const message =
98
+ `[BUILD][${actorName}]: Build ${buildId} (${versionNumber}) failed. ` +
99
+ `Not continuing with other builds and tests.`;
88
100
  console.error(`[${this.actorName}]: ${versionNumber}`);
89
101
  throw new Error(message);
90
102
  }
@@ -93,8 +105,8 @@ class ApifyBuilder {
93
105
  };
94
106
 
95
107
  /**
96
- * Create ApifyBuilder with actor owner's token
97
- */
108
+ * Create ApifyBuilder with actor owner's token
109
+ */
98
110
  static fromActorName = (actorName: string): ApifyBuilder => {
99
111
  const username = actorName.split('/')[0];
100
112
  // GitHib secrets only allow word characters (alphanum + underscore)
@@ -102,8 +114,10 @@ class ApifyBuilder {
102
114
  const usernameEnvVar = `APIFY_TOKEN_${usernameInGitHubSecretsFormat}`;
103
115
  const token = process.env[usernameEnvVar];
104
116
  if (!token) {
105
- throw new Error(`Cannot find Apify API token for username: ${username}. `
106
- + `Have you set secret env var to this GitHub repo with key: ${usernameEnvVar}?`);
117
+ throw new Error(
118
+ `Cannot find Apify API token for username: ${username}. ` +
119
+ `Have you set secret env var to this GitHub repo with key: ${usernameEnvVar}?`,
120
+ );
107
121
  }
108
122
  const apifyClient = new ApifyClient({ token });
109
123
  const builder = new ApifyBuilder(apifyClient, actorName);
@@ -118,7 +132,21 @@ class ApifyBuilder {
118
132
  // Even though we don't version our current Actors, if we ever such Actors to GitHub CI, we would accidentally delete old supported versions
119
133
  // This hardcoded solution is not ideal, but it should prevent most imaginable cases
120
134
  // All currently popular versioned Actors use `version-${number}` format
121
- const PROTECTED_TAGS_PREFIX = ['latest', 'v-', 'version', 'v0', 'v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9'];
135
+ const PROTECTED_TAGS_PREFIX = [
136
+ 'latest',
137
+ 'v-',
138
+ 'version',
139
+ 'v0',
140
+ 'v1',
141
+ 'v2',
142
+ 'v3',
143
+ 'v4',
144
+ 'v5',
145
+ 'v6',
146
+ 'v7',
147
+ 'v8',
148
+ 'v9',
149
+ ];
122
150
 
123
151
  // We don't want to be too short because we might to debug something
124
152
  // but also not too long because it increases the risk of users using outdated versions
@@ -132,43 +160,58 @@ class ApifyBuilder {
132
160
 
133
161
  const allTags = Object.keys(actorInfo.taggedBuilds ?? {});
134
162
  const protectedTags = allTags.filter((tag) => PROTECTED_TAGS_PREFIX.some((prefix) => tag.startsWith(prefix)));
135
- const protectedBuildNumbers = protectedTags.map((tag) => ({ buildNumber: actorInfo.taggedBuilds![tag]!.buildNumber, tag }));
163
+ const protectedBuildNumbers = protectedTags.map((tag) => ({
164
+ buildNumber: actorInfo.taggedBuilds![tag]!.buildNumber,
165
+ tag,
166
+ }));
136
167
 
137
- const { items } = (await this.apifyClient.actor(this.actorName).builds().list());
168
+ const { items } = await this.apifyClient.actor(this.actorName).builds().list();
138
169
 
139
170
  // Deleting default build throws an error, so we skip it
140
- const { defaultBuildNumber, defaultBuildTag } = await ApifyBuilder.fromActorName(this.actorName).getDefaultVersionAndTag();
171
+ const { defaultBuildNumber, defaultBuildTag } = await ApifyBuilder.fromActorName(
172
+ this.actorName,
173
+ ).getDefaultVersionAndTag();
141
174
 
142
175
  const daysAgoUnixProd = Date.now() - DEFAULT_DAYS_BACK_PROD_VERSIONS * 24 * 60 * 60 * 1000;
143
176
  const daysAgoUnixDevel = Date.now() - DEFAULT_DAYS_BACK_DEVEL * 24 * 60 * 60 * 1000;
144
177
 
145
178
  // Fixing API client missing buildNumber field
146
- type CorrectBuildColletionItem = typeof items[0] & { buildNumber: string };
179
+ type CorrectBuildColletionItem = (typeof items)[0] & { buildNumber: string };
147
180
  const buildsToDelete = (items as CorrectBuildColletionItem[]).filter((build) => {
148
181
  if (build.buildNumber === defaultBuildNumber) {
149
- console.error(`[DELETE OLD BUILDS][${this.actorName}]: Skipping default build ${defaultBuildNumber} (${defaultBuildTag}). `
150
- + `We never delete default builds`);
182
+ console.error(
183
+ `[DELETE OLD BUILDS][${this.actorName}]: Skipping default build ${defaultBuildNumber} (${defaultBuildTag}). ` +
184
+ `We never delete default builds`,
185
+ );
151
186
  return false;
152
187
  }
153
188
 
154
- const protectedTagFound = protectedBuildNumbers.find((protectedBuildNumber) => protectedBuildNumber.buildNumber === build.buildNumber);
189
+ const protectedTagFound = protectedBuildNumbers.find(
190
+ (protectedBuildNumber) => protectedBuildNumber.buildNumber === build.buildNumber,
191
+ );
155
192
  if (protectedTagFound) {
156
- console.error(`[DELETE OLD BUILDS][${this.actorName}]: Skipping protected build ${protectedTagFound.buildNumber} (${protectedTagFound.tag}).`);
193
+ console.error(
194
+ `[DELETE OLD BUILDS][${this.actorName}]: Skipping protected build ${protectedTagFound.buildNumber} (${protectedTagFound.tag}).`,
195
+ );
157
196
  return false;
158
197
  }
159
198
 
160
199
  if (taggedDevelBuildNumber && build.buildNumber === taggedDevelBuildNumber) {
161
200
  const shouldDeleteDevelBuild = build.startedAt.getTime() < daysAgoUnixDevel;
162
201
  if (shouldDeleteDevelBuild) {
163
- console.error(`[DELETE OLD BUILDS][${this.actorName}]: Removing olf devel build ${taggedDevelBuildNumber}.`);
202
+ console.error(
203
+ `[DELETE OLD BUILDS][${this.actorName}]: Removing olf devel build ${taggedDevelBuildNumber}.`,
204
+ );
164
205
  }
165
206
  return shouldDeleteDevelBuild;
166
207
  }
167
208
  return build.startedAt.getTime() < daysAgoUnixProd;
168
209
  });
169
210
 
170
- console.error(`[DELETE OLD BUILDS][${this.actorName}]: Deleting ${buildsToDelete.length} old builds that are non-default and `
171
- + `older than 30 days from total ${items.length}`);
211
+ console.error(
212
+ `[DELETE OLD BUILDS][${this.actorName}]: Deleting ${buildsToDelete.length} old builds that are non-default and ` +
213
+ `older than 30 days from total ${items.length}`,
214
+ );
172
215
  for (const build of buildsToDelete) {
173
216
  await this.apifyClient.build(build.id).delete();
174
217
  }
@@ -176,20 +219,14 @@ class ApifyBuilder {
176
219
  }
177
220
 
178
221
  type RunBuildsOptions = {
179
- actorConfigs: ActorConfig[]
180
- isLatest?: boolean
181
- repoUrl: string
182
- branch: string
183
- dryRun: boolean
184
- }
222
+ actorConfigs: ActorConfig[];
223
+ isLatest?: boolean;
224
+ repoUrl: string;
225
+ branch: string;
226
+ dryRun: boolean;
227
+ };
185
228
 
186
- export const runBuilds = async ({
187
- repoUrl,
188
- branch,
189
- actorConfigs,
190
- isLatest = false,
191
- dryRun,
192
- }: RunBuildsOptions) => {
229
+ export const runBuilds = async ({ repoUrl, branch, actorConfigs, isLatest = false, dryRun }: RunBuildsOptions) => {
193
230
  const buildConfigs: BuildPrActorOptions[] = [];
194
231
 
195
232
  const circleActors = isLatest ? await findCircleApifyManaged(actorConfigs) : [];
@@ -199,7 +236,8 @@ export const runBuilds = async ({
199
236
  let buildTag: string | undefined;
200
237
 
201
238
  if (isLatest) {
202
- const { defaultVersionNumber, defaultBuildTag } = await ApifyBuilder.fromActorName(actorName).getDefaultVersionAndTag();
239
+ const { defaultVersionNumber, defaultBuildTag } =
240
+ await ApifyBuilder.fromActorName(actorName).getDefaultVersionAndTag();
203
241
  versionNumber = defaultVersionNumber;
204
242
  buildTag = defaultBuildTag;
205
243
  } else {
@@ -217,25 +255,29 @@ export const runBuilds = async ({
217
255
  if (dryRun) {
218
256
  return buildConfigs;
219
257
  }
220
- console.error("=========================================");
221
- console.error("STARTED BUILDS:");
222
- const startedBuilds = await Promise.all(buildConfigs.map(async (buildConfig) => {
223
- const builder = ApifyBuilder.fromActorName(buildConfig.actorName);
224
- const buildData = await builder.startActorBuild(buildConfig);
225
- return buildData;
226
- }));
227
- console.error("=========================================");
228
- console.error("FINISHED BUILDS:");
229
- await Promise.all(startedBuilds.map(async (buildData) => {
230
- const builder = ApifyBuilder.fromActorName(buildData.actorName);
231
- await builder.waitForBuildToFinish(buildData.buildId, buildData.actorName);
232
- }));
233
- console.error("=========================================");
234
- console.error("SUMMARY:");
258
+ console.error('=========================================');
259
+ console.error('STARTED BUILDS:');
260
+ const startedBuilds = await Promise.all(
261
+ buildConfigs.map(async (buildConfig) => {
262
+ const builder = ApifyBuilder.fromActorName(buildConfig.actorName);
263
+ const buildData = await builder.startActorBuild(buildConfig);
264
+ return buildData;
265
+ }),
266
+ );
267
+ console.error('=========================================');
268
+ console.error('FINISHED BUILDS:');
269
+ await Promise.all(
270
+ startedBuilds.map(async (buildData) => {
271
+ const builder = ApifyBuilder.fromActorName(buildData.actorName);
272
+ await builder.waitForBuildToFinish(buildData.buildId, buildData.actorName);
273
+ }),
274
+ );
275
+ console.error('=========================================');
276
+ console.error('SUMMARY:');
235
277
  for (const buildData of startedBuilds.sort((a, b) => a.actorName.localeCompare(b.actorName))) {
236
278
  console.error(`[${buildData.actorName}]: ${buildData.buildNumber} `);
237
279
  }
238
- console.error("=========================================");
280
+ console.error('=========================================');
239
281
 
240
282
  return startedBuilds;
241
283
  };
@@ -259,27 +301,36 @@ const findCircleApifyManaged = async (actorConfigs: ActorConfig[]): Promise<Acto
259
301
 
260
302
  const { items: circleActors } = await client.actors().list();
261
303
 
262
- const actorsToBuild = circleActors.map<ActorConfig | undefined>((circleActor) => {
263
- // They prefix all with apify-managed---, I communicated with Jacques to keep doing that
264
- let actorConfigFound = actorConfigs.find((actorConfig) => circleActor.name.replace('apify-managed---', '') === actorConfig.actorName.split('/')[1]);
265
-
266
- // Hack for bad naming of circ_le/apify-managed-google-search, we don't want to rename now to break customers
267
- if (!actorConfigFound && circleActor.name === 'apify-managed-google-search') {
268
- actorConfigFound = actorConfigs.find((actorConfig) => actorConfig.actorName.split('/')[1] === 'google-search-scraper');
269
- }
304
+ const actorsToBuild = circleActors
305
+ .map<ActorConfig | undefined>((circleActor) => {
306
+ // They prefix all with apify-managed---, I communicated with Jacques to keep doing that
307
+ let actorConfigFound = actorConfigs.find(
308
+ (actorConfig) =>
309
+ circleActor.name.replace('apify-managed---', '') === actorConfig.actorName.split('/')[1],
310
+ );
311
+
312
+ // Hack for bad naming of circ_le/apify-managed-google-search, we don't want to rename now to break customers
313
+ if (!actorConfigFound && circleActor.name === 'apify-managed-google-search') {
314
+ actorConfigFound = actorConfigs.find(
315
+ (actorConfig) => actorConfig.actorName.split('/')[1] === 'google-search-scraper',
316
+ );
317
+ }
270
318
 
271
- if (actorConfigFound) {
272
- return {
273
- // We point the circle Actor to the repo folder
274
- actorName: `${circleActor.username}/${circleActor.name}`,
275
- folder: actorConfigFound.folder,
276
- isStandalone: actorConfigFound.isStandalone,
277
- };
278
- }
279
- return undefined;
280
- }).filter((config) => config !== undefined);
319
+ if (actorConfigFound) {
320
+ return {
321
+ // We point the circle Actor to the repo folder
322
+ actorName: `${circleActor.username}/${circleActor.name}`,
323
+ folder: actorConfigFound.folder,
324
+ isStandalone: actorConfigFound.isStandalone,
325
+ };
326
+ }
327
+ return undefined;
328
+ })
329
+ .filter((config) => config !== undefined);
281
330
 
282
- console.error(`Found ${actorsToBuild.length} circ_le actors that match Actors we built out of total ${circleActors.length} circ_le actors`);
331
+ console.error(
332
+ `Found ${actorsToBuild.length} circ_le actors that match Actors we built out of total ${circleActors.length} circ_le actors`,
333
+ );
283
334
  console.error(`All circ_le actors: ${circleActors.map((actor) => actor.name).join(', ')}`);
284
335
  console.error(`circ_le Actors to build: ${actorsToBuild.map((actor) => actor.actorName).join(', ')}`);
285
336
 
@@ -0,0 +1,156 @@
1
+ import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
2
+ import type { ActorConfig, Commit } from './types.js';
3
+
4
+ interface ShouldBuildAndTestOptions {
5
+ filepathsChanged: string[];
6
+ actorConfigs: ActorConfig[];
7
+ isLatest?: boolean;
8
+ commits: Commit[];
9
+ }
10
+
11
+ export const maybeParseActorFolder = (
12
+ lowercaseFilePath: string,
13
+ ): { isActorFolder: true; actorName: string } | { isActorFolder: false } => {
14
+ const match = lowercaseFilePath.match(/^(?:standalone-)?actors\/([^/]+)\/.+/);
15
+ if (match) {
16
+ // Some usernames weirdly use underscores, e.g. google_maps_email_extractor_standby-contact-details-scraper so we only need replace the last one
17
+ return { isActorFolder: true, actorName: match[1].replace(/_(?=[^_]*$)/, '/') };
18
+ }
19
+ return { isActorFolder: false };
20
+ };
21
+
22
+ /**
23
+ * Also works for folders
24
+ */
25
+ const isIgnoredTopLevelFile = (lowercaseFilePath: string) => {
26
+ // On top level, we should only have dev-only readme and .actor/ is just for apify push CLI (real Actor configs are in /actors)
27
+ const IGNORED_TOP_LEVEL_FILES = [
28
+ '.vscode/',
29
+ '.gitignore',
30
+ 'readme.md',
31
+ '.husky/',
32
+ '.eslintrc',
33
+ 'eslint.config.mjs',
34
+ '.prettierrc',
35
+ '.editorconfig',
36
+ '.actor/',
37
+ ];
38
+ // Strip out deprecated /code and /shared folders, treat them as top-level code
39
+ const sanitizedLowercaseFilePath = lowercaseFilePath.replace(/^code\//, '').replace(/^shared\//, '');
40
+
41
+ return IGNORED_TOP_LEVEL_FILES.some((ignoredFile) => sanitizedLowercaseFilePath.startsWith(ignoredFile));
42
+ };
43
+
44
+ type FileChange =
45
+ | { impact: 'ignored' }
46
+ // Only things that influence how the Actor looks - e.g. README and CHANGELOG files, schema titles, descriptions, reordering, etc. We only need to rebuild on release
47
+ | { impact: 'cosmetic'; includes: 'all-actors' | ActorConfig }
48
+ // Influences how the Actor works - we need to run tests
49
+ | {
50
+ impact: 'functional';
51
+ includes: 'all-actors' | ActorConfig;
52
+ };
53
+
54
+ const classifyFileChange = (lowercaseFilePath: string, actorConfigs: ActorConfig[], commits: Commit[]): FileChange => {
55
+ if (isIgnoredTopLevelFile(lowercaseFilePath)) {
56
+ return { impact: 'ignored' };
57
+ }
58
+
59
+ if (lowercaseFilePath.endsWith('changelog.md')) {
60
+ return { impact: 'cosmetic', includes: 'all-actors' };
61
+ }
62
+
63
+ const actorFolderInfo = maybeParseActorFolder(lowercaseFilePath);
64
+ if (actorFolderInfo.isActorFolder) {
65
+ const actorConfigChanged = actorConfigs.find(
66
+ ({ actorName }) => actorName.toLowerCase() === actorFolderInfo.actorName,
67
+ );
68
+ // This is some super weird case that happened once in the past but I don't remember the context anymore
69
+ if (actorConfigChanged === undefined) {
70
+ console.error(
71
+ 'SHOULD NEVER HAPPEN: changes was found in an actor folder which no longer exists in the current commit, skipping this file',
72
+ {
73
+ actorName: actorFolderInfo.actorName,
74
+ lowercaseFilePath,
75
+ },
76
+ );
77
+ return { impact: 'ignored' };
78
+ }
79
+ if (lowercaseFilePath.endsWith('readme.md')) {
80
+ return { impact: 'cosmetic', includes: actorConfigChanged };
81
+ }
82
+ if (lowercaseFilePath.endsWith('.json') && isCosmeticOnlyJsonSchemaChange(commits, lowercaseFilePath)) {
83
+ return { impact: 'cosmetic', includes: actorConfigChanged };
84
+ }
85
+
86
+ return { impact: 'functional', includes: actorConfigChanged };
87
+ }
88
+
89
+ // For any other files, we assume they can interact with the code
90
+ return { impact: 'functional', includes: 'all-actors' };
91
+ };
92
+
93
+ export const getChangedActors = ({
94
+ filepathsChanged,
95
+ actorConfigs,
96
+ isLatest = false,
97
+ commits,
98
+ }: ShouldBuildAndTestOptions): ActorConfig[] => {
99
+ // folder -> ActorConfig
100
+ const actorsChangedMap = new Map<string, ActorConfig>();
101
+
102
+ const actorConfigsWithoutStandalone = actorConfigs.filter(({ isStandalone }) => !isStandalone);
103
+
104
+ const lowercaseFiles = filepathsChanged.map((file) => file.toLowerCase());
105
+
106
+ for (const lowercaseFilePath of lowercaseFiles) {
107
+ const fileChange = classifyFileChange(lowercaseFilePath, actorConfigs, commits);
108
+ if (fileChange.impact === 'ignored') {
109
+ continue;
110
+ }
111
+
112
+ if (fileChange.impact === 'cosmetic' && !isLatest) {
113
+ continue;
114
+ }
115
+
116
+ if (fileChange.includes !== 'all-actors') {
117
+ actorsChangedMap.set(fileChange.includes.folder, fileChange.includes);
118
+ } else if (fileChange.includes === 'all-actors') {
119
+ // Standalone Actors are handled always via specific actors change, not all-actors
120
+ for (const actorConfig of actorConfigsWithoutStandalone) {
121
+ actorsChangedMap.set(actorConfig.folder, actorConfig);
122
+ }
123
+ }
124
+ }
125
+
126
+ const actorsChanged = Array.from(actorsChangedMap.values());
127
+
128
+ // All below here is just for logging
129
+ const ignoredFilesChanged = lowercaseFiles.filter(
130
+ (file) => classifyFileChange(file, actorConfigs, commits).impact === 'ignored',
131
+ );
132
+ console.error(`[DIFF]: Ignored files (don't trigger test or build): ${ignoredFilesChanged.join(', ')}`);
133
+
134
+ const cosmeticFilesChanged = lowercaseFiles.filter(
135
+ (file) => classifyFileChange(file, actorConfigs, commits).impact === 'cosmetic',
136
+ );
137
+ console.error(`[DIFF]: Cosmetic files (should only trigger release build): ${cosmeticFilesChanged.join(', ')}`);
138
+
139
+ const functionalFilesChanged = lowercaseFiles.filter(
140
+ (file) => classifyFileChange(file, actorConfigs, commits).impact === 'functional',
141
+ );
142
+ console.error(`[DIFF]: Functional files (trigger test & release build): ${functionalFilesChanged.join(', ')}`);
143
+
144
+ if (actorsChanged.length > 0) {
145
+ const miniactors = actorsChanged.filter((config) => !config.isStandalone).map((config) => config.actorName);
146
+ const standaloneActors = actorsChanged
147
+ .filter((config) => config.isStandalone)
148
+ .map((config) => config.actorName);
149
+ console.error(`[DIFF]: MiniActors to be built and tested: ${miniactors.join(', ')}`);
150
+ console.error(`[DIFF]: Standalone Actors to be built and tested: ${standaloneActors.join(', ')}`);
151
+ } else {
152
+ console.error(`[DIFF]: No relevant files changed, skipping builds and tests`);
153
+ }
154
+
155
+ return actorsChanged;
156
+ };
@@ -0,0 +1,50 @@
1
+ import type { Commit } from './types.js';
2
+ import { spawnCommandInGhWorkspace } from './utils.js';
3
+
4
+ const COSMETIC_JSON_FIELD_NAMES = new Set([
5
+ 'title',
6
+ 'description',
7
+ 'example',
8
+ 'enumTitles',
9
+ 'sectionCaption',
10
+ 'sectionDescription',
11
+ ]);
12
+
13
+ const isPlainObject = (val: unknown): val is Record<string, unknown> =>
14
+ typeof val === 'object' && val !== null && !Array.isArray(val);
15
+
16
+ const isCosmeticObjectChange = (oldVal: unknown, newVal: unknown, currentKey?: string): boolean => {
17
+ // If the key itself is cosmetic, any change under it is fine
18
+ if (currentKey && COSMETIC_JSON_FIELD_NAMES.has(currentKey)) return true;
19
+ if (JSON.stringify(oldVal) === JSON.stringify(newVal)) return true;
20
+ if (isPlainObject(oldVal) && isPlainObject(newVal)) {
21
+ const allKeys = new Set([...Object.keys(oldVal), ...Object.keys(newVal)]);
22
+ return [...allKeys].every((key) => isCosmeticObjectChange(oldVal[key], newVal[key], key));
23
+ }
24
+ return false;
25
+ };
26
+
27
+ /**
28
+ * Returns true if the two JSON strings differ only in cosmetic fields
29
+ * (title, description, example, enumTitles, sectionCaption, sectionDescription).
30
+ */
31
+ export const isCosmeticOnlyJsonSchemaChange = (commits: Commit[], changedFilepath: string): boolean => {
32
+ // TODO: validate this is the right commit range
33
+ const oldRef = `${commits[0].sha}~`;
34
+ const newRef = commits[commits.length - 1].sha;
35
+ let oldJson: unknown;
36
+ let newJson: unknown;
37
+ try {
38
+ const oldContent = spawnCommandInGhWorkspace(`git show ${oldRef}:${changedFilepath}`);
39
+ const newContent = spawnCommandInGhWorkspace(`git show ${newRef}:${changedFilepath}`);
40
+
41
+ oldJson = JSON.parse(oldContent);
42
+ newJson = JSON.parse(newContent);
43
+ } catch {
44
+ console.error(
45
+ `Failed to get or parse JSON content for ${changedFilepath} at refs ${oldRef} and ${newRef}, maybe it is new file or deleted? Treating it as a non-cosmetic change.`,
46
+ );
47
+ return false;
48
+ }
49
+ return isCosmeticObjectChange(oldJson, newJson);
50
+ };
package/bin/git.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Commit, Config } from './types.js';
1
+ import type { Commit, Config } from './types.js';
2
2
  import { spawnCommandInGhWorkspace } from './utils.js';
3
3
 
4
4
  export const GIT_FORMAT_SEPARATOR = '»¦«';
@@ -8,18 +8,44 @@ const GIT_LOG_FORMAT = ['%H', '%aN<%aE>', '%aD', '%s'].join(GIT_FORMAT_SEPARATOR
8
8
  * Gets the list of changed files between the given commits (inclusive).
9
9
  */
10
10
  export const getChangedFiles = (commits: Commit[]) => {
11
- const changedFiles = spawnCommandInGhWorkspace(
11
+ const changedFilesString = spawnCommandInGhWorkspace(
12
12
  `git diff --name-only ${commits[0].sha}~..${commits[commits.length - 1].sha}`,
13
13
  );
14
14
 
15
- return changedFiles.split('\n');
15
+ const changedFiles = changedFilesString.split('\n');
16
+ console.error(`Changed files (up to 50): ${changedFiles.slice(0, 50).join(', ')}`);
17
+ return changedFiles;
18
+ };
19
+
20
+ const SHA_REGEX = /^[0-9a-f]{40}$/i;
21
+
22
+ /**
23
+ *
24
+ * @param shaOrCommit Supports both a SHA string or a Commit object in JSON format. Can be empty.
25
+ * @returns The SHA string if valid, otherwise throws an error.
26
+ */
27
+ export const parseBaseCommit = (shaOrCommit: string | undefined): string | undefined => {
28
+ if (!shaOrCommit) return undefined;
29
+ let sha: string;
30
+ if (shaOrCommit.startsWith('{')) {
31
+ sha = (JSON.parse(shaOrCommit) as Commit).sha;
32
+ } else {
33
+ sha = shaOrCommit;
34
+ }
35
+ if (!SHA_REGEX.test(sha)) {
36
+ throw new Error(
37
+ `Invalid base commit SHA: "${sha}". It should be a 40-character hexadecimal string, instead got input: "${shaOrCommit}".`,
38
+ );
39
+ }
40
+ return sha;
16
41
  };
17
42
 
18
43
  /**
19
44
  * Gets the commits between sourceBranch and targetBranch (exclusive).
20
45
  * - If baseCommit is provided, only returns commits after the baseCommit.
21
46
  */
22
- export const getCommits = ({ sourceBranch, targetBranch, baseCommit: baseCommitSha }: Config): Commit[] => {
47
+ export const getCommits = ({ sourceBranch, targetBranch, baseCommit }: Config): Commit[] => {
48
+ const baseCommitSha = parseBaseCommit(baseCommit);
23
49
  const commitsStrings = spawnCommandInGhWorkspace(
24
50
  `git log --pretty=format:'${GIT_LOG_FORMAT}' ${targetBranch}..${sourceBranch}`,
25
51
  ).split('\n');
@@ -29,17 +55,22 @@ export const getCommits = ({ sourceBranch, targetBranch, baseCommit: baseCommitS
29
55
  const baseCommitIndex = commits.findIndex((commit) => commit.sha === baseCommitSha);
30
56
 
31
57
  const hasBaseCommit = baseCommitIndex !== -1;
32
- if (hasBaseCommit) return commits.slice(baseCommitIndex + 1);
58
+ if (hasBaseCommit) {
59
+ const commitsUpToBaseCommit = commits.slice(baseCommitIndex + 1);
60
+ console.error(
61
+ `Found base commit ${baseCommitSha} at index ${baseCommitIndex}, returning ${commitsUpToBaseCommit.length} commits after it`,
62
+ );
63
+ console.error(`Commits being returned: ${commitsUpToBaseCommit.map((c) => c.sha).join(', ')}`);
64
+ return commitsUpToBaseCommit;
65
+ }
33
66
 
67
+ console.error(
68
+ `Base commit ${baseCommitSha} not found in the commit range, returning all ${commits.length} commits`,
69
+ );
70
+ console.error(`Commits being returned: ${commits.map((c) => c.sha).join(', ')}`);
34
71
  return commits;
35
72
  };
36
73
 
37
- export const getCommitInfo = (commitSha: string): Commit => {
38
- const commitString = spawnCommandInGhWorkspace(`git log -1 --pretty=format:'${GIT_LOG_FORMAT}' ${commitSha}`);
39
- const commit = parseCommit(commitString);
40
- return commit;
41
- };
42
-
43
74
  export const parseCommit = (commitString: string): Commit => {
44
75
  const splits = commitString.split(GIT_FORMAT_SEPARATOR);
45
76
  if (splits.length !== 4) {