apify-test-tools 0.10.1-beta.2 → 0.10.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/.github/actions/checkout-restore-dependencies/action.yaml +4 -3
  2. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +7 -2
  3. package/.github/workflows/public_platform-tests.yaml +0 -1
  4. package/.github/workflows-package-version +1 -1
  5. package/CHANGELOG.md +6 -0
  6. package/CONTRIBUTING.md +13 -3
  7. package/README.md +2 -2
  8. package/bin/build-from-local.ts +8 -7
  9. package/bin/build.ts +22 -21
  10. package/bin/diff-changes.ts +11 -10
  11. package/bin/diff-json-schema.ts +14 -12
  12. package/bin/git.ts +89 -47
  13. package/bin/logger.ts +23 -0
  14. package/bin/main.ts +44 -38
  15. package/bin/output.ts +6 -0
  16. package/bin/slack.ts +8 -7
  17. package/bin/test-report.ts +22 -21
  18. package/bin/types.ts +0 -1
  19. package/bin/utils/config/actor-json.ts +84 -0
  20. package/bin/utils/config/load-config.ts +46 -46
  21. package/bin/utils/config/parser.ts +33 -28
  22. package/bin/utils/config/structures/base.ts +7 -0
  23. package/bin/utils/json-file.ts +11 -14
  24. package/bin/utils/path/repo-relative.ts +156 -0
  25. package/bin/utils/path/schema.ts +18 -0
  26. package/bin/utils.ts +63 -44
  27. package/dist/bin/build-from-local.d.ts +1 -1
  28. package/dist/bin/build-from-local.d.ts.map +1 -1
  29. package/dist/bin/build-from-local.js +8 -7
  30. package/dist/bin/build-from-local.js.map +1 -1
  31. package/dist/bin/build.d.ts.map +1 -1
  32. package/dist/bin/build.js +22 -21
  33. package/dist/bin/build.js.map +1 -1
  34. package/dist/bin/diff-changes.d.ts +1 -1
  35. package/dist/bin/diff-changes.d.ts.map +1 -1
  36. package/dist/bin/diff-changes.js +9 -8
  37. package/dist/bin/diff-changes.js.map +1 -1
  38. package/dist/bin/diff-json-schema.d.ts +1 -1
  39. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  40. package/dist/bin/diff-json-schema.js +13 -12
  41. package/dist/bin/diff-json-schema.js.map +1 -1
  42. package/dist/bin/git.d.ts +10 -10
  43. package/dist/bin/git.d.ts.map +1 -1
  44. package/dist/bin/git.js +78 -37
  45. package/dist/bin/git.js.map +1 -1
  46. package/dist/bin/logger.d.ts +11 -0
  47. package/dist/bin/logger.d.ts.map +1 -0
  48. package/dist/bin/logger.js +20 -0
  49. package/dist/bin/logger.js.map +1 -0
  50. package/dist/bin/main.d.ts +1 -1
  51. package/dist/bin/main.js +44 -38
  52. package/dist/bin/main.js.map +1 -1
  53. package/dist/bin/output.d.ts +3 -0
  54. package/dist/bin/output.d.ts.map +1 -0
  55. package/dist/bin/output.js +6 -0
  56. package/dist/bin/output.js.map +1 -0
  57. package/dist/bin/slack.d.ts.map +1 -1
  58. package/dist/bin/slack.js +8 -7
  59. package/dist/bin/slack.js.map +1 -1
  60. package/dist/bin/test-report.d.ts.map +1 -1
  61. package/dist/bin/test-report.js +22 -21
  62. package/dist/bin/test-report.js.map +1 -1
  63. package/dist/bin/types.d.ts +0 -1
  64. package/dist/bin/types.d.ts.map +1 -1
  65. package/dist/bin/utils/config/actor-json.d.ts +14 -0
  66. package/dist/bin/utils/config/actor-json.d.ts.map +1 -0
  67. package/dist/bin/utils/config/actor-json.js +60 -0
  68. package/dist/bin/utils/config/actor-json.js.map +1 -0
  69. package/dist/bin/utils/config/load-config.d.ts +12 -2
  70. package/dist/bin/utils/config/load-config.d.ts.map +1 -1
  71. package/dist/bin/utils/config/load-config.js +32 -31
  72. package/dist/bin/utils/config/load-config.js.map +1 -1
  73. package/dist/bin/utils/config/parser.d.ts +4 -12
  74. package/dist/bin/utils/config/parser.d.ts.map +1 -1
  75. package/dist/bin/utils/config/parser.js +24 -23
  76. package/dist/bin/utils/config/parser.js.map +1 -1
  77. package/dist/bin/utils/config/structures/base.d.ts +5 -0
  78. package/dist/bin/utils/config/structures/base.d.ts.map +1 -1
  79. package/dist/bin/utils/config/structures/base.js.map +1 -1
  80. package/dist/bin/utils/json-file.d.ts +3 -2
  81. package/dist/bin/utils/json-file.d.ts.map +1 -1
  82. package/dist/bin/utils/json-file.js +8 -13
  83. package/dist/bin/utils/json-file.js.map +1 -1
  84. package/dist/bin/utils/path/repo-relative.d.ts +35 -0
  85. package/dist/bin/utils/path/repo-relative.d.ts.map +1 -0
  86. package/dist/bin/utils/path/repo-relative.js +137 -0
  87. package/dist/bin/utils/path/repo-relative.js.map +1 -0
  88. package/dist/bin/utils/path/schema.d.ts +7 -0
  89. package/dist/bin/utils/path/schema.d.ts.map +1 -0
  90. package/dist/bin/utils/path/schema.js +16 -0
  91. package/dist/bin/utils/path/schema.js.map +1 -0
  92. package/dist/bin/utils.d.ts +14 -6
  93. package/dist/bin/utils.d.ts.map +1 -1
  94. package/dist/bin/utils.js +52 -36
  95. package/dist/bin/utils.js.map +1 -1
  96. package/dist/test/unit/bin/build-from-local.test.js +4 -40
  97. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  98. package/dist/test/unit/bin/diff-changes.test.js +97 -96
  99. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  100. package/dist/test/unit/bin/diff.test.js +52 -48
  101. package/dist/test/unit/bin/diff.test.js.map +1 -1
  102. package/dist/test/unit/bin/git-command.test.d.ts +2 -0
  103. package/dist/test/unit/bin/git-command.test.d.ts.map +1 -0
  104. package/dist/test/unit/bin/git-command.test.js +54 -0
  105. package/dist/test/unit/bin/git-command.test.js.map +1 -0
  106. package/dist/test/unit/bin/git.test.js +211 -94
  107. package/dist/test/unit/bin/git.test.js.map +1 -1
  108. package/dist/test/unit/bin/logger.test.d.ts +2 -0
  109. package/dist/test/unit/bin/logger.test.d.ts.map +1 -0
  110. package/dist/test/unit/bin/logger.test.js +44 -0
  111. package/dist/test/unit/bin/logger.test.js.map +1 -0
  112. package/dist/test/unit/bin/logging.test.d.ts +2 -0
  113. package/dist/test/unit/bin/logging.test.d.ts.map +1 -0
  114. package/dist/test/unit/bin/logging.test.js +43 -0
  115. package/dist/test/unit/bin/logging.test.js.map +1 -0
  116. package/dist/test/unit/bin/utils/config/load-config.test.js +47 -25
  117. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  118. package/dist/test/unit/bin/utils/config/parser.test.js +64 -19
  119. package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
  120. package/dist/test/unit/bin/utils/files.test.js +16 -27
  121. package/dist/test/unit/bin/utils/files.test.js.map +1 -1
  122. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts +2 -0
  123. package/dist/test/unit/bin/utils/path/repo-relative.test.d.ts.map +1 -0
  124. package/dist/test/unit/bin/utils/path/repo-relative.test.js +196 -0
  125. package/dist/test/unit/bin/utils/path/repo-relative.test.js.map +1 -0
  126. package/dist/test/unit/bin/utils/path/schema.test.d.ts +2 -0
  127. package/dist/test/unit/bin/utils/path/schema.test.d.ts.map +1 -0
  128. package/dist/test/unit/bin/utils/path/schema.test.js +52 -0
  129. package/dist/test/unit/bin/utils/path/schema.test.js.map +1 -0
  130. package/dist/test/unit/should-built-and-test.test.js +40 -38
  131. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  132. package/dist/tsconfig.tsbuildinfo +1 -1
  133. package/eslint.config.mjs +9 -1
  134. package/package.json +5 -5
  135. package/test/unit/bin/build-from-local.test.ts +4 -54
  136. package/test/unit/bin/diff-changes.test.ts +97 -96
  137. package/test/unit/bin/diff.test.ts +53 -48
  138. package/test/unit/bin/git-command.test.ts +71 -0
  139. package/test/unit/bin/git.test.ts +246 -91
  140. package/test/unit/bin/logger.test.ts +55 -0
  141. package/test/unit/bin/logging.test.ts +52 -0
  142. package/test/unit/bin/utils/config/load-config.test.ts +63 -26
  143. package/test/unit/bin/utils/config/parser.test.ts +81 -27
  144. package/test/unit/bin/utils/files.test.ts +19 -33
  145. package/test/unit/bin/utils/path/repo-relative.test.ts +250 -0
  146. package/test/unit/bin/utils/path/schema.test.ts +65 -0
  147. package/test/unit/should-built-and-test.test.ts +40 -38
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
6
 
