apify-test-tools 0.8.6 → 0.9.0-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 (79) hide show
  1. package/README.md +83 -13
  2. package/bin/build-from-local.ts +35 -34
  3. package/bin/build.ts +55 -59
  4. package/bin/diff-changes.ts +166 -125
  5. package/bin/dockerignore.ts +50 -0
  6. package/bin/main.ts +7 -7
  7. package/bin/path-utils.ts +13 -0
  8. package/bin/test-report.ts +5 -5
  9. package/bin/types.ts +17 -5
  10. package/bin/utils.ts +124 -95
  11. package/dist/bin/build-from-local.d.ts +2 -2
  12. package/dist/bin/build-from-local.d.ts.map +1 -1
  13. package/dist/bin/build-from-local.js +33 -28
  14. package/dist/bin/build-from-local.js.map +1 -1
  15. package/dist/bin/build.d.ts +5 -8
  16. package/dist/bin/build.d.ts.map +1 -1
  17. package/dist/bin/build.js +49 -56
  18. package/dist/bin/build.js.map +1 -1
  19. package/dist/bin/diff-changes.d.ts +0 -6
  20. package/dist/bin/diff-changes.d.ts.map +1 -1
  21. package/dist/bin/diff-changes.js +129 -89
  22. package/dist/bin/diff-changes.js.map +1 -1
  23. package/dist/bin/dockerignore.d.ts +17 -0
  24. package/dist/bin/dockerignore.d.ts.map +1 -0
  25. package/dist/bin/dockerignore.js +41 -0
  26. package/dist/bin/dockerignore.js.map +1 -0
  27. package/dist/bin/main.js +7 -7
  28. package/dist/bin/main.js.map +1 -1
  29. package/dist/bin/path-utils.d.ts +4 -0
  30. package/dist/bin/path-utils.d.ts.map +1 -0
  31. package/dist/bin/path-utils.js +9 -0
  32. package/dist/bin/path-utils.js.map +1 -0
  33. package/dist/bin/test-report.js +3 -3
  34. package/dist/bin/test-report.js.map +1 -1
  35. package/dist/bin/types.d.ts +15 -4
  36. package/dist/bin/types.d.ts.map +1 -1
  37. package/dist/bin/utils.d.ts +2 -19
  38. package/dist/bin/utils.d.ts.map +1 -1
  39. package/dist/bin/utils.js +82 -81
  40. package/dist/bin/utils.js.map +1 -1
  41. package/dist/lib/lib.d.ts +10 -4
  42. package/dist/lib/lib.d.ts.map +1 -1
  43. package/dist/lib/lib.js +28 -22
  44. package/dist/lib/lib.js.map +1 -1
  45. package/dist/lib/types.d.ts +2 -2
  46. package/dist/lib/types.d.ts.map +1 -1
  47. package/dist/lib/utils.d.ts +1 -1
  48. package/dist/lib/utils.d.ts.map +1 -1
  49. package/dist/lib/utils.js +4 -4
  50. package/dist/lib/utils.js.map +1 -1
  51. package/dist/test/unit/bin/build-from-local.test.js +55 -86
  52. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  53. package/dist/test/unit/bin/diff-changes.test.js +358 -36
  54. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  55. package/dist/test/unit/bin/dockerignore.test.d.ts +2 -0
  56. package/dist/test/unit/bin/dockerignore.test.d.ts.map +1 -0
  57. package/dist/test/unit/bin/dockerignore.test.js +102 -0
  58. package/dist/test/unit/bin/dockerignore.test.js.map +1 -0
  59. package/dist/test/unit/bin/path-utils.test.d.ts +2 -0
  60. package/dist/test/unit/bin/path-utils.test.d.ts.map +1 -0
  61. package/dist/test/unit/bin/path-utils.test.js +14 -0
  62. package/dist/test/unit/bin/path-utils.test.js.map +1 -0
  63. package/dist/test/unit/bin/utils.test.d.ts +2 -0
  64. package/dist/test/unit/bin/utils.test.d.ts.map +1 -0
  65. package/dist/test/unit/bin/utils.test.js +292 -0
  66. package/dist/test/unit/bin/utils.test.js.map +1 -0
  67. package/dist/test/unit/should-built-and-test.test.js +83 -32
  68. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  69. package/dist/tsconfig.tsbuildinfo +1 -1
  70. package/lib/lib.ts +28 -22
  71. package/lib/types.ts +2 -2
  72. package/lib/utils.ts +4 -4
  73. package/package.json +62 -61
  74. package/test/unit/bin/build-from-local.test.ts +62 -121
  75. package/test/unit/bin/diff-changes.test.ts +397 -41
  76. package/test/unit/bin/dockerignore.test.ts +123 -0
  77. package/test/unit/bin/path-utils.test.ts +17 -0
  78. package/test/unit/bin/utils.test.ts +342 -0
  79. package/test/unit/should-built-and-test.test.ts +89 -32
