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,4 +1,6 @@
1
1
  import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
2
+ import { type DockerIgnoreMatcher, loadDockerIgnore } from './dockerignore.js';
3
+ import { findContainingScope, hoistPath, isPathWithinScope } from './path-utils.js';
2
4
  import type { ActorConfig, Commit } from './types.js';
3
5
 
4
6
  interface ShouldBuildAndTestOptions {
@@ -8,89 +10,160 @@ interface ShouldBuildAndTestOptions {
8
10
  commits: Commit[];
9
11
  }
10
12
 
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
- };
13
+ const IGNORED_TOP_LEVEL_FILES = [
14
+ '.vscode/',
15
+ '.gitignore',
16
+ '.husky/',
17
+ '.eslintrc',
18
+ 'eslint.config.mjs',
19
+ '.prettierrc',
20
+ '.editorconfig',
21
+ ];
22
+
23
+ // Expects an already-hoisted path (relative to the matched context entry, see findContainingScope).
24
+ const isIgnoredTopLevelFile = (hoistedLowercaseFilePath: string): boolean =>
25
+ IGNORED_TOP_LEVEL_FILES.some((pattern) => hoistedLowercaseFilePath.startsWith(pattern));
26
+
27
+ type FileChangeForActor =
28
+ | { impact: 'ignored' }
29
+ | { impact: 'outside-context' }
30
+ | { impact: 'cosmetic'; semanticallyVerified: boolean }
31
+ | { impact: 'functional' };
21
32
 
