apify-test-tools 0.9.1-beta.4 → 0.9.1-beta.5

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 (51) hide show
  1. package/.prettierignore +3 -0
  2. package/.vscode/settings.json +2 -1
  3. package/bin/main.ts +2 -1
  4. package/bin/types.ts +0 -11
  5. package/bin/utils/config/load-config.ts +251 -0
  6. package/bin/utils/json-file.ts +55 -0
  7. package/bin/utils.ts +0 -138
  8. package/dist/bin/main.d.ts.map +1 -1
  9. package/dist/bin/main.js +2 -1
  10. package/dist/bin/main.js.map +1 -1
  11. package/dist/bin/types.d.ts +0 -9
  12. package/dist/bin/types.d.ts.map +1 -1
  13. package/dist/bin/utils/config/load-config.d.ts +44 -0
  14. package/dist/bin/utils/config/load-config.d.ts.map +1 -0
  15. package/dist/bin/utils/config/load-config.js +177 -0
  16. package/dist/bin/utils/config/load-config.js.map +1 -0
  17. package/dist/bin/utils/json-file.d.ts +20 -0
  18. package/dist/bin/utils/json-file.d.ts.map +1 -0
  19. package/dist/bin/utils/json-file.js +42 -0
  20. package/dist/bin/utils/json-file.js.map +1 -0
  21. package/dist/bin/utils.d.ts +0 -6
  22. package/dist/bin/utils.d.ts.map +1 -1
  23. package/dist/bin/utils.js +0 -98
  24. package/dist/bin/utils.js.map +1 -1
  25. package/dist/test/unit/bin/utils/actor-config.test.d.ts +2 -0
  26. package/dist/test/unit/bin/utils/actor-config.test.d.ts.map +1 -0
  27. package/dist/test/unit/bin/{utils.test.js → utils/actor-config.test.js} +126 -51
  28. package/dist/test/unit/bin/utils/actor-config.test.js.map +1 -0
  29. package/dist/test/unit/bin/utils/files.test.d.ts +2 -0
  30. package/dist/test/unit/bin/utils/files.test.d.ts.map +1 -0
  31. package/dist/test/unit/bin/utils/files.test.js +117 -0
  32. package/dist/test/unit/bin/utils/files.test.js.map +1 -0
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +3 -2
  35. package/test/fixtures/bin/utils/files/array.json +1 -0
  36. package/test/fixtures/bin/utils/files/comments.jsonc +4 -0
  37. package/test/fixtures/bin/utils/files/directory.json/.gitkeep +0 -0
  38. package/test/fixtures/bin/utils/files/empty-object.json +1 -0
  39. package/test/fixtures/bin/utils/files/empty.json +0 -0
  40. package/test/fixtures/bin/utils/files/malformed.json +1 -0
  41. package/test/fixtures/bin/utils/files/nested.json +6 -0
  42. package/test/fixtures/bin/utils/files/no-json-extension.actorrc +1 -0
  43. package/test/fixtures/bin/utils/files/null.json +1 -0
  44. package/test/fixtures/bin/utils/files/number.json +1 -0
  45. package/test/fixtures/bin/utils/files/unreadable.json +1 -0
  46. package/test/tsconfig.json +4 -2
  47. package/test/unit/bin/{utils.test.ts → utils/actor-config.test.ts} +152 -52
  48. package/test/unit/bin/utils/files.test.ts +158 -0
  49. package/dist/test/unit/bin/utils.test.d.ts +0 -2
  50. package/dist/test/unit/bin/utils.test.d.ts.map +0 -1
  51. package/dist/test/unit/bin/utils.test.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apify-test-tools",
3
- "version": "0.9.1-beta.4",
3
+ "version": "0.9.1-beta.5",
4
4
  "type": "module",
5
5
  "description": "TBD",