@@ -1,47 +1,35 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
- import { getChangedActors, maybeParseActorFolder } from '../../../bin/diff-changes.js';
3
+ import { getChangedActors } from '../../../bin/diff-changes.js';
4
4
  import * as DiffJsonSchema from '../../../bin/diff-json-schema.js';
5
+ import * as Dockerignore from '../../../bin/dockerignore.js';
5
6
  import type { ActorConfig } from '../../../bin/types.js';
6
7
 
7
- const miniActor: ActorConfig = { actorName: 'foo/bar', folder: 'actors/foo_bar', isStandalone: false };
8
+ const miniActor: ActorConfig = {
9
+ actorFullName: 'foo/bar',
10
+ folder: 'actors/foo_bar',
11
+ tokenEnvVar: 'APIFY_TOKEN_FOO',
12
+ dockerContextDir: '',
13
+ contextPaths: [''],
14
+ };
8
15
  const standaloneActor: ActorConfig = {
9
- actorName: 'standalone',
16
+ actorFullName: 'owner/standalone',
10
17
  folder: 'standalone-actors/standalone',
11
- isStandalone: true,
18
+ tokenEnvVar: 'APIFY_TOKEN_OWNER',
19
+ dockerContextDir: 'standalone-actors/standalone',
20
+ contextPaths: ['standalone-actors/standalone'],
12
21
  };
13
22
  const actorConfigs = [miniActor, standaloneActor];
23
+ const amazonActor: ActorConfig = {
24
+ actorFullName: 'junglee/amazon-crawler',
25
+ folder: 'actors/junglee_Amazon-crawler',
26
+ tokenEnvVar: 'APIFY_TOKEN_JUNGLEE',
27
+ dockerContextDir: '',
28
+ contextPaths: ['actors/junglee_Amazon-crawler', 'code', 'shared'],
29
+ };
14
30
 
15
31
  const commits = [{ sha: 'Commit1', author: '', date: '', message: '' }];
16
32
 