7
7
  import { CONFIG_FILE_NAME, loadActorConfig, readConfigFile } from '../../../../../bin/utils/config/load-config.js';
8
8
  import type { ResolvedActorConfig } from '../../../../../bin/utils/config/structures/base.js';
9
+ import { ExistingDir, ExistingFile, RelativeDir, RelativeFile } from '../../../../../bin/utils/path/repo-relative.js';
9
10
 
10
11
  let repoDir: string;
11
12
  let originalCwd: string;
@@ -15,6 +16,7 @@ beforeEach(async () => {
15
16
  // `realpath` because macOS hands out a symlinked temp dir, while `process.cwd()` reports the target.
16
17
  repoDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'apify-test-tools-config-')));
17
18
  process.chdir(repoDir);
19
+ await fs.mkdir('actors/shopify', { recursive: true });
18
20
  });
19
21
 
20
22
  afterEach(async () => {
@@ -37,12 +39,11 @@ const writeFiles = async (files: Record<string, string>) =>
37
39
  );
38
40
 
39
41
  describe('loadActorConfig', () => {
40
- const entry = (fields: Partial<ResolvedActorConfig> = {}): ResolvedActorConfig => ({
41
- folder: 'actors/shopify',
42
- actorFullName: 'myteam/shopify',
43
- tokenEnvVar: 'APIFY_TOKEN',
44
- overrideActorContext: undefined,
45
- ...fields,
42
+ const entry = (fields: Partial<ResolvedActorConfig> = {}) => ({
43
+ folder: new ExistingDir(fields.folder ?? 'actors/shopify'),
44
+ actorFullName: fields.actorFullName ?? 'myteam/shopify',
45
+ tokenEnvVar: fields.tokenEnvVar ?? 'APIFY_TOKEN',
46
+ overrideActorContext: fields.overrideActorContext?.map((contextPath) => new RelativeDir(contextPath)),
46
47
  });
47
48
 
48
49
  const writeActorJson = async (fields: Record<string, unknown> = {}) =>
@@ -51,31 +52,64 @@ describe('loadActorConfig', () => {
51
52
  it('resolves data coming from actor.json', async () => {
52
53
  await writeActorJson({ dockerContextDir: '..' });
53
54
 
54
- expect(loadActorConfig(entry())).toEqual({
55
- actorFullName: 'myteam/shopify',
56
- folder: 'actors/shopify',
57
- tokenEnvVar: 'APIFY_TOKEN',
58
- dockerContextDir: 'actors/shopify',
59
- contextPaths: ['actors/shopify'],
60
- });
55
+ const loaded = loadActorConfig(entry());
56
+ expect(loaded.actorFullName).toBe('myteam/shopify');
57
+ expect(loaded.folder).toBeInstanceOf(ExistingDir);
58
+ expect(loaded.folder.path).toBe('actors/shopify');
59
+ expect(loaded.actorJson.file).toBeInstanceOf(ExistingFile);
60
+ expect(loaded.actorJson.file.path).toBe('actors/shopify/.actor/actor.json');
61
+ expect(loaded.dockerContextDir).toBeInstanceOf(RelativeDir);
62
+ expect(loaded.dockerContextDir.path).toBe('actors/shopify');
63
+ expect(loaded.contextPaths.map((contextPath) => contextPath.path)).toEqual(['actors/shopify']);
61
64
  });
62
65
 
63
66
  it('defaults dockerContextDir to the actor folder when actor.json does not set it', async () => {
64
67
  await writeActorJson();
65
68
 
66
- expect(loadActorConfig(entry()).dockerContextDir).toBe('actors/shopify');
69
+ expect(loadActorConfig(entry()).dockerContextDir.path).toBe('actors/shopify');
70
+ });
71
+
72
+ it('resolves path fields from actor.json while allowing inline schemas', async () => {
73
+ await writeActorJson({
74
+ readme: '../README.md',
75
+ changelog: '../../CHANGELOG.md',
76
+ dockerfile: '../Dockerfile',
77
+ input: '../INPUT_SCHEMA.json',
78
+ output: { type: 'object' },
79
+ webServerSchema: './openapi.json',
80
+ });
81
+
82
+ const { actorJson: actorJsonPaths } = loadActorConfig(entry());
83
+ expect(actorJsonPaths.readme).toBeInstanceOf(RelativeFile);
84
+ expect(actorJsonPaths.readme?.path).toBe('actors/shopify/README.md');
85
+ expect(actorJsonPaths.changelog?.path).toBe('actors/CHANGELOG.md');
86
+ expect(actorJsonPaths.dockerfile?.path).toBe('actors/shopify/Dockerfile');
87
+ expect(actorJsonPaths.inputSchema?.path).toBe('actors/shopify/INPUT_SCHEMA.json');
88
+ expect(actorJsonPaths.outputSchema).toBeUndefined();
89
+ expect(actorJsonPaths.webServerSchema?.path).toBe('actors/shopify/.actor/openapi.json');
90
+ });
91
+
92
+ it('rejects malformed actor.json path fields', async () => {
93
+ await writeActorJson({ dockerContextDir: 123 });
94
+ expect(() => loadActorConfig(entry())).toThrow(/dockerContextDir/);
95
+
96
+ await writeActorJson({ readme: '/outside.md' });
97
+ expect(() => loadActorConfig(entry())).toThrow(/Invalid "readme"/);
98
+
99
+ await writeActorJson({ inputSchema: '/outside.json' });
100
+ expect(() => loadActorConfig(entry())).toThrow(/Invalid "inputSchema"/);
67
101
  });
68
102
 
69
103
  it('resolves dockerContextDir relative to the .actor/ folder, not the actor folder', async () => {
70
104
  await writeActorJson({ dockerContextDir: '../../..' });
71
105
 
72
- expect(loadActorConfig(entry()).dockerContextDir).toBe('');
106
+ expect(loadActorConfig(entry()).dockerContextDir.path).toBe('.');
73
107
  });
74
108
 
75
109
  it('throws when dockerContextDir resolves outside the repository root', async () => {
76
110
  await writeActorJson({ dockerContextDir: '../../../..' });
77
111
 
78
- expect(() => loadActorConfig(entry())).toThrow(/resolves outside the repository root/);
112
+ expect(() => loadActorConfig(entry())).toThrow(/escapes the repo root/);
79
113
  });
80
114
 
81
115
  it('throws when actor.json is missing', () => {
@@ -85,27 +119,30 @@ describe('loadActorConfig', () => {
85
119
  it('resolves contextPaths from overrideActorContext', async () => {
86
120
  await writeActorJson({ dockerContextDir: '../../..' });
87
121
 
88
- expect(loadActorConfig(entry({ overrideActorContext: ['actors/shopify', 'packages'] })).contextPaths).toEqual([
89
- 'actors/shopify',
90
- 'packages',
91
- ]);
122
+ expect(
123
+ loadActorConfig(entry({ overrideActorContext: ['actors/shopify', 'packages'] })).contextPaths.map(
124
+ (contextPath) => contextPath.path,
125
+ ),
126
+ ).toEqual(['actors/shopify', 'packages']);
92
127
  });
93
128
 
94
129
  it('adds the actor own folder automatically when overrideActorContext does not cover it', async () => {
95
130
  await writeActorJson();
96
131
 
97
- expect(loadActorConfig(entry({ overrideActorContext: ['code', 'shared'] })).contextPaths).toEqual([
98
- 'code',
99
- 'shared',
100
- 'actors/shopify',
101
- ]);
132
+ expect(
133
+ loadActorConfig(entry({ overrideActorContext: ['code', 'shared'] })).contextPaths.map(
134
+ (contextPath) => contextPath.path,
135
+ ),
136
+ ).toEqual(['code', 'shared', 'actors/shopify']);
102
137
  });
103
138
 
104
139
  it('allows overrideActorContext with disjoint sibling paths that all reach the actor folder via one entry', async () => {
105
140
  await writeActorJson();
106
141
 
107
142
  expect(
108
- loadActorConfig(entry({ overrideActorContext: ['actors/shopify', 'code', 'shared'] })).contextPaths,
143
+ loadActorConfig(entry({ overrideActorContext: ['actors/shopify', 'code', 'shared'] })).contextPaths.map(
144
+ (contextPath) => contextPath.path,
145
+ ),
109
146
  ).toEqual(['actors/shopify', 'code', 'shared']);
110
147
  });
111
148
 
@@ -1,12 +1,43 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
6
 
3
7
  import { _privates, parseConfigFile } from '../../../../../bin/utils/config/parser.js';
4
8
  import { CONFIG_FILE_STRATEGY } from '../../../../../bin/utils/config/structures/base.js';
5
9
  import { GROUPED_PARSER } from '../../../../../bin/utils/config/structures/grouped.js';
6
10
  import { LEGACY_PARSER } from '../../../../../bin/utils/config/structures/legacy.js';
11
+ import { ExistingDir, RelativeDir } from '../../../../../bin/utils/path/repo-relative.js';
7
12
 
8
13
  const { selectStrategy, verifyConfiguration } = _privates;
9
14
 
15
+ let repoDir: string;
16
+ const originalCwd = process.cwd();
17
+
18
+ beforeAll(async () => {
19
+ repoDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'apify-test-tools-parser-')));
20
+ for (const folder of [
21
+ 'actors/shopify',
22
+ 'actors/a',
23
+ 'actors/b',
24
+ 'actors/c',
25
+ 'actors/d',
26
+ 'bin',
27
+ 'test',
28
+ 'collide/collide',
29
+ ]) {
30
+ await fs.mkdir(path.join(repoDir, folder), { recursive: true });
31
+ }
32
+ await fs.writeFile(path.join(repoDir, 'package.json'), '{}');
33
+ process.chdir(repoDir);
34
+ });
35
+
36
+ afterAll(async () => {
37
+ process.chdir(originalCwd);
38
+ await fs.rm(repoDir, { recursive: true, force: true });
39
+ });
40
+
10
41
  const actor = (fields: Record<string, unknown> = {}) => ({
11
42
  folder: 'actors/shopify',
12
43
  actorFullName: 'myteam/shopify',
@@ -33,7 +64,7 @@ describe('selectStrategy', () => {
33
64
  });
34
65
 
35
66
  describe('verifyConfiguration', () => {
36
- it('resolves the repo root to "" and gets rid of trailing slashes', () => {
67
+ it('resolves the repo root and gets rid of trailing slashes', () => {
37
68
  const result = verifyConfiguration([
38
69
  { folder: '.', actorFullName: 'myteam/root', tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: undefined },
39
70
  {
@@ -44,15 +75,11 @@ describe('verifyConfiguration', () => {
44
75
  },
45
76
  ]);
46
77
 
47
- expect(result).toEqual([
48
- { folder: '', actorFullName: 'myteam/root', tokenEnvVar: 'APIFY_TOKEN', overrideActorContext: undefined },
49
- {
50
- folder: 'actors/shopify',
51
- actorFullName: 'myteam/shopify',
52
- tokenEnvVar: 'APIFY_TOKEN',
53
- overrideActorContext: ['packages'],
54
- },
55
- ]);
78
+ expect(result[0].folder).toBeInstanceOf(ExistingDir);
79
+ expect(result[0].folder.path).toBe('.');
80
+ expect(result[1].folder.path).toBe('actors/shopify');
81
+ expect(result[1].overrideActorContext?.[0]).toBeInstanceOf(RelativeDir);
82
+ expect(result[1].overrideActorContext?.[0].path).toBe('packages');
56
83
  });
57
84
 
58
85
  it.each([
@@ -123,26 +150,42 @@ describe('verifyConfiguration', () => {
123
150
  });
124
151
 
125
152
  describe('parseConfigFile', () => {
126
- it('validates and normalizes a plain object without touching the filesystem', () => {
127
- expect(parseConfigFile({ actors: [actor({ folder: 'actors/shopify/' })] })).toEqual([
128
- actor({ folder: 'actors/shopify', overrideActorContext: undefined }),
129
- ]);
153
+ it('validates and normalizes paths after resolving the config', () => {
154
+ const [parsed] = parseConfigFile({ actors: [actor({ folder: './bin/', overrideActorContext: ['test/'] })] });
155
+ expect(parsed.folder).toBeInstanceOf(ExistingDir);
156
+ expect(parsed.folder.path).toBe('bin');
157
+ expect(parsed.overrideActorContext?.[0]).toBeInstanceOf(RelativeDir);
158
+ expect(parsed.overrideActorContext?.[0].path).toBe('test');
159
+ });
160
+
161
+ it('accepts the repo root as an existing directory', () => {
162
+ expect(parseConfigFile({ actors: [actor({ folder: '' })] })[0].folder.path).toBe('.');
163
+ });
164
+
165
+ it('reports invalid paths at their config fields', () => {
166
+ expect(() => parseConfigFile({ actors: [actor({ folder: 'missing-actor-dir' })] })).toThrow(/\[0\]\.folder/);
167
+ expect(() => parseConfigFile({ actors: [actor({ folder: 'package.json' })] })).toThrow(/to be a directory/);
168
+ expect(() =>
169
+ parseConfigFile({ actors: [actor({ folder: 'bin', overrideActorContext: ['../outside'] })] }),
170
+ ).toThrow(/\[0\]\.overrideActorContext\[0\]/);
130
171
  });
131
172
 
132
173
  it('parses a grouped config file, which needs mode to be stripped', () => {
133
- expect(
134
- parseConfigFile({
135
- mode: CONFIG_FILE_STRATEGY.GROUPED,
136
- groups: {
137
- myteam: {
138
- actors: [{ folder: 'actors/shopify/', actorFullName: 'myteam/shopify' }],
139
- tokenEnvVar: 'APIFY_TOKEN',
140
- },
174
+ const parsed = parseConfigFile({
175
+ mode: CONFIG_FILE_STRATEGY.GROUPED,
176
+ groups: {
177
+ myteam: {
178
+ actors: [{ folder: 'actors/shopify/', actorFullName: 'myteam/shopify' }],
179
+ tokenEnvVar: 'APIFY_TOKEN',
141
180
  },
142
- }),
143
- ).toEqual([
144
- actor({ folder: 'actors/shopify', actorFullName: 'myteam/shopify', overrideActorContext: undefined }),
145
- ]);
181
+ },
182
+ });
183
+ expect(parsed).toHaveLength(1);
184
+ expect(parsed[0].folder).toBeInstanceOf(ExistingDir);
185
+ expect(parsed[0].folder.path).toBe('actors/shopify');
186
+ expect(parsed[0].actorFullName).toBe('myteam/shopify');
187
+ expect(parsed[0].tokenEnvVar).toBe('APIFY_TOKEN');
188
+ expect(parsed[0].overrideActorContext).toBeUndefined();
146
189
  });
147
190
 
148
191
  it('surfaces cross-entry violations from verifyConfiguration', () => {
@@ -155,4 +198,15 @@ describe('parseConfigFile', () => {
155
198
  }),
156
199
  ).toThrow(/Duplicate folder/);
157
200
  });
201
+
202
+ it('rejects folders that collide after resolving dot segments', () => {
203
+ expect(() =>
204
+ parseConfigFile({
205
+ actors: [
206
+ actor({ actorFullName: 'myteam/actor-a', folder: 'bin/../test' }),
207
+ actor({ actorFullName: 'myteam/actor-b', folder: 'test' }),
208
+ ],
209
+ }),
210
+ ).toThrow('Duplicate folder "test"');
211
+ });
158
212
  });
@@ -1,17 +1,25 @@
1
1
  import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
2
  import { fileURLToPath } from 'node:url';
4
3
 
5
- import { afterEach, describe, expect, it } from 'vitest';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
5
 
7
6
  import { safeReadJsonObjectFile } from '../../../../bin/utils/json-file.js';
7
+ import { ExistingFile } from '../../../../bin/utils/path/repo-relative.js';
8
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.
9
+ // These tests read real fixture files rather than mocking `node:fs`. Existence and file-kind checks
10
+ // happen when constructing ExistingFile; this reader tests JSON parsing and read failures after that.
12
11
  const FIXTURE_DIR = fileURLToPath(new URL('../../../fixtures/bin/utils/files/', import.meta.url));
13
12
 
14
- const fixture = (name: string) => path.join(FIXTURE_DIR, name);
13
+ let originalCwd: string;
14
+
15
+ beforeEach(() => {
16
+ originalCwd = process.cwd();
17
+ process.chdir(FIXTURE_DIR);
18
+ });
19
+
20
+ afterEach(() => process.chdir(originalCwd));
21
+
22
+ const fixture = (name: string): ExistingFile => new ExistingFile(name);
15
23
 
16
24
  const NESTED_FIXTURE = {
17
25
  storages: { datasets: { default: { title: 'Default' } } },
@@ -42,29 +50,6 @@ describe('safeReadJsonObjectFile', () => {
42
50
  });
43
51
 
44
52
  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
53
  // Regression guard: `lstat` would report the link itself as "not a file" and reject it, even
69
54
  // though `readFile` follows symlinks and would have read the target without complaint.
70
55
  it('follows a symlink to its target file', () => {
@@ -76,14 +61,15 @@ describe('safeReadJsonObjectFile', () => {
76
61
  // Git only tracks the executable bit, so this is the one case whose fixture cannot carry its own
77
62
  // mode — `unreadable.json` is committed readable and stripped of permissions just for this test.
78
63
  describe('unreadable file', () => {
79
- const filePath = fixture('unreadable.json');
64
+ const filePath = 'unreadable.json';
80
65
 
81
66
  afterEach(async () => fs.chmod(filePath, 0o644));
82
67
 
83
68
  it.runIf(process.getuid?.() !== 0)('fails when the file exists but cannot be read', async () => {
69
+ const file = fixture(filePath);
84
70
  await fs.chmod(filePath, 0o000);
85
71
 
86
- const result = safeReadJsonObjectFile(filePath);
72
+ const result = safeReadJsonObjectFile(file);
87
73
 
88
74
  expect(result).toMatchObject({
89
75
  success: false,
@@ -135,7 +121,7 @@ describe('safeReadJsonObjectFile', () => {
135
121
  const result = safeReadJsonObjectFile(filePath);
136
122
 
137
123
  expect(result).toMatchObject({ success: false, reason: 'invalid-json' });
138
- expect((result as { failure: Error }).failure.message).toContain(`Failed to parse ${filePath}`);
124
+ expect((result as { failure: Error }).failure.message).toContain(`Failed to parse ${filePath.path}`);
139
125
  });
140
126
 
141
127
  it('fails on an empty file', () => {
@@ -144,7 +130,7 @@ describe('safeReadJsonObjectFile', () => {
144
130
  const result = safeReadJsonObjectFile(filePath);
145
131
 
146
132
  expect(result).toMatchObject({ success: false, reason: 'invalid-json' });
147
- expect((result as { failure: Error }).failure.message).toContain(`Failed to parse ${filePath}`);
133
+ expect((result as { failure: Error }).failure.message).toContain(`Failed to parse ${filePath.path}`);
148
134
  });
149
135
 
150
136
  it.fails('reads jsonc', () => {
@@ -0,0 +1,250 @@
1
+ import path from 'node:path';
2
+
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import {
6
+ ExistingDir,
7
+ ExistingFile,
8
+ RelativeDir,
9
+ type RelativeFile,
10
+ } from '../../../../../bin/utils/path/repo-relative.js';
11
+
12
+ // Paths from the config file and from git all enter through the root.
13
+ const { ROOT } = RelativeDir;
14
+ const dir = (raw: string): RelativeDir => ROOT.joinDir(raw);
15
+ const file = (raw: string): RelativeFile => ROOT.joinFile(raw);
16
+
17
+ describe('ExistingDir', () => {
18
+ it('validates directories through both construction paths', () => {
19
+ expect(ExistingDir.initialize(dir('bin')).path).toBe('bin');
20
+ expect(new ExistingDir('./bin/').path).toBe('bin');
21
+ expect(new ExistingDir('.').path).toBe('.');
22
+ expect(() => ExistingDir.initialize(dir('missing-dir'))).toThrow('to exist');
23
+ expect(() => new ExistingDir('package.json')).toThrow('to be a directory');
24
+ });
25
+
26
+ it('preserves directory equality after existence validation', () => {
27
+ const relative = dir('./bin');
28
+ const existing = ExistingDir.initialize(relative);
29
+
30
+ expect(relative.isEqualTo(existing)).toBe(true);
31
+ expect(existing.isEqualTo(relative)).toBe(true);
32
+ expect(existing.isEqualTo(file('bin'))).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe('ExistingFile', () => {
37
+ it('validates files through initialize', () => {
38
+ expect(ExistingFile.initialize(file('package.json')).path).toBe('package.json');
39
+ expect(() => ExistingFile.initialize(file('package.json/missing'))).toThrow();
40
+ expect(() => ExistingFile.initialize(file('bin'))).toThrow('to be a file');
41
+ expect(() => new ExistingFile('missing.txt')).toThrow('to exist');
42
+ });
43
+
44
+ it('preserves file equality after existence validation', () => {
45
+ const relative = file('./package.json');
46
+ const existing = ExistingFile.initialize(relative);
47
+
48
+ expect(relative.isEqualTo(existing)).toBe(true);
49
+ expect(existing.isEqualTo(relative)).toBe(true);
50
+ expect(existing.isEqualTo(ExistingFile.initialize(file('package.json')))).toBe(true);
51
+ expect(existing.isEqualTo(file('other.json'))).toBe(false);
52
+ expect(existing.isEqualTo(dir('package.json'))).toBe(false);
53
+ expect(dir('package.json').isEqualTo(existing)).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe('RelativeDir', () => {
58
+ describe('ROOT', () => {
59
+ it('is a RelativeDir spelled "."', () => {
60
+ expect(ROOT).toBeInstanceOf(RelativeDir);
61
+ expect(ROOT.toString()).toBe('.');
62
+ });
63
+
64
+ it.each(['', '.', './', './/', 'a/..'])('is what %j parses to', (raw) => {
65
+ expect(dir(raw).isEqualTo(ROOT)).toBe(true);
66
+ expect(dir(raw).toString()).toBe('.');
67
+ });
68
+ });
69
+
70
+ describe('parsing (ROOT.joinDir)', () => {
71
+ it.each(['actors/foo', './actors/foo', 'actors/foo/', 'actors//foo', './actors/./foo//', 'actors/bar/../foo'])(
72
+ 'normalizes %j to actors/foo',
73
+ (raw) => {
74
+ expect(dir(raw).toString()).toBe('actors/foo');
75
+ },
76
+ );
77
+
78
+ it.each(['..', '../x', 'actors/../../x', './..'])('rejects %j because it escapes the repo root', (raw) => {
79
+ expect(() => dir(raw)).toThrow('escapes the repo root');
80
+ });
81
+
82
+ it('rejects absolute paths', () => {
83
+ expect(() => file('/etc/passwd')).toThrow('got absolute path "/etc/passwd"');
84
+ });
85
+
86
+ it('rejects absolute paths even when they point inside the working directory', () => {
87
+ expect(() => dir(path.join(process.cwd(), 'actors/foo'))).toThrow('got absolute path');
88
+ });
89
+
90
+ it('reports the path as written, not its normalized form', () => {
91
+ expect(() => dir('a/../../x')).toThrow();
92
+ });
93
+
94
+ it('keeps a directory named "..foo", which does not escape the repo root', () => {
95
+ expect(dir('..foo/bar').toString()).toBe('..foo/bar');
96
+ });
97
+
98
+ it('keeps case, since git paths are case-sensitive', () => {
99
+ expect(dir('Actors/Foo').isEqualTo(dir('actors/foo'))).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('joinDir / joinFile', () => {
104
+ const actorDir = dir('actors/foo');
105
+ const actorDotDir = dir('actors/foo/.actor');
106
+
107
+ it('resolves relative to the path it is called on', () => {
108
+ expect(actorDir.joinDir('src').toString()).toBe('actors/foo/src');
109
+ expect(actorDir.joinFile('src/main.ts').toString()).toBe('actors/foo/src/main.ts');
110
+ });
111
+
112
+ it('resolves a dockerContextDir the way actor.json means it', () => {
113
+ expect(actorDotDir.joinDir('..').toString()).toBe('actors/foo');
114
+ expect(actorDotDir.joinDir('../..').toString()).toBe('actors');
115
+ expect(actorDotDir.joinDir('../../..').isEqualTo(ROOT)).toBe(true);
116
+ expect(actorDotDir.joinFile('./Dockerfile').toString()).toBe('actors/foo/.actor/Dockerfile');
117
+ });
118
+
119
+ it('can climb out of the path it is called on, as long as it stays within the repo root', () => {
120
+ expect(actorDir.joinDir('../bar').toString()).toBe('actors/bar');
121
+ });
122
+
123
+ it.each(['', '.', '..', 'src/', 'src/.', 'src/..'])('rejects %j as a file path', (raw) => {
124
+ expect(() => actorDir.joinFile(raw)).toThrow('Expected a file path');
125
+ expect(() => file(`actors/foo/${raw}`)).toThrow('Expected a file path');
126
+ });
127
+
128
+ it('rejects a file path through a file, too', () => {
129
+ expect(() => file('actors/foo/main.ts').joinFile('.')).toThrow('Expected a file path');
130
+ });
131
+
132
+ it('rejects results that escape the repo root', () => {
133
+ expect(() => actorDotDir.joinDir('../../../..')).toThrow('escapes the repo root');
134
+ });
135
+
136
+ it('does not depend on the process working directory', () => {
137
+ const originalCwd = process.cwd();
138
+ try {
139
+ process.chdir(path.join(originalCwd, 'bin'));
140
+ expect(dir('actors/foo').toString()).toBe('actors/foo');
141
+ expect(actorDir.joinDir('src').toString()).toBe('actors/foo/src');
142
+ } finally {
143
+ process.chdir(originalCwd);
144
+ }
145
+ });
146
+ });
147
+
148
+ describe('isEqualTo', () => {
149
+ it('matches different spellings of the same path', () => {
150
+ expect(dir('./actors/foo/').isEqualTo(dir('actors/foo'))).toBe(true);
151
+ });
152
+
153
+ it('does not match different paths', () => {
154
+ expect(dir('actors/foo').isEqualTo(dir('actors/foobar'))).toBe(false);
155
+ });
156
+
157
+ it('does not match a file and a directory spelled the same', () => {
158
+ expect(dir('actors/foo').isEqualTo(file('actors/foo'))).toBe(false);
159
+ expect(file('actors/foo').isEqualTo(dir('actors/foo'))).toBe(false);
160
+ });
161
+ });
162
+
163
+ describe('isStrictAncestorOf', () => {
164
+ const foo = dir('actors/foo');
165
+
166
+ it('is true for paths inside it', () => {
167
+ expect(foo.isStrictAncestorOf(file('actors/foo/src/main.ts'))).toBe(true);
168
+ });
169
+
170
+ it('is false for itself', () => {
171
+ expect(foo.isStrictAncestorOf(foo)).toBe(false);
172
+ });
173
+
174
+ it('is false for a sibling that shares a name prefix', () => {
175
+ expect(foo.isStrictAncestorOf(dir('actors/foobar'))).toBe(false);
176
+ });
177
+
178
+ it('makes the root an ancestor of every other path', () => {
179
+ expect(ROOT.isStrictAncestorOf(foo)).toBe(true);
180
+ expect(ROOT.isStrictAncestorOf(file('README.md'))).toBe(true);
181
+ expect(ROOT.isStrictAncestorOf(ROOT)).toBe(false);
182
+ });
183
+ });
184
+
185
+ describe('contains', () => {
186
+ const foo = dir('actors/foo');
187
+
188
+ it('matches itself and paths inside it', () => {
189
+ expect(foo.contains(foo)).toBe(true);
190
+ expect(foo.contains(file('actors/foo/src/main.ts'))).toBe(true);
191
+ });
192
+
193
+ it('does not match a sibling that shares a name prefix', () => {
194
+ expect(foo.contains(file('actors/foobar/main.ts'))).toBe(false);
195
+ });
196
+
197
+ it('makes the root contain every path', () => {
198
+ expect(ROOT.contains(foo)).toBe(true);
199
+ expect(ROOT.contains(file('README.md'))).toBe(true);
200
+ expect(ROOT.contains(ROOT)).toBe(true);
201
+ });
202
+
203
+ it('does not contain a file spelled the same as itself', () => {
204
+ expect(foo.contains(file('actors/foo'))).toBe(false);
205
+ expect(foo.isStrictAncestorOf(file('actors/foo'))).toBe(false);
206
+ });
207
+
208
+ it('does not make a subfolder contain the root', () => {
209
+ expect(foo.contains(ROOT)).toBe(false);
210
+ });
211
+ });
212
+
213
+ describe('string output', () => {
214
+ it('renders as the path in templates', () => {
215
+ expect(`${dir('actors/foo')}/x`).toBe('actors/foo/x');
216
+ });
217
+
218
+ it('renders as a plain string in JSON', () => {
219
+ expect(JSON.stringify({ folder: dir('actors/foo'), root: ROOT })).toBe(
220
+ '{"folder":"actors/foo","root":"."}',
221
+ );
222
+ });
223
+ });
224
+
225
+ // Git and the config file always use "/", so paths must not change spelling with the host OS.
226
+ describe('on Windows', () => {
227
+ afterEach(() => {
228
+ vi.doUnmock('node:path');
229
+ vi.resetModules();
230
+ });
231
+
232
+ const importWithWin32Path = async (): Promise<typeof RelativeDir> => {
233
+ vi.resetModules();
234
+ vi.doMock('node:path', () => ({ default: path.win32, ...path.win32 }));
235
+ const module = await import('../../../../../bin/utils/path/repo-relative.js');
236
+ return module.RelativeDir;
237
+ };
238
+
239
+ it('keeps forward slashes', async () => {
240
+ const WinRelativeDir = await importWithWin32Path();
241
+ expect(WinRelativeDir.ROOT.joinDir('actors/foo').toString()).toBe('actors/foo');
242
+ });
243
+
244
+ it('matches paths inside a scope', async () => {
245
+ const WinRelativeDir = await importWithWin32Path();
246
+ const foo = WinRelativeDir.ROOT.joinDir('actors/foo');
247
+ expect(foo.contains(WinRelativeDir.ROOT.joinFile('actors/foo/src/main.ts'))).toBe(true);
248
+ });
249
+ });
250
+ });