6
6
  "repository": {
@@ -20,7 +20,8 @@
20
20
  "@slack/web-api": "^7.9.2",
21
21
  "apify-client": "^2.22.2",
22
22
  "ignore": "^7.0.5",
23
- "yargs": "^18.0.0"
23
+ "yargs": "^18.0.0",
24
+ "zod": "^4.6.5"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@apify/eslint-config": "^1.0.0",
@@ -0,0 +1 @@
1
+ [{ "a": 1 }]
@@ -0,0 +1,4 @@
1
+ {
2
+ // this is a comment
3
+ "a": 3,
4
+ }
File without changes
@@ -0,0 +1 @@
1
+ { "a": 1
@@ -0,0 +1,6 @@
1
+ {
2
+ "storages": { "datasets": { "default": { "title": "Default" } } },
3
+ "overrideActorContext": ["../shared", "."],
4
+ "changelog": null,
5
+ "nested": [{ "deep": [1, 2, { "deeper": true }] }]
6
+ }
@@ -0,0 +1 @@
1
+ null
@@ -0,0 +1 @@
1
+ { "a": 1 }
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "extends": "@apify/tsconfig",
3
3
  "compilerOptions": {
4
- "module": "ES2022",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
5
6
  "target": "ES2022",
6
7
  "outDir": "dist",
7
8
  "noUnusedLocals": false,
8
9
  "noEmit": true,
10
+ "rootDir": "..",
9
11
  "lib": ["DOM", "es2021"],
10
12
  "skipLibCheck": true
11
13
  },
12
- "include": ["./**/*"]
14
+ "include": ["./**/*", "../bin/**/*", "../lib/**/*"]
13
15
  }
@@ -1,36 +1,102 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
-
3
- import { CONFIG_FILE_NAME, readConfigFile } from '../../../bin/utils.js';
4
-
5
- const { fsMock } = vi.hoisted(() => ({
6
- fsMock: {
7
- readFile: vi.fn(),
8
- },
9
- }));
10
-
11
- vi.mock('node:fs/promises', () => ({ default: fsMock }));
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+
7
+ import {
8
+ CONFIG_FILE_NAME,
9
+ loadActorConfig,
10
+ parseConfigFile,
11
+ readConfigFile,
12
+ resolveRawConfig,
13
+ } from '../../../../bin/utils/config/load-config.js';
14
+
15
+ // `readConfigFile` resolves every path against the process's working directory, so these tests give it
16
+ // a real one: a throwaway repo in a temp dir. Reading actual files rather than a mocked
17
+ // `node:fs/promises` is what makes the path handling (the ".actor/" hop, a dockerContextDir escaping
18
+ // the repo root) worth asserting on — against a mock those assertions only describe the mock.
19
+ let repoDir: string;
20
+ let originalCwd: string;
21
+
22
+ beforeEach(async () => {
23
+ originalCwd = process.cwd();
24
+ // `realpath` because macOS hands out a symlinked temp dir, while `process.cwd()` reports the target.
25
+ repoDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'apify-test-tools-config-')));
26
+ process.chdir(repoDir);
27
+ });
12
28
 
13
- afterEach(() => vi.restoreAllMocks());
29
+ afterEach(async () => {
30
+ process.chdir(originalCwd);
31
+ await fs.rm(repoDir, { recursive: true, force: true });
32
+ });
14
33
 
15
34
  const emptyActorSelection = { actors: [], ignore: [] };
16
35
 
17
36
  const validConfig = (actors: object[]) => JSON.stringify({ actors });
18
37
  const actorJson = (fields: Record<string, unknown> = {}) => JSON.stringify(fields);
19
38
 