17
- describe('maybeParseActorFolder', () => {
18
- it('returns actorName for actors/ path', () => {
19
- expect(maybeParseActorFolder('actors/foo_bar/actor.json')).toEqual({
20
- isActorFolder: true,
21
- actorName: 'foo/bar',
22
- });
23
- });
24
-
25
- it('returns actorName for standalone-actors/ path', () => {
26
- expect(maybeParseActorFolder('standalone-actors/my_actor/main.ts')).toEqual({
27
- isActorFolder: true,
28
- actorName: 'my/actor',
29
- });
30
- });
31
-
32
- it('returns false for top-level file', () => {
33
- expect(maybeParseActorFolder('package.json')).toEqual({ isActorFolder: false });
34
- });
35
-
36
- it('returns false for path with no file inside actor folder', () => {
37
- expect(maybeParseActorFolder('actors/foo_bar')).toEqual({ isActorFolder: false });
38
- });
39
-
40
- it('returns false for unrelated folder', () => {
41
- expect(maybeParseActorFolder('src/utils.ts')).toEqual({ isActorFolder: false });
42
- });
43
- });
44
-
45
33
  describe('getChangedActors', () => {
46
34
  beforeEach(() => {
47
35
  vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(false);
@@ -92,7 +80,7 @@ describe('getChangedActors', () => {
92
80
  it('returns actor when isLatest and JSON file has only cosmetic changes', () => {
93
81
  vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(true);
94
82
  const result = getChangedActors({
95
- filepathsChanged: ['actors/foo_bar/actor.json'],
83
+ filepathsChanged: ['actors/foo_bar/.actor/actor.json'],
96
84
  actorConfigs,
97
85
  commits,
98
86
  isLatest: true,
@@ -103,7 +91,7 @@ describe('getChangedActors', () => {
103
91
  it('does not return actor when not isLatest and JSON file has only cosmetic changes', () => {
104
92
  vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(true);
105
93
  const result = getChangedActors({
106
- filepathsChanged: ['actors/foo_bar/actor.json'],
94
+ filepathsChanged: ['actors/foo_bar/.actor/actor.json'],
107
95
  actorConfigs,
108
96
  commits,
109
97
  isLatest: false,
@@ -114,14 +102,37 @@ describe('getChangedActors', () => {
114
102
  it('returns actor when JSON file has functional changes', () => {
115
103
  vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(false);
116
104
  const result = getChangedActors({
117
- filepathsChanged: ['actors/foo_bar/actor.json'],
105
+ filepathsChanged: ['actors/foo_bar/.actor/actor.json'],
106
+ actorConfigs,
107
+ commits,
108
+ });
109
+ expect(result).toEqual([miniActor]);
110
+ });
111
+
112
+ it('JSON file in actor folder but outside .actor/ is functional, not checked for cosmetic', () => {
113
+ vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(true);
114
+ const result = getChangedActors({
115
+ filepathsChanged: ['actors/foo_bar/package.json'],
118
116
  actorConfigs,
119
117
  commits,
118
+ isLatest: false,
119
+ });
120
+ expect(result).toEqual([miniActor]);
121
+ expect(DiffJsonSchema.isCosmeticOnlyJsonSchemaChange).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it('JSON file under .actor/ inside actor folder is checked for cosmetic changes', () => {
125
+ vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(true);
126
+ const result = getChangedActors({
127
+ filepathsChanged: ['actors/foo_bar/.actor/input_schema.json'],
128
+ actorConfigs,
129
+ commits,
130
+ isLatest: true,
120
131
  });
121
132
  expect(result).toEqual([miniActor]);
122
133
  });
123
134
 
124
- it('returns all non-standalone actors when a non-actor-folder functional file changes', () => {
135
+ it('does not trigger narrow-context actor when shared file changes', () => {
125
136
  const result = getChangedActors({
126
137
  filepathsChanged: ['shared/utils.ts'],
127
138
  actorConfigs,
@@ -131,18 +142,28 @@ describe('getChangedActors', () => {
131
142
  expect(result).not.toContainEqual(standaloneActor);
132
143
  });
133
144
 
134
- it('does not include standalone actor in all-actors expansion from changelog', () => {
145
+ it('root-level changelog outside any actor folder is cosmetic for every actor when isLatest', () => {
135
146
  const result = getChangedActors({
136
147
  filepathsChanged: ['CHANGELOG.md'],
137
148
  actorConfigs,
138
149
  commits,
139
150
  isLatest: true,
140
151
  });
141
- expect(result).toContainEqual(miniActor);
142
- expect(result).not.toContainEqual(standaloneActor);
152
+ expect(result).toEqual(expect.arrayContaining([miniActor, standaloneActor]));
153
+ expect(result).toHaveLength(2);
143
154
  });
144
155
 
145
- it('includes standalone actor when its own folder changes', () => {
156
+ it('root-level changelog is not cosmetic-triggered when not isLatest', () => {
157
+ const result = getChangedActors({
158
+ filepathsChanged: ['CHANGELOG.md'],
159
+ actorConfigs,
160
+ commits,
161
+ isLatest: false,
162
+ });
163
+ expect(result).toEqual([]);
164
+ });
165
+
166
+ it('triggers narrow-context actor when its own folder changes', () => {
146
167
  const result = getChangedActors({
147
168
  filepathsChanged: ['standalone-actors/standalone/src/main.ts'],
148
169
  actorConfigs,
@@ -161,7 +182,7 @@ describe('getChangedActors', () => {
161
182
  expect(result).toContainEqual(miniActor);
162
183
  });
163
184
 
164
- it('handles mixed changes: returns both mini and standalone actors', () => {
185
+ it('handles mixed changes: returns both broad and narrow-context actors', () => {
165
186
  const result = getChangedActors({
166
187
  filepathsChanged: ['actors/foo_bar/src/main.ts', 'standalone-actors/standalone/Dockerfile'],
167
188
  actorConfigs,
@@ -171,6 +192,47 @@ describe('getChangedActors', () => {
171
192
  expect(result).toContainEqual(standaloneActor);
172
193
  });
173
194
 
195
+ it('matches folder where folder name differs from actor name', () => {
196
+ const ownerlessActor: ActorConfig = {
197
+ actorFullName: 'myteam/shopify-scraper',
198
+ folder: 'actors/shopify',
199
+ tokenEnvVar: 'APIFY_TOKEN_MYTEAM',
200
+ dockerContextDir: '',
201
+ contextPaths: [''],
202
+ };
203
+ const result = getChangedActors({
204
+ filepathsChanged: ['actors/shopify/src/main.ts'],
205
+ actorConfigs: [ownerlessActor],
206
+ commits,
207
+ });
208
+ expect(result).toEqual([ownerlessActor]);
209
+ });
210
+
211
+ it('in single-actor repo, .actor/ changes trigger builds', () => {
212
+ const rootActor: ActorConfig = {
213
+ actorFullName: 'myteam/my-actor',
214
+ folder: '',
215
+ tokenEnvVar: 'BUILDER_APIFY_TOKEN',
216
+ dockerContextDir: '',
217
+ contextPaths: [''],
218
+ };
219
+ const result = getChangedActors({
220
+ filepathsChanged: ['.actor/actor.json'],
221
+ actorConfigs: [rootActor],
222
+ commits,
223
+ });
224
+ expect(result).toEqual([rootActor]);
225
+ });
226
+
227
+ it('in multi-actor repo, .actor/ changes only trigger broad-context actors', () => {
228
+ const result = getChangedActors({
229
+ filepathsChanged: ['.actor/actor.json'],
230
+ actorConfigs,
231
+ commits,
232
+ });
233
+ expect(result).toEqual([miniActor]);
234
+ });
235
+
174
236
  it('file paths are matched case-insensitively', () => {
175
237
  const result = getChangedActors({
176
238
  filepathsChanged: ['Actors/FOO_BAR/Main.ts'],
@@ -179,4 +241,298 @@ describe('getChangedActors', () => {
179
241
  });
180
242
  expect(result).toEqual([miniActor]);
181
243
  });
244
+
245
+ it('triggers actor with contextPaths override when file matches an override path', () => {
246
+ const overrideActor: ActorConfig = {
247
+ actorFullName: 'team/override-actor',
248
+ folder: 'actors/override',
249
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
250
+ dockerContextDir: 'actors/override',
251
+ contextPaths: ['actors/override', 'packages'],
252
+ };
253
+ const result = getChangedActors({
254
+ filepathsChanged: ['packages/shared/utils.ts'],
255
+ actorConfigs: [overrideActor],
256
+ commits,
257
+ });
258
+ expect(result).toEqual([overrideActor]);
259
+ });
260
+
261
+ it('does not trigger actor with contextPaths override when file is outside all override paths', () => {
262
+ const overrideActor: ActorConfig = {
263
+ actorFullName: 'team/override-actor',
264
+ folder: 'actors/override',
265
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
266
+ dockerContextDir: 'actors/override',
267
+ contextPaths: ['actors/override', 'packages'],
268
+ };
269
+ const result = getChangedActors({
270
+ filepathsChanged: ['other-dir/file.ts'],
271
+ actorConfigs: [overrideActor],
272
+ commits,
273
+ });
274
+ expect(result).toEqual([]);
275
+ });
276
+
277
+ it('broad-context actor skips files in sibling actor folders', () => {
278
+ const actorA: ActorConfig = {
279
+ actorFullName: 'team/actor-a',
280
+ folder: 'actors/a',
281
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
282
+ dockerContextDir: '',
283
+ contextPaths: [''],
284
+ };
285
+ const actorB: ActorConfig = {
286
+ actorFullName: 'team/actor-b',
287
+ folder: 'actors/b',
288
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
289
+ dockerContextDir: '',
290
+ contextPaths: [''],
291
+ };
292
+ const result = getChangedActors({
293
+ filepathsChanged: ['actors/b/src/main.ts'],
294
+ actorConfigs: [actorA, actorB],
295
+ commits,
296
+ });
297
+ expect(result).toEqual([actorB]);
298
+ });
299
+
300
+ it('root actor (folder="") is excluded from sibling actor folder files', () => {
301
+ const rootActor: ActorConfig = {
302
+ actorFullName: 'team/root',
303
+ folder: '',
304
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
305
+ dockerContextDir: '',
306
+ contextPaths: [''],
307
+ };
308
+ const childActor: ActorConfig = {
309
+ actorFullName: 'team/child',
310
+ folder: 'actors/child',
311
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
312
+ dockerContextDir: 'actors/child',
313
+ contextPaths: ['actors/child'],
314
+ };
315
+ const result = getChangedActors({
316
+ filepathsChanged: ['actors/child/src/main.ts'],
317
+ actorConfigs: [rootActor, childActor],
318
+ commits,
319
+ });
320
+ expect(result).not.toContainEqual(rootActor);
321
+ expect(result).toContainEqual(childActor);
322
+ });
323
+
324
+ it('root actor (folder="") sees files outside any actor folder', () => {
325
+ const rootActor: ActorConfig = {
326
+ actorFullName: 'team/root',
327
+ folder: '',
328
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
329
+ dockerContextDir: '',
330
+ contextPaths: [''],
331
+ };
332
+ const childActor: ActorConfig = {
333
+ actorFullName: 'team/child',
334
+ folder: 'actors/child',
335
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
336
+ dockerContextDir: 'actors/child',
337
+ contextPaths: ['actors/child'],
338
+ };
339
+ const result = getChangedActors({
340
+ filepathsChanged: ['lib/shared-utils.ts'],
341
+ actorConfigs: [rootActor, childActor],
342
+ commits,
343
+ });
344
+ expect(result).toContainEqual(rootActor);
345
+ expect(result).not.toContainEqual(childActor);
346
+ });
347
+
348
+ it('file matched by .dockerignore is treated as ignored', () => {
349
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue(
350
+ (filePath) => filePath === 'actors/foo_bar/node_modules/foo.js',
351
+ );
352
+ const result = getChangedActors({
353
+ filepathsChanged: ['actors/foo_bar/node_modules/foo.js'],
354
+ actorConfigs: [miniActor],
355
+ commits,
356
+ });
357
+ expect(result).toEqual([]);
358
+ });
359
+
360
+ it('file not matched by .dockerignore is classified normally', () => {
361
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue((filePath) => filePath.includes('node_modules'));
362
+ const result = getChangedActors({
363
+ filepathsChanged: ['actors/foo_bar/src/main.ts'],
364
+ actorConfigs: [miniActor],
365
+ commits,
366
+ });
367
+ expect(result).toEqual([miniActor]);
368
+ });
369
+
370
+ it('JSON file in context but outside actor folder is functional (not checked for cosmetic)', () => {
371
+ vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(true);
372
+ const result = getChangedActors({
373
+ filepathsChanged: ['lib/config.json'],
374
+ actorConfigs: [miniActor],
375
+ commits,
376
+ });
377
+ expect(result).toEqual([miniActor]);
378
+ });
379
+
380
+ it('README outside actor folder but inside context is ignored (not cosmetic)', () => {
381
+ const result = getChangedActors({
382
+ filepathsChanged: ['docs/README.md'],
383
+ actorConfigs: [miniActor],
384
+ commits,
385
+ isLatest: true,
386
+ });
387
+ expect(result).toEqual([]);
388
+ });
389
+
390
+ it('README inside actor folder is cosmetic', () => {
391
+ const result = getChangedActors({
392
+ filepathsChanged: ['actors/foo_bar/README.md'],
393
+ actorConfigs: [miniActor],
394
+ commits,
395
+ isLatest: true,
396
+ });
397
+ expect(result).toEqual([miniActor]);
398
+ });
399
+
400
+ it('hoists a standalone actor own top-level dev file relative to its context before checking the ignore list', () => {
401
+ const result = getChangedActors({
402
+ filepathsChanged: ['standalone-actors/standalone/.eslintrc'],
403
+ actorConfigs,
404
+ commits,
405
+ });
406
+ expect(result).toEqual([]);
407
+ });
408
+
409
+ it('does not special-case code/ and shared/ prefixes anymore — must be declared via overrideActorContext', () => {
410
+ const result = getChangedActors({
411
+ filepathsChanged: ['code/.eslintrc'],
412
+ actorConfigs: [miniActor],
413
+ commits,
414
+ });
415
+ expect(result).toEqual([miniActor]);
416
+ });
417
+
418
+ it('ignores code/.eslintrc when "code" is declared via overrideActorContext', () => {
419
+ const result = getChangedActors({
420
+ filepathsChanged: ['code/.eslintrc'],
421
+ actorConfigs: [amazonActor],
422
+ commits,
423
+ });
424
+ expect(result).toEqual([]);
425
+ });
426
+
427
+ it('shared/Dockerfile declared via overrideActorContext is functional', () => {
428
+ const result = getChangedActors({
429
+ filepathsChanged: ['shared/Dockerfile'],
430
+ actorConfigs: [amazonActor],
431
+ commits,
432
+ });
433
+ expect(result).toEqual([amazonActor]);
434
+ });
435
+
436
+ it('code/README.md declared via overrideActorContext is ignored (outside actor folder)', () => {
437
+ const result = getChangedActors({
438
+ filepathsChanged: ['code/README.md'],
439
+ actorConfigs: [amazonActor],
440
+ commits,
441
+ isLatest: true,
442
+ });
443
+ expect(result).toEqual([]);
444
+ });
445
+ });
446
+
447
+ describe('getChangedActors logging', () => {
448
+ beforeEach(() => {
449
+ vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockReturnValue(false);
450
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
451
+ });
452
+
453
+ it('logs a single "specific" group for a single actor with one functional file', () => {
454
+ getChangedActors({
455
+ filepathsChanged: ['actors/foo_bar/src/main.ts'],
456
+ actorConfigs: [miniActor],
457
+ commits,
458
+ });
459
+
460
+ expect(console.error).toHaveBeenCalledWith(
461
+ '[DIFF]: Changes specific to actor foo/bar: actors/foo_bar/src/main.ts',
462
+ );
463
+ expect(console.error).toHaveBeenCalledWith('[DIFF]: Actors to be built and tested: foo/bar');
464
+ });
465
+
466
+ it('logs a single "shared" group when two actors are triggered by the exact same file', () => {
467
+ const actorA: ActorConfig = {
468
+ actorFullName: 'team/actor-a',
469
+ folder: 'actors/a',
470
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
471
+ dockerContextDir: '',
472
+ contextPaths: ['', 'shared'],
473
+ };
474
+ const actorB: ActorConfig = {
475
+ actorFullName: 'team/actor-b',
476
+ folder: 'actors/b',
477
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
478
+ dockerContextDir: '',
479
+ contextPaths: ['', 'shared'],
480
+ };
481
+
482
+ getChangedActors({
483
+ filepathsChanged: ['shared/shared.ts'],
484
+ actorConfigs: [actorA, actorB],
485
+ commits,
486
+ });
487
+
488
+ expect(console.error).toHaveBeenCalledWith(
489
+ '[DIFF]: Shared changes for actors team/actor-a, team/actor-b: shared/shared.ts',
490
+ );
491
+ expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining('Changes specific to actor'));
492
+ expect(console.error).toHaveBeenCalledWith('[DIFF]: Actors to be built and tested: team/actor-a, team/actor-b');
493
+ });
494
+
495
+ it('logs shared and specific groups in descending-size order for partial overlap across actors', () => {
496
+ const actorA: ActorConfig = {
497
+ actorFullName: 'team/actor-a',
498
+ folder: 'actors/a',
499
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
500
+ dockerContextDir: '',
501
+ contextPaths: [''],
502
+ };
503
+ const actorB: ActorConfig = {
504
+ actorFullName: 'team/actor-b',
505
+ folder: 'actors/b',
506
+ tokenEnvVar: 'APIFY_TOKEN_TEAM',
507
+ dockerContextDir: '',
508
+ contextPaths: [''],
509
+ };
510
+
511
+ getChangedActors({
512
+ filepathsChanged: ['shared.ts', 'actors/a/a-only.ts', 'actors/b/b-only.ts'],
513
+ actorConfigs: [actorA, actorB],
514
+ commits,
515
+ });
516
+
517
+ const errorCalls = (console.error as unknown as ReturnType<typeof vi.fn>).mock.calls.map((call) => call[0]);
518
+
519
+ expect(errorCalls).toEqual([
520
+ '[DIFF]: Shared changes for actors team/actor-a, team/actor-b: shared.ts',
521
+ '[DIFF]: Changes specific to actor team/actor-a: actors/a/a-only.ts',
522
+ '[DIFF]: Changes specific to actor team/actor-b: actors/b/b-only.ts',
523
+ '[DIFF]: Actors to be built and tested: team/actor-a, team/actor-b',
524
+ ]);
525
+ });
526
+
527
+ it('logs no group lines when zero actors changed', () => {
528
+ getChangedActors({
529
+ filepathsChanged: ['.gitignore', 'README.md'],
530
+ actorConfigs,
531
+ commits,
532
+ });
533
+
534
+ expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining('Shared changes'));
535
+ expect(console.error).not.toHaveBeenCalledWith(expect.stringContaining('Changes specific to'));
536
+ expect(console.error).toHaveBeenCalledWith('[DIFF]: No relevant files changed, skipping builds and tests');
537
+ });
182
538
  });
@@ -0,0 +1,123 @@
1
+ import path from 'node:path';
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { buildDockerIgnoreMatcher, loadDockerIgnore } from '../../../bin/dockerignore.js';
6
+
7
+ const { fsMock } = vi.hoisted(() => ({
8
+ fsMock: {
9
+ readFileSync: vi.fn(),
10
+ },
11
+ }));
12
+
13
+ vi.mock('node:fs', () => ({ default: fsMock }));
14
+
15
+ afterEach(() => vi.restoreAllMocks());
16
+
17
+ describe('loadDockerIgnore', () => {
18
+ it('returns no-op matcher when .dockerignore is absent', () => {
19
+ fsMock.readFileSync.mockImplementation(() => {
20
+ throw new Error('ENOENT');
21
+ });
22
+ const matcher = loadDockerIgnore('actors/my-actor');
23
+ expect(matcher('actors/my-actor/src/main.ts')).toBe(false);
24
+ expect(matcher('actors/my-actor/node_modules/foo.js')).toBe(false);
25
+ });
26
+
27
+ it('matches files listed in .dockerignore', () => {
28
+ fsMock.readFileSync.mockReturnValue('node_modules\n*.log\n');
29
+ const matcher = loadDockerIgnore('');
30
+ expect(matcher('node_modules/foo/bar.js')).toBe(true);
31
+ expect(matcher('debug.log')).toBe(true);
32
+ });
33
+
34
+ it('does not match files not in .dockerignore', () => {
35
+ fsMock.readFileSync.mockReturnValue('node_modules\n');
36
+ const matcher = loadDockerIgnore('');
37
+ expect(matcher('src/main.ts')).toBe(false);
38
+ });
39
+
40
+ it.each(['dist', 'dist/'])('handles directory pattern "%s"', (pattern) => {
41
+ fsMock.readFileSync.mockReturnValue(`${pattern}\n`);
42
+ const matcher = loadDockerIgnore('');
43
+ expect(matcher('dist/bundle.js')).toBe(true);
44
+ expect(matcher('src/dist-utils.ts')).toBe(false);
45
+ });
46
+
47
+ it('handles negation patterns', () => {
48
+ fsMock.readFileSync.mockReturnValue('*.log\n!important.log\n');
49
+ const matcher = loadDockerIgnore('');
50
+ expect(matcher('debug.log')).toBe(true);
51
+ expect(matcher('important.log')).toBe(false);
52
+ });
53
+
54
+ it('strips dockerContextDir prefix before matching', () => {
55
+ fsMock.readFileSync.mockReturnValue('node_modules\n');
56
+ const matcher = loadDockerIgnore('actors/shopify');
57
+ expect(matcher('actors/shopify/node_modules/foo.js')).toBe(true);
58
+ expect(matcher('actors/shopify/src/main.ts')).toBe(false);
59
+ });
60
+
61
+ it('returns false for files outside dockerContextDir', () => {
62
+ fsMock.readFileSync.mockReturnValue('*\n');
63
+ const matcher = loadDockerIgnore('actors/shopify');
64
+ expect(matcher('other-actor/src/main.ts')).toBe(false);
65
+ });
66
+
67
+ it('reads .dockerignore from the resolved, absolute dockerContextDir root', () => {
68
+ fsMock.readFileSync.mockReturnValue('');
69
+ loadDockerIgnore('actors/shopify');
70
+ expect(fsMock.readFileSync).toHaveBeenCalledWith(
71
+ path.join(path.resolve('actors/shopify'), '.dockerignore'),
72
+ 'utf-8',
73
+ );
74
+ });
75
+
76
+ it('reads .dockerignore from the resolved repo root when dockerContextDir is empty', () => {
77
+ fsMock.readFileSync.mockReturnValue('');
78
+ loadDockerIgnore('');
79
+ expect(fsMock.readFileSync).toHaveBeenCalledWith(path.join(path.resolve(''), '.dockerignore'), 'utf-8');
80
+ });
81
+
82
+ it('handles comments and blank lines', () => {
83
+ fsMock.readFileSync.mockReturnValue('# this is a comment\n\nnode_modules\n');
84
+ const matcher = loadDockerIgnore('');
85
+ expect(matcher('node_modules/foo.js')).toBe(true);
86
+ expect(matcher('src/main.ts')).toBe(false);
87
+ });
88
+
89
+ it('normalizes a leading "./" in patterns, which Docker treats as a no-op', () => {
90
+ // The common real-world .dockerignore style: every pattern prefixed with "./",
91
+ // including a negation re-including one of the otherwise-matched files.
92
+ fsMock.readFileSync.mockReturnValue('./node_modules\n./*.log\n!./keep.log\n');
93
+ const matcher = loadDockerIgnore('');
94
+ expect(matcher('node_modules/foo.js')).toBe(true);
95
+ expect(matcher('debug.log')).toBe(true);
96
+ expect(matcher('keep.log')).toBe(false);
97
+ expect(matcher('main.js')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('buildDockerIgnoreMatcher', () => {
102
+ it('treats paths as already relative to absoluteRootDir when hoistFrom is left at its default', () => {
103
+ fsMock.readFileSync.mockReturnValue('test/\n*.log\n');
104
+ const matcher = buildDockerIgnoreMatcher('/repo/actors/shopify');
105
+ expect(matcher('test/fixture.json')).toBe(true);
106
+ expect(matcher('debug.log')).toBe(true);
107
+ expect(matcher('main.js')).toBe(false);
108
+ });
109
+
110
+ it('reads .dockerignore from the given absoluteRootDir', () => {
111
+ fsMock.readFileSync.mockReturnValue('');
112
+ buildDockerIgnoreMatcher('/repo/actors/shopify');
113
+ expect(fsMock.readFileSync).toHaveBeenCalledWith('/repo/actors/shopify/.dockerignore', 'utf-8');
114
+ });
115
+
116
+ it('returns no-op matcher when .dockerignore is absent', () => {
117
+ fsMock.readFileSync.mockImplementation(() => {
118
+ throw new Error('ENOENT');
119
+ });
120
+ const matcher = buildDockerIgnoreMatcher('/repo/actors/shopify');
121
+ expect(matcher('main.js')).toBe(false);
122
+ });
123
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { findContainingScope, hoistPath, isPathWithinScope } from '../../../bin/path-utils.js';
4
+
5
+ describe('path-utils', () => {
6
+ it('isPathWithinScope matches a scope entry that names an exact file, not just a directory prefix', () => {
7
+ expect(isPathWithinScope('shared/utils.ts', 'shared/utils.ts')).toBe(true);
8
+ });
9
+
10
+ it('findContainingScope picks the exact-file entry out of a list of scope paths', () => {
11
+ expect(findContainingScope('shared/utils.ts', ['actors/foo', 'shared/utils.ts'])).toBe('shared/utils.ts');
12
+ });
13
+
14
+ it('hoistPath resolves an exact-file scope match to an empty string, not an out-of-bounds slice', () => {
15
+ expect(hoistPath('shared/utils.ts', 'shared/utils.ts')).toBe('');
16
+ });
17
+ });