22
33
  /**
23
- * Also works for folders
34
+ * Classify a single file change for a single actor.
35
+ *
36
+ * Steps (in order):
37
+ * 1. CHANGELOG.md, by filename, anywhere → cosmetic. There is a single repo-wide shared changelog,
38
+ * not one per actor, so it applies to every actor regardless of context/folder.
39
+ * 2. Context matching (actorConfig.contextPaths) → outside-context if no match
40
+ * 3. Hardcoded ignore list, checked against the path hoisted relative to the matched context entry → ignored
41
+ * 4. .dockerignore filtering (patterns relative to dockerContextDir), skipped for the actor's own `.actor/`
42
+ * dir → ignored if matched
43
+ * 5. README.md by filename → cosmetic if inside the actor's own folder, otherwise ignored
44
+ * 6. .json inside the actor's own `.actor/` dir with only cosmetic schema diffs → cosmetic (semantically verified)
45
+ * 7. Everything else → functional
24
46
  */
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'; semanticallyVerified: boolean; 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 = (originalFilePath: string, actorConfigs: ActorConfig[], commits: Commit[]): FileChange => {
55
- // Lowercase for case-insensitive matching; keep original for git show (case-sensitive on Linux)
47
+ const classifyFileChange = (
48
+ originalFilePath: string,
49
+ actorConfig: ActorConfig,
50
+ commits: Commit[],
51
+ dockerIgnoreMatcher: DockerIgnoreMatcher,
52
+ ): FileChangeForActor => {
56
53
  const lowercaseFilePath = originalFilePath.toLowerCase();
57
- if (isIgnoredTopLevelFile(lowercaseFilePath)) {
54
+
55
+ // TODO: hardcodes that there's a single repo-wide changelog belonging to every actor. Should instead
56
+ // be derived from parsing actor.json (readme, changelog, schema paths), see
57
+ // https://github.com/apify/apify-test-tools/issues/106
58
+ if (lowercaseFilePath.endsWith('changelog.md')) {
59
+ return { impact: 'cosmetic', semanticallyVerified: false };
60
+ }
61
+
62
+ const lowercaseContextPaths = actorConfig.contextPaths.map((contextPath) => contextPath.toLowerCase());
63
+
64
+ const matchedContext = findContainingScope(lowercaseFilePath, lowercaseContextPaths);
65
+ if (matchedContext === undefined) {
66
+ return { impact: 'outside-context' };
67
+ }
68
+
69
+ const hoistedFilePath = hoistPath(lowercaseFilePath, matchedContext);
70
+ if (isIgnoredTopLevelFile(hoistedFilePath)) {
58
71
  return { impact: 'ignored' };
59
72
  }
60
73
 
61
- if (lowercaseFilePath.endsWith('changelog.md')) {
62
- return { impact: 'cosmetic', semanticallyVerified: false, includes: 'all-actors' };
74
+ const lowercaseFolder = actorConfig.folder.toLowerCase();
75
+ const actorDotDir = lowercaseFolder ? `${lowercaseFolder}/.actor` : '.actor';
76
+ const isUnderActorDotDir = isPathWithinScope(lowercaseFilePath, actorDotDir);
77
+
78
+ // .actor/ can legitimately be listed in .dockerignore (the Apify platform evaluates it before
79
+ // the Docker build, so excluding it from the build context is a valid caching optimization) —
80
+ // that shouldn't cause changes to .actor/ itself to be ignored here.
81
+ if (!isUnderActorDotDir && dockerIgnoreMatcher(originalFilePath)) {
82
+ return { impact: 'ignored' };
63
83
  }
64
84
 
65
- const actorFolderInfo = maybeParseActorFolder(lowercaseFilePath);
66
- if (actorFolderInfo.isActorFolder) {
67
- const actorConfigChanged = actorConfigs.find(
68
- ({ actorName }) => actorName.toLowerCase() === actorFolderInfo.actorName,
69
- );
70
- // This is some super weird case that happened once in the past but I don't remember the context anymore
71
- if (actorConfigChanged === undefined) {
72
- console.error(
73
- 'SHOULD NEVER HAPPEN: changes was found in an actor folder which no longer exists in the current commit, skipping this file',
74
- {
75
- actorName: actorFolderInfo.actorName,
76
- lowercaseFilePath,
77
- },
78
- );
79
- return { impact: 'ignored' };
80
- }
81
- if (lowercaseFilePath.endsWith('readme.md')) {
82
- return { impact: 'cosmetic', semanticallyVerified: false, includes: actorConfigChanged };
85
+ const isInActorFolder = isPathWithinScope(lowercaseFilePath, lowercaseFolder);
86
+
87
+ if (lowercaseFilePath.endsWith('readme.md')) {
88
+ return isInActorFolder ? { impact: 'cosmetic', semanticallyVerified: false } : { impact: 'ignored' };
89
+ }
90
+
91
+ if (lowercaseFilePath.endsWith('.json') && isUnderActorDotDir) {
92
+ const isCosmetic = isCosmeticOnlyJsonSchemaChange(commits, originalFilePath);
93
+ if (isCosmetic) {
94
+ return { impact: 'cosmetic', semanticallyVerified: true };
83
95
  }
84
- // originalFilePath must be used here (not lowercaseFilePath) — git show is case-sensitive on Linux
85
- if (lowercaseFilePath.endsWith('.json') && isCosmeticOnlyJsonSchemaChange(commits, originalFilePath)) {
86
- return { impact: 'cosmetic', semanticallyVerified: true, includes: actorConfigChanged };
96
+ }
97
+
98
+ return { impact: 'functional' };
99
+ };
100
+
101
+ /**
102
+ * Check if a file falls inside another actor's folder.
103
+ * Root actors (folder === "") never exclude files from siblings.
104
+ */
105
+ const isExcludedBySibling = (lowercaseFilePath: string, actor: ActorConfig, allActors: ActorConfig[]): boolean => {
106
+ return allActors.some(
107
+ (other) =>
108
+ other.folder !== actor.folder &&
109
+ other.folder !== '' &&
110
+ isPathWithinScope(lowercaseFilePath, other.folder.toLowerCase()),
111
+ );
112
+ };
113
+
114
+ type ActorChangeEntry = {
115
+ actorConfig: ActorConfig;
116
+ files: string[];
117
+ };
118
+
119
+ type ChangeGroup = { actors: string[]; files: string[] };
120
+
121
+ /**
122
+ * Maps each changed file to the set of actor names it triggered a change for.
123
+ */
124
+ const buildFileToActorsMap = (actorsChangedMap: Map<string, ActorChangeEntry>): Map<string, Set<string>> => {
125
+ const fileToActors = new Map<string, Set<string>>();
126
+ for (const { actorConfig, files } of actorsChangedMap.values()) {
127
+ for (const file of files) {
128
+ const actors = fileToActors.get(file) ?? new Set<string>();
129
+ actors.add(actorConfig.actorFullName);
130
+ fileToActors.set(file, actors);
87
131
  }
132
+ }
133
+ return fileToActors;
134
+ };
88
135
 
89
- return { impact: 'functional', includes: actorConfigChanged };
136
+ /**
137
+ * Groups files by their identical actor-set (files triggering a change for the exact same
138
+ * actors are grouped together), then orders the groups by descending actor-set size
139
+ * (most-shared groups first), breaking ties alphabetically by actor names.
140
+ */
141
+ const groupFilesByActorSet = (fileToActors: Map<string, Set<string>>): ChangeGroup[] => {
142
+ const groupsByKey = new Map<string, ChangeGroup>();
143
+ for (const [file, actorsSet] of fileToActors) {
144
+ const actors = Array.from(actorsSet).sort();
145
+ const key = actors.join(',');
146
+ const group = groupsByKey.get(key) ?? { actors, files: [] };
147
+ group.files.push(file);
148
+ groupsByKey.set(key, group);
90
149
  }
91
150
 
92
- // For any other files, we assume they can interact with the code
93
- return { impact: 'functional', includes: 'all-actors' };
151
+ return Array.from(groupsByKey.values()).sort((groupA, groupB) => {
152
+ if (groupB.actors.length !== groupA.actors.length) {
153
+ return groupB.actors.length - groupA.actors.length;
154
+ }
155
+ return groupA.actors.join(',').localeCompare(groupB.actors.join(','));
156
+ });
157
+ };
158
+
159
+ const logChangeGroups = (groups: ChangeGroup[]): void => {
160
+ for (const { actors, files } of groups) {
161
+ if (actors.length > 1) {
162
+ console.error(`[DIFF]: Shared changes for actors ${actors.join(', ')}: ${files.join(', ')}`);
163
+ } else {
164
+ console.error(`[DIFF]: Changes specific to actor ${actors[0]}: ${files.join(', ')}`);
165
+ }
166
+ }
94
167
  };
95
168
 
96
169
  export const getChangedActors = ({
@@ -99,72 +172,40 @@ export const getChangedActors = ({
99
172
  isLatest = false,
100
173
  commits,
101
174
  }: ShouldBuildAndTestOptions): ActorConfig[] => {
102
- // folder -> ActorConfig
103
- const actorsChangedMap = new Map<string, ActorConfig>();
104
-
105
- const actorConfigsWithoutStandalone = actorConfigs.filter(({ isStandalone }) => !isStandalone);
175
+ const actorsChangedMap = new Map<string, ActorChangeEntry>();
106
176
 
107
- for (const originalFilePath of filepathsChanged) {
108
- const fileChange = classifyFileChange(originalFilePath, actorConfigs, commits);
109
- if (fileChange.impact === 'ignored') {
110
- continue;
111
- }
177
+ for (const actorConfig of actorConfigs) {
178
+ const dockerIgnoreMatcher = loadDockerIgnore(actorConfig.dockerContextDir);
112
179
 
113
- if (fileChange.impact === 'cosmetic' && !isLatest) {
114
- continue;
115
- }
180
+ for (const originalFilePath of filepathsChanged) {
181
+ const lowercaseFilePath = originalFilePath.toLowerCase();
116
182
 
117
- if (fileChange.includes !== 'all-actors') {
118
- actorsChangedMap.set(fileChange.includes.folder, fileChange.includes);
119
- } else if (fileChange.includes === 'all-actors') {
120
- // Standalone Actors are handled always via specific actors change, not all-actors
121
- for (const actorConfig of actorConfigsWithoutStandalone) {
122
- actorsChangedMap.set(actorConfig.folder, actorConfig);
183
+ if (isExcludedBySibling(lowercaseFilePath, actorConfig, actorConfigs)) {
184
+ continue;
123
185
  }
124
- }
125
- }
126
186
 
127
- const actorsChanged = Array.from(actorsChangedMap.values());
187
+ const change = classifyFileChange(originalFilePath, actorConfig, commits, dockerIgnoreMatcher);
128
188
 
129
- // All below here is just for logging
130
- const formatFiles = (files: string[]) => (files.length > 0 ? files.join(', ') : '<no files>');
189
+ if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
190
+ if (change.impact === 'cosmetic' && !isLatest) continue;
131
191
 
132
- const ignoredFilesChanged = filepathsChanged.filter(
133
- (file) => classifyFileChange(file, actorConfigs, commits).impact === 'ignored',
134
- );
135
- console.error(`[DIFF]: Ignored files (don't trigger test or build): ${formatFiles(ignoredFilesChanged)}`);
136
-
137
- const cosmeticChanges = filepathsChanged
138
- .map((file) => ({ file, change: classifyFileChange(file, actorConfigs, commits) }))
139
- .filter(({ change }) => change.impact === 'cosmetic') as {
140
- file: string;
141
- change: Extract<FileChange, { impact: 'cosmetic' }>;
142
- }[];
143
- const semanticallyVerifiedFiles = cosmeticChanges
144
- .filter(({ change }) => change.semanticallyVerified)
145
- .map(({ file }) => file);
146
- const inherentlyCosmeticFiles = cosmeticChanges
147
- .filter(({ change }) => !change.semanticallyVerified)
148
- .map(({ file }) => file);
149
- console.error(
150
- `[DIFF]: Cosmetic-only JSON schema changes (semantically verified, only trigger release build): ${formatFiles(semanticallyVerifiedFiles)}`,
151
- );
152
- console.error(
153
- `[DIFF]: Inherently cosmetic files (README, CHANGELOG — only trigger release build): ${formatFiles(inherentlyCosmeticFiles)}`,
154
- );
192
+ const entry = actorsChangedMap.get(actorConfig.folder) ?? { actorConfig, files: [] };
193
+ entry.files.push(originalFilePath);
194
+ actorsChangedMap.set(actorConfig.folder, entry);
195
+ }
196
+ }
155
197
 
156
- const functionalFilesChanged = filepathsChanged.filter(
157
- (file) => classifyFileChange(file, actorConfigs, commits).impact === 'functional',
158
- );
159
- console.error(`[DIFF]: Functional files (trigger test & release build): ${formatFiles(functionalFilesChanged)}`);
198
+ const actorsChanged = Array.from(actorsChangedMap.values()).map((entry) => entry.actorConfig);
199
+
200
+ // Log changes grouped by actor set, so changes shared across actors are logged once
201
+ // instead of being repeated per actor.
202
+ const fileToActors = buildFileToActorsMap(actorsChangedMap);
203
+ const groups = groupFilesByActorSet(fileToActors);
204
+ logChangeGroups(groups);
160
205
 
161
206
  if (actorsChanged.length > 0) {
162
- const miniactors = actorsChanged.filter((config) => !config.isStandalone).map((config) => config.actorName);
163
- const standaloneActors = actorsChanged
164
- .filter((config) => config.isStandalone)
165
- .map((config) => config.actorName);
166
- console.error(`[DIFF]: MiniActors to be built and tested: ${miniactors.join(', ')}`);
167
- console.error(`[DIFF]: Standalone Actors to be built and tested: ${standaloneActors.join(', ')}`);
207
+ const actors = actorsChanged.map((config) => config.actorFullName);
208
+ console.error(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
168
209
  } else {
169
210
  console.error(`[DIFF]: No relevant files changed, skipping builds and tests`);
170
211
  }
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import ignore from 'ignore';
5
+
6
+ import { hoistPath, isPathWithinScope } from './path-utils.js';
7
+
8
+ export type DockerIgnoreMatcher = (repoRelativePath: string) => boolean;
9
+
10
+ // Docker normalizes each pattern before matching, so a leading "./" (as in the common "./node_modules"
11
+ // style) is a no-op for Docker. The `ignore` package has no such normalization — it treats "./" as
12
+ // literal pattern text that can never match a real path, so a .dockerignore written in that style
13
+ // would otherwise silently match nothing. Strip it here (after any negation prefix) so the pattern
14
+ // behaves the way Docker itself would apply it.
15
+ const normalizeDockerignorePattern = (line: string): string => line.replace(/^(!?)(?:\.\/)+/, '$1');
16
+
17
+ /**
18
+ * Reads `.dockerignore` from `absoluteRootDir` and returns a matcher for paths relative to
19
+ * `hoistFrom`. `hoistFrom` defaults to '', meaning callers already pass paths relative to
20
+ * `absoluteRootDir` directly — isPathWithinScope/hoistPath both treat '' as "matches everything" /
21
+ * identity, so the scope-check and hoist collapse to a no-op in that case, not via a branch.
22
+ *
23
+ * Returns a no-op matcher (always returns false) when the file is absent.
24
+ */
25
+ export const buildDockerIgnoreMatcher = (absoluteRootDir: string, hoistFrom = ''): DockerIgnoreMatcher => {
26
+ let content: string;
27
+ try {
28
+ content = fs.readFileSync(path.join(absoluteRootDir, '.dockerignore'), 'utf-8');
29
+ } catch {
30
+ return () => false;
31
+ }
32
+
33
+ const matcher = ignore().add(content.split('\n').map(normalizeDockerignorePattern).join('\n'));
34
+
35
+ return (filePath: string): boolean => {
36
+ if (!isPathWithinScope(filePath.toLowerCase(), hoistFrom.toLowerCase())) {
37
+ return false;
38
+ }
39
+
40
+ return matcher.ignores(hoistPath(filePath, hoistFrom));
41
+ };
42
+ };
43
+
44
+ /**
45
+ * Load .dockerignore from the root of an actor's dockerContextDir and return a matcher
46
+ * that accepts repo-root-relative file paths. Patterns are resolved relative to
47
+ * dockerContextDir, matching Docker's own behavior.
48
+ */
49
+ export const loadDockerIgnore = (dockerContextDir: string): DockerIgnoreMatcher =>
50
+ buildDockerIgnoreMatcher(path.resolve(dockerContextDir), dockerContextDir);
package/bin/main.ts CHANGED
@@ -14,7 +14,7 @@ import { getPushData } from './github.js';
14
14
  import { notifyToSlack } from './slack.js';
15
15
  import { reportTestResults } from './test-report.js';
16
16
  import type { Config } from './types.js';
17
- import { getRepoActors, setCwd, spawnCommandInGhWorkspace } from './utils.js';
17
+ import { readConfigFile, setCwd, spawnCommandInGhWorkspace } from './utils.js';
18
18
 
19
19
  /**
20
20
  * Middlewares to be run before every command execution
@@ -44,7 +44,7 @@ const resolveChangedActors = async (
44
44
  { targetBranch, sourceBranch, baseCommit }: Config,
45
45
  { isLatest }: { isLatest: boolean },
46
46
  ) => {
47
- const actorConfigs = await getRepoActors();
47
+ const actorConfigs = await readConfigFile();
48
48
 
49
49
  // This is an optimization for the common case where a branch only has cosmetic changes but had to merge in
50
50
  // functional changes from master (being up-to-date is a CI requirement). Master is already validated, and
@@ -108,7 +108,7 @@ await yargs()
108
108
  '',
109
109
  (_) => _,
110
110
  async () => {
111
- const actorConfigs = await getRepoActors();
111
+ const actorConfigs = await readConfigFile();
112
112
  console.log(JSON.stringify(actorConfigs));
113
113
  },
114
114
  )
@@ -173,7 +173,7 @@ await yargs()
173
173
  args.pushEventPath,
174
174
  );
175
175
  const isLatest = true;
176
- const actorConfigs = await getRepoActors();
176
+ const actorConfigs = await readConfigFile();
177
177
  const actorsChanged = getChangedActors({
178
178
  filepathsChanged: changedFiles,
179
179
  actorConfigs,
@@ -215,11 +215,11 @@ await yargs()
215
215
  })
216
216
  .option('dry-run', { type: 'boolean', default: false }),
217
217
  async ({ actors, dryRun }) => {
218
- const allActorConfigs = await getRepoActors();
218
+ const allActorConfigs = await readConfigFile();
219
219
  const actorConfigs = actors
220
220
  ? actors.split(',').map((name) => {
221
221
  const trimmed = name.trim();
222
- const config = allActorConfigs.find((c) => c.actorName === trimmed);
222
+ const config = allActorConfigs.find((c) => c.actorFullName === trimmed);
223
223
  if (!config) throw new Error(`Actor "${trimmed}" not found in repo`);
224
224
  return config;
225
225
  })
@@ -233,7 +233,7 @@ await yargs()
233
233
  '',
234
234
  (_) => _,
235
235
  async () => {
236
- const actorConfigs = await getRepoActors();
236
+ const actorConfigs = await readConfigFile();
237
237
  await deleteOldBuilds(actorConfigs);
238
238
  },
239
239
  )
@@ -0,0 +1,13 @@
1
+ // filePath and scopePath are both repo-root-relative POSIX paths, already normalized (no trailing slashes).
2
+ // scopePath === '' means "matches everything" — the caller's own scope (a context path, an actor's folder,
3
+ // a sibling's folder, a docker context dir), not necessarily the repo root.
4
+ export const isPathWithinScope = (filePath: string, scopePath: string): boolean =>
5
+ scopePath === '' || filePath === scopePath || filePath.startsWith(`${scopePath}/`);
6
+
7
+ // Returns the single scopePaths entry filePath falls under, if any.
8
+ export const findContainingScope = (filePath: string, scopePaths: string[]): string | undefined =>
9
+ scopePaths.find((scopePath) => isPathWithinScope(filePath, scopePath));
10
+
11
+ // Returns filePath relative to scopePath (assumes isPathWithinScope(filePath, scopePath) is already true).
12
+ export const hoistPath = (filePath: string, scopePath: string): string =>
13
+ scopePath === '' ? filePath : filePath.slice(scopePath.length + 1);
@@ -36,7 +36,7 @@ export const reportTestResults = async ({
36
36
  }
37
37
  }
38
38
 
39
- const failedAssertions: { message: string; runLink: string; actorName: string }[] = [];
39
+ const failedAssertions: { message: string; runLink: string; actorId: string }[] = [];
40
40
 
41
41
  console.error();
42
42
  console.error(`PASSED: ${passed.length}, FAILED: ${failed.length}`);
@@ -62,7 +62,7 @@ export const reportTestResults = async ({
62
62
  ...failureMessages.map((message) => ({
63
63
  message: message.split('\n')?.[0],
64
64
  runLink: meta.runLink,
65
- actorName: meta.actorName,
65
+ actorId: meta.actorId,
66
66
  })),
67
67
  );
68
68
  }
@@ -89,10 +89,10 @@ export const reportTestResults = async ({
89
89
  const jobLink = jobUrl ? ` Check <${jobUrl}|the job>.` : '';
90
90
  let slackMessage = `\`${workflowName ?? '-'}\``;
91
91
  slackMessage += `: has ${failedAssertions.length} failed assertions. Failing test suites: ${failed.length}/${total}.${jobLink}`;
92
- slackMessage += `\n\n${failedAssertions[0].message} --- <${failedAssertions[0].runLink}|${failedAssertions[0].actorName}>`;
92
+ slackMessage += `\n\n${failedAssertions[0].message} --- <${failedAssertions[0].runLink}|${failedAssertions[0].actorId}>`;
93
93
  const blocks = failedAssertions
94
94
  .slice(1)
95
- .map(({ message, runLink, actorName }) => `• ${message} --- <${runLink}|${actorName}>`);
95
+ .map(({ message, runLink, actorId }) => `• ${message} --- <${runLink}|${actorId}>`);
96
96
 
97
97
  console.error('SLACK:', slackMessage);
98
98
  console.error('\tblocks:', blocks.join('\n\t\t'));
@@ -122,7 +122,7 @@ interface JsonAssertionResult {
122
122
  meta: {
123
123
  runId: string;
124
124
  runLink: string;
125
- actorName: string;
125
+ actorId: string;
126
126
  };
127
127
  duration?: Milliseconds | null;
128
128
  failureMessages: string[] | null;
package/bin/types.ts CHANGED
@@ -85,16 +85,28 @@ export interface GithubCommit {
85
85
  modified: string[];
86
86
  }
87
87
 
88
+ export interface ActorConfigFileEntry {
89
+ folder: string;
90
+ actorFullName: string;
91
+ tokenEnvVar: string;
92
+ overrideActorContext?: string[];
93
+ }
94
+
95
+ export interface ActorConfigFile {
96
+ actors: ActorConfigFileEntry[];
97
+ }
98
+
88
99
  export interface BuildData {
89
100
  buildId: string;
90
- actorId: string;
91
- actorName: string;
92
- // folder: string | undefined;
101
+ actorRawId: string;
102
+ actorFullName: string;
93
103
  buildNumber: string;
94
104
  }
95
105
 
96
106
  export interface ActorConfig {
97
- actorName: string;
107
+ actorFullName: string;
98
108
  folder: string;
99
- isStandalone: boolean;
109
+ tokenEnvVar: string;
110
+ dockerContextDir: string;
111
+ contextPaths: string[];
100
112
  }