20
- const expectFileRead = (filePath: string) => {
21
- expect(fsMock.readFile).toHaveBeenCalledWith(filePath, expect.anything());
22
- };
39
+ const writeFiles = async (files: Record<string, string>) =>
40
+ Promise.all(
41
+ Object.entries(files).map(async ([filePath, contents]) => {
42
+ const absPath = path.join(repoDir, filePath);
43
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
44
+ await fs.writeFile(absPath, contents);
45
+ }),
46
+ );
47
+
48
+ // Stages 2-4 are callable on their own, which is the point: a differently shaped config file only
49
+ // has to reach `resolveRawConfig`'s output to work with everything downstream.
50
+ describe('the parse/resolve/load seam', () => {
51
+ it('parseConfigFile validates the file shape without touching the filesystem', () => {
52
+ const entry = { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' };
53
+
54
+ expect(parseConfigFile({ actors: [entry] })).toEqual({ actors: [entry] });
55
+ expect(() => parseConfigFile({ actors: [{ ...entry, folder: 123 }] })).toThrow(/Invalid "folder"/);
56
+ });
23
57
 
24
- const mockFiles = (files: Record<string, string>) => {
25
- fsMock.readFile.mockImplementation(async (filePath: string) => {
26
- if (filePath in files) return Promise.resolve(files[filePath]);
27
- return Promise.reject(new Error(`ENOENT: ${filePath}`));
58
+ it('resolveRawConfig is where the repo root becomes "" and trailing slashes go', () => {
59
+ const result = resolveRawConfig({
60
+ actors: [
61
+ { folder: '.', actorFullName: 'myteam/root', tokenEnvVar: 'APIFY_TOKEN' },
62
+ {
63
+ folder: 'actors/shopify/',
64
+ actorFullName: 'myteam/shopify',
65
+ tokenEnvVar: 'APIFY_TOKEN',
66
+ overrideActorContext: ['packages/'],
67
+ },
68
+ ],
69
+ });
70
+
71
+ expect(result).toEqual([
72
+ { folder: '', actorFullName: 'myteam/root', tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: undefined },
73
+ {
74
+ folder: 'actors/shopify',
75
+ actorFullName: 'myteam/shopify',
76
+ tokenEnvVar: 'APIFY_TOKEN',
77
+ overrideActorContext: ['packages'],
78
+ },
79
+ ]);
28
80
  });
29
- };
81
+
82
+ it('loadActorConfig resolves data coming from actor.json', async () => {
83
+ await writeFiles({ 'actors/shopify/.actor/actor.json': actorJson({ dockerContextDir: '..' }) });
84
+
85
+ expect(
86
+ loadActorConfig({ folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' }),
87
+ ).toEqual({
88
+ actorFullName: 'myteam/shopify',
89
+ folder: 'actors/shopify',
90
+ tokenEnvVar: 'APIFY_TOKEN',
91
+ dockerContextDir: 'actors/shopify',
92
+ contextPaths: ['actors/shopify'],
93
+ });
94
+ });
95
+ });
30
96
 
31
97
  describe('readConfigFile', () => {
32
98
  it('returns correct ActorConfig[] for a valid config', async () => {
33
- mockFiles({
99
+ await writeFiles({
34
100
  [CONFIG_FILE_NAME]: validConfig([
35
101
  {
36
102
  folder: 'actors/shopify',
@@ -42,7 +108,6 @@ describe('readConfigFile', () => {
42
108
  });
43
109
 
44
110
  const result = await readConfigFile(emptyActorSelection);
45
- expectFileRead('actors/shopify/.actor/actor.json');
46
111
  expect(result).toEqual([
47
112
  {
48
113
  actorFullName: 'myteam/shopify-scraper',
@@ -55,7 +120,7 @@ describe('readConfigFile', () => {
55
120
  });
56
121
 
57
122
  it('normalizes folder "." to ""', async () => {
58
- mockFiles({
123
+ await writeFiles({
59
124
  [CONFIG_FILE_NAME]: validConfig([
60
125
  { folder: '.', actorFullName: 'apify/my-actor', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
61
126
  ]),
@@ -64,11 +129,10 @@ describe('readConfigFile', () => {
64
129
 
65
130
  const result = await readConfigFile(emptyActorSelection);
66
131
  expect(result[0].folder).toBe('');
67
- expectFileRead('.actor/actor.json');
68
132
  });
69
133
 
70
134
  it('defaults dockerContextDir to actor folder when absent from actor.json', async () => {
71
- mockFiles({
135
+ await writeFiles({
72
136
  [CONFIG_FILE_NAME]: validConfig([
73
137
  { folder: 'actors/web-scraper', actorFullName: 'apify/web-scraper', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
74
138
  ]),
@@ -81,7 +145,7 @@ describe('readConfigFile', () => {
81
145
  });
82
146
 
83
147
  it('resolves dockerContextDir relative to .actor/ folder', async () => {
84
- mockFiles({
148
+ await writeFiles({
85
149
  [CONFIG_FILE_NAME]: validConfig([
86
150
  { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' },
87
151
  ]),
@@ -92,8 +156,19 @@ describe('readConfigFile', () => {
92
156
  expect(result[0].dockerContextDir).toBe('');
93
157
  });
94
158
 
159
+ it('throws when dockerContextDir resolves outside the repository root', async () => {
160
+ await writeFiles({
161
+ [CONFIG_FILE_NAME]: validConfig([
162
+ { folder: 'actors/shopify', actorFullName: 'myteam/shopify', tokenEnvVar: 'APIFY_TOKEN' },
163
+ ]),
164
+ 'actors/shopify/.actor/actor.json': actorJson({ dockerContextDir: '../../../..' }),
165
+ });
166
+
167
+ await expect(readConfigFile(emptyActorSelection)).rejects.toThrow(/resolves outside the repository root/);
168
+ });
169
+
95
170
  it('resolves contextPaths from overrideActorContext', async () => {
96
- mockFiles({
171
+ await writeFiles({
97
172
  [CONFIG_FILE_NAME]: validConfig([
98
173
  {
99
174
  folder: 'actors/shopify',
@@ -110,7 +185,7 @@ describe('readConfigFile', () => {
110
185
  });
111
186
 
112
187
  it('handles multiple actors', async () => {
113
- mockFiles({
188
+ await writeFiles({
114
189
  [CONFIG_FILE_NAME]: validConfig([
115
190
  { folder: 'actors/web-scraper', actorFullName: 'apify/web-scraper', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
116
191
  {
@@ -130,22 +205,26 @@ describe('readConfigFile', () => {
130
205
  });
131
206
 
132
207
  it('throws when config file is missing', async () => {
133
- fsMock.readFile.mockRejectedValue(new Error('ENOENT'));
134
208
  await expect(readConfigFile(emptyActorSelection)).rejects.toThrow('not found');
135
209
  });
136
210
 
137
211
  it('throws when config file contains invalid JSON', async () => {
138
- fsMock.readFile.mockResolvedValue('{bad json');
212
+ await writeFiles({ [CONFIG_FILE_NAME]: '{bad json' });
139
213
  await expect(readConfigFile(emptyActorSelection)).rejects.toThrow('invalid JSON');
140
214
  });
141
215
 
142
216
  it('throws when actors array is missing', async () => {
143
- fsMock.readFile.mockResolvedValue(JSON.stringify({ notActors: [] }));
217
+ await writeFiles({ [CONFIG_FILE_NAME]: JSON.stringify({ notActors: [] }) });
218
+ await expect(readConfigFile(emptyActorSelection)).rejects.toThrow('"actors" array');
219
+ });
220
+
221
+ it('throws when the config file is not a JSON object', async () => {
222
+ await writeFiles({ [CONFIG_FILE_NAME]: JSON.stringify([{ folder: 'actors/shopify' }]) });
144
223
  await expect(readConfigFile(emptyActorSelection)).rejects.toThrow('"actors" array');
145
224
  });
146
225
 
147
226
  it('throws on duplicate folders', async () => {
148
- mockFiles({
227
+ await writeFiles({
149
228
  [CONFIG_FILE_NAME]: validConfig([
150
229
  { folder: 'actors/shopify', actorFullName: 'apify/shopify', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
151
230
  { folder: 'actors/shopify', actorFullName: 'other/shopify', tokenEnvVar: 'APIFY_TOKEN_OTHER' },
@@ -157,7 +236,7 @@ describe('readConfigFile', () => {
157
236
  });
158
237
 
159
238
  it('throws on duplicate folders after normalization ("." and "")', async () => {
160
- mockFiles({
239
+ await writeFiles({
161
240
  [CONFIG_FILE_NAME]: validConfig([
162
241
  { folder: '.', actorFullName: 'apify/actor-a', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
163
242
  { folder: '', actorFullName: 'other/actor-b', tokenEnvVar: 'APIFY_TOKEN_OTHER' },
@@ -169,7 +248,7 @@ describe('readConfigFile', () => {
169
248
  });
170
249
 
171
250
  it('throws when actor.json is missing', async () => {
172
- mockFiles({
251
+ await writeFiles({
173
252
  [CONFIG_FILE_NAME]: validConfig([
174
253
  { folder: 'actors/shopify', actorFullName: 'apify/shopify', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
175
254
  ]),
@@ -179,7 +258,7 @@ describe('readConfigFile', () => {
179
258
  });
180
259
 
181
260
  it('throws when folder is missing', async () => {
182
- mockFiles({
261
+ await writeFiles({
183
262
  [CONFIG_FILE_NAME]: validConfig([{ actorFullName: 'apify/shopify', tokenEnvVar: 'APIFY_TOKEN_APIFY' }]),
184
263
  });
185
264
 
@@ -187,7 +266,7 @@ describe('readConfigFile', () => {
187
266
  });
188
267
 
189
268
  it('throws when folder is not a string', async () => {
190
- mockFiles({
269
+ await writeFiles({
191
270
  [CONFIG_FILE_NAME]: validConfig([
192
271
  { folder: 123, actorFullName: 'apify/shopify', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
193
272
  ]),
@@ -197,7 +276,7 @@ describe('readConfigFile', () => {
197
276
  });
198
277
 
199
278
  it('throws when actorFullName is missing', async () => {
200
- mockFiles({
279
+ await writeFiles({
201
280
  [CONFIG_FILE_NAME]: validConfig([{ folder: 'actors/shopify', tokenEnvVar: 'APIFY_TOKEN_APIFY' }]),
202
281
  'actors/shopify/.actor/actor.json': actorJson({}),
203
282
  });
@@ -206,7 +285,7 @@ describe('readConfigFile', () => {
206
285
  });
207
286
 
208
287
  it('throws when actorFullName has no slash', async () => {
209
- mockFiles({
288
+ await writeFiles({
210
289
  [CONFIG_FILE_NAME]: validConfig([
211
290
  { folder: 'actors/shopify', actorFullName: 'shopify-scraper', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
212
291
  ]),
@@ -217,7 +296,7 @@ describe('readConfigFile', () => {
217
296
  });
218
297
 
219
298
  it('throws when actorFullName has empty parts', async () => {
220
- mockFiles({
299
+ await writeFiles({
221
300
  [CONFIG_FILE_NAME]: validConfig([
222
301
  { folder: 'actors/shopify', actorFullName: '/shopify', tokenEnvVar: 'APIFY_TOKEN_APIFY' },
223
302
  ]),
@@ -227,8 +306,29 @@ describe('readConfigFile', () => {
227
306
  await expect(readConfigFile(emptyActorSelection)).rejects.toThrow('Invalid "actorFullName"');
228
307
  });
229
308
 
309
+ it('throws when tokenEnvVar is missing', async () => {
310
+ await writeFiles({
311
+ [CONFIG_FILE_NAME]: validConfig([{ folder: 'actors/shopify', actorFullName: 'apify/shopify' }]),
312
+ 'actors/shopify/.actor/actor.json': actorJson({}),
313
+ });
314
+
315
+ await expect(readConfigFile(emptyActorSelection)).rejects.toThrow('Invalid "tokenEnvVar"');
316
+ });
317
+
318
+ it('reports every invalid field at once instead of only the first', async () => {
319
+ await writeFiles({
320
+ [CONFIG_FILE_NAME]: validConfig([{ folder: 123, actorFullName: 'shopify', overrideActorContext: 'nope' }]),
321
+ });
322
+
323
+ const promise = readConfigFile(emptyActorSelection);
324
+ await expect(promise).rejects.toThrow(/Invalid "folder"/);
325
+ await expect(promise).rejects.toThrow(/Invalid "actorFullName"/);
326
+ await expect(promise).rejects.toThrow(/Invalid "tokenEnvVar"/);
327
+ await expect(promise).rejects.toThrow(/Invalid "overrideActorContext"/);
328
+ });
329
+
230
330
  it('throws when overrideActorContext is not an array', async () => {
231
- mockFiles({
331
+ await writeFiles({
232
332
  [CONFIG_FILE_NAME]: validConfig([
233
333
  {
234
334
  folder: 'actors/shopify',
@@ -244,7 +344,7 @@ describe('readConfigFile', () => {
244
344
  });
245
345
 
246
346
  it('throws when overrideActorContext contains non-strings', async () => {
247
- mockFiles({
347
+ await writeFiles({
248
348
  [CONFIG_FILE_NAME]: validConfig([
249
349
  {
250
350
  folder: 'actors/shopify',
@@ -260,7 +360,7 @@ describe('readConfigFile', () => {
260
360
  });
261
361
 
262
362
  it('throws when overrideActorContext entries overlap (one is a prefix of another)', async () => {
263
- mockFiles({
363
+ await writeFiles({
264
364
  [CONFIG_FILE_NAME]: validConfig([
265
365
  {
266
366
  folder: 'actors/shopify',
@@ -276,7 +376,7 @@ describe('readConfigFile', () => {
276
376
  });
277
377
 
278
378
  it('throws when overrideActorContext contains the repo root alongside another entry', async () => {
279
- mockFiles({
379
+ await writeFiles({
280
380
  [CONFIG_FILE_NAME]: validConfig([
281
381
  {
282
382
  folder: 'actors/shopify',
@@ -292,7 +392,7 @@ describe('readConfigFile', () => {
292
392
  });
293
393
 
294
394
  it('adds the actor own folder automatically when overrideActorContext does not cover it', async () => {
295
- mockFiles({
395
+ await writeFiles({
296
396
  [CONFIG_FILE_NAME]: validConfig([
297
397
  {
298
398
  folder: 'actors/shopify',
@@ -309,7 +409,7 @@ describe('readConfigFile', () => {
309
409
  });
310
410
 
311
411
  it('strips trailing slashes from folder and overrideActorContext entries', async () => {
312
- mockFiles({
412
+ await writeFiles({
313
413
  [CONFIG_FILE_NAME]: validConfig([
314
414
  {
315
415
  folder: 'actors/shopify/',
@@ -327,7 +427,7 @@ describe('readConfigFile', () => {
327
427
  });
328
428
 
329
429
  it('allows overrideActorContext with disjoint sibling paths that all reach the actor folder via one entry', async () => {
330
- mockFiles({
430
+ await writeFiles({
331
431
  [CONFIG_FILE_NAME]: validConfig([
332
432
  {
333
433
  folder: 'actors/shopify',
@@ -344,8 +444,8 @@ describe('readConfigFile', () => {
344
444
  });
345
445
 
346
446
  describe('actor selection', () => {
347
- const twoActors = () =>
348
- mockFiles({
447
+ const twoActors = async () =>
448
+ writeFiles({
349
449
  [CONFIG_FILE_NAME]: validConfig([
350
450
  { folder: 'actors/a', actorFullName: 'team/a', tokenEnvVar: 'TOKEN' },
351
451
  { folder: 'actors/b', actorFullName: 'team/b', tokenEnvVar: 'TOKEN' },
@@ -357,22 +457,22 @@ describe('readConfigFile', () => {
357
457
  const fullNames = (result: { actorFullName: string }[]) => result.map((c) => c.actorFullName);
358
458
 
359
459
  it('returns all actors when the selection is empty', async () => {
360
- twoActors();
460
+ await twoActors();
361
461
  expect(fullNames(await readConfigFile(emptyActorSelection))).toEqual(['team/a', 'team/b']);
362
462
  });
363
463
 
364
464
  it('keeps only the selected actors', async () => {
365
- twoActors();
465
+ await twoActors();
366
466
  expect(fullNames(await readConfigFile({ actors: ['team/a'], ignore: [] }))).toEqual(['team/a']);
367
467
  });
368
468
 
369
469
  it('drops ignored actors', async () => {
370
- twoActors();
470
+ await twoActors();
371
471
  expect(fullNames(await readConfigFile({ actors: [], ignore: ['team/a'] }))).toEqual(['team/b']);
372
472
  });
373
473
 
374
474
  it('throws on an unknown actor name', async () => {
375
- twoActors();
475
+ await twoActors();
376
476
  await expect(readConfigFile({ actors: ['team/nope'], ignore: [] })).rejects.toThrow(
377
477
  'do not exist: team/nope',
378
478
  );
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+
7
+ import { safeReadJsonObjectFile } from '../../../../bin/utils/json-file.js';
8
+
9
+ // These tests read real fixture files rather than mocking `node:fs`: the whole point of
10
+ // `safeReadJsonObjectFile` is how it reacts to real stat/readFile outcomes (missing path, directory,
11
+ // symlink, unreadable file), so mocking those away would only assert the mock back to itself.
12
+ const FIXTURE_DIR = fileURLToPath(new URL('../../../fixtures/bin/utils/files/', import.meta.url));
13
+
14
+ const fixture = (name: string) => path.join(FIXTURE_DIR, name);
15
+
16
+ const NESTED_FIXTURE = {
17
+ storages: { datasets: { default: { title: 'Default' } } },
18
+ overrideActorContext: ['../shared', '.'],
19
+ changelog: null,
20
+ nested: [{ deep: [1, 2, { deeper: true }] }],
21
+ };
22
+
23
+ describe('safeReadJsonObjectFile', () => {
24
+ describe('valid JSON objects', () => {
25
+ it('returns the parsed contents, preserving nested objects, arrays and null values', () => {
26
+ const result = safeReadJsonObjectFile(fixture('nested.json'));
27
+
28
+ expect(result).toStrictEqual({ success: true, contents: NESTED_FIXTURE });
29
+ });
30
+
31
+ it('accepts an empty JSON object', () => {
32
+ const result = safeReadJsonObjectFile(fixture('empty-object.json'));
33
+
34
+ expect(result).toStrictEqual({ success: true, contents: {} });
35
+ });
36
+
37
+ it('reads files that are not named *.json', () => {
38
+ const result = safeReadJsonObjectFile(fixture('no-json-extension.actorrc'));
39
+
40
+ expect(result).toStrictEqual({ success: true, contents: { a: 1 } });
41
+ });
42
+ });
43
+
44
+ describe('filesystem-level handling', () => {
45
+ it('fails when the path does not exist', () => {
46
+ const filePath = fixture('does-not-exist.json');
47
+
48
+ const result = safeReadJsonObjectFile(filePath);
49
+
50
+ expect(result).toMatchObject({
51
+ success: false,
52
+ reason: 'unreadable',
53
+ });
54
+ });
55
+
56
+ // The fixture is named `directory.json` on purpose — the check is `stat().isFile()`, not the extension.
57
+ it('fails when the path is a directory', () => {
58
+ const filePath = fixture('directory.json');
59
+
60
+ const result = safeReadJsonObjectFile(filePath);
61
+
62
+ expect(result).toMatchObject({
63
+ success: false,
64
+ reason: 'not-a-file',
65
+ });
66
+ });
67
+
68
+ // Regression guard: `lstat` would report the link itself as "not a file" and reject it, even
69
+ // though `readFile` follows symlinks and would have read the target without complaint.
70
+ it('follows a symlink to its target file', () => {
71
+ const result = safeReadJsonObjectFile(fixture('symlink-to-valid.json'));
72
+
73
+ expect(result).toStrictEqual({ success: true, contents: NESTED_FIXTURE });
74
+ });
75
+
76
+ // Git only tracks the executable bit, so this is the one case whose fixture cannot carry its own
77
+ // mode — `unreadable.json` is committed readable and stripped of permissions just for this test.
78
+ describe('unreadable file', () => {
79
+ const filePath = fixture('unreadable.json');
80
+
81
+ afterEach(async () => fs.chmod(filePath, 0o644));
82
+
83
+ it.runIf(process.getuid?.() !== 0)('fails when the file exists but cannot be read', async () => {
84
+ await fs.chmod(filePath, 0o000);
85
+
86
+ const result = safeReadJsonObjectFile(filePath);
87
+
88
+ expect(result).toMatchObject({
89
+ success: false,
90
+ reason: 'unreadable',
91
+ });
92
+ });
93
+ });
94
+ });
95
+
96
+ describe('JSON that is not an object', () => {
97
+ it('rejects a JSON array', () => {
98
+ const filePath = fixture('array.json');
99
+
100
+ const result = safeReadJsonObjectFile(filePath);
101
+
102
+ expect(result).toMatchObject({
103
+ success: false,
104
+ reason: 'not-an-object',
105
+ });
106
+ });
107
+
108
+ it('rejects JSON null', () => {
109
+ const filePath = fixture('null.json');
110
+
111
+ const result = safeReadJsonObjectFile(filePath);
112
+
113
+ expect(result).toMatchObject({
114
+ success: false,
115
+ reason: 'not-an-object',
116
+ });
117
+ });
118
+
119
+ it('rejects a JSON primitive', () => {
120
+ const filePath = fixture('number.json');
121
+
122
+ const result = safeReadJsonObjectFile(filePath);
123
+
124
+ expect(result).toMatchObject({
125
+ success: false,
126
+ reason: 'not-an-object',
127
+ });
128
+ });
129
+ });
130
+
131
+ describe('unparseable files', () => {
132
+ it('fails on malformed JSON', () => {
133
+ const filePath = fixture('malformed.json');
134
+
135
+ const result = safeReadJsonObjectFile(filePath);
136
+
137
+ expect(result).toMatchObject({ success: false, reason: 'invalid-json' });
138
+ expect((result as { failure: Error }).failure.message).toContain(`Failed to parse ${filePath}`);
139
+ });
140
+
141
+ it('fails on an empty file', () => {
142
+ const filePath = fixture('empty.json');
143
+
144
+ const result = safeReadJsonObjectFile(filePath);
145
+
146
+ expect(result).toMatchObject({ success: false, reason: 'invalid-json' });
147
+ expect((result as { failure: Error }).failure.message).toContain(`Failed to parse ${filePath}`);
148
+ });
149
+
150
+ it.fails('reads jsonc', () => {
151
+ const filePath = fixture('comments.jsonc');
152
+
153
+ const result = safeReadJsonObjectFile(filePath);
154
+
155
+ expect(result).toMatchObject({ success: true, contents: { a: 3 } });
156
+ });
157
+ });
158
+ });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=utils.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils.test.d.ts","sourceRoot":"","sources":["../../../../test/unit/bin/utils.test.ts"],"names":[],"mappings":""}