apify-test-tools 0.10.2-beta.2 → 0.10.2-beta.21

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 (275) hide show
  1. package/.gitattributes +1 -0
  2. package/.github/actionlint.yaml +11 -0
  3. package/.github/actions/checkout-restore-dependencies/action.yaml +58 -6
  4. package/.github/cli/apify-test-tools.mjs +135 -142
  5. package/.github/scripts/mask-config-secrets.sh +17 -0
  6. package/.github/scripts/run-with-apify-tokens.mjs +63 -103
  7. package/.github/workflows/_check_code.yaml +5 -21
  8. package/.github/workflows/_move_major_tag.yaml +2 -2
  9. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +89 -8
  10. package/.github/workflows/public_platform-tests.yaml +68 -30
  11. package/.github/workflows/public_pr-build-test.yaml +77 -38
  12. package/.github/workflows/public_push-build-latest.yaml +49 -11
  13. package/.github/workflows/public_review.yaml +5 -4
  14. package/.github/workflows/public_stale-issues.yaml +51 -0
  15. package/.husky/pre-commit +4 -4
  16. package/.prettierignore +2 -1
  17. package/CHANGELOG.md +23 -0
  18. package/CONTRIBUTING.md +117 -17
  19. package/README.md +221 -595
  20. package/bin/actor-ids.ts +41 -0
  21. package/bin/affected-actors.ts +211 -0
  22. package/bin/build-from-local.ts +19 -1
  23. package/bin/build.ts +105 -39
  24. package/bin/diff-changes.ts +69 -36
  25. package/bin/diff-json-schema.ts +5 -6
  26. package/bin/git.ts +73 -175
  27. package/bin/main.ts +142 -189
  28. package/bin/notify.ts +198 -0
  29. package/bin/run-tests.ts +182 -0
  30. package/bin/slack.ts +16 -76
  31. package/bin/stdin-input.ts +156 -0
  32. package/bin/test-files-glob.ts +126 -0
  33. package/bin/test-report.ts +141 -140
  34. package/bin/types.ts +4 -7
  35. package/bin/utils/config/load-config.ts +20 -2
  36. package/bin/utils/config/parser.ts +40 -8
  37. package/bin/utils/config/structures/grouped.ts +2 -2
  38. package/bin/utils/config/structures/legacy.ts +1 -1
  39. package/dist/bin/actor-ids.d.ts +15 -0
  40. package/dist/bin/actor-ids.d.ts.map +1 -0
  41. package/dist/bin/actor-ids.js +30 -0
  42. package/dist/bin/actor-ids.js.map +1 -0
  43. package/dist/bin/affected-actors.d.ts +55 -0
  44. package/dist/bin/affected-actors.d.ts.map +1 -0
  45. package/dist/bin/affected-actors.js +142 -0
  46. package/dist/bin/affected-actors.js.map +1 -0
  47. package/dist/bin/build-from-local.d.ts +2 -1
  48. package/dist/bin/build-from-local.d.ts.map +1 -1
  49. package/dist/bin/build-from-local.js +6 -2
  50. package/dist/bin/build-from-local.js.map +1 -1
  51. package/dist/bin/build.d.ts +18 -5
  52. package/dist/bin/build.d.ts.map +1 -1
  53. package/dist/bin/build.js +74 -24
  54. package/dist/bin/build.js.map +1 -1
  55. package/dist/bin/diff-changes.d.ts +21 -5
  56. package/dist/bin/diff-changes.d.ts.map +1 -1
  57. package/dist/bin/diff-changes.js +41 -28
  58. package/dist/bin/diff-changes.js.map +1 -1
  59. package/dist/bin/diff-json-schema.d.ts +2 -2
  60. package/dist/bin/diff-json-schema.d.ts.map +1 -1
  61. package/dist/bin/diff-json-schema.js +1 -4
  62. package/dist/bin/diff-json-schema.js.map +1 -1
  63. package/dist/bin/git.d.ts +26 -34
  64. package/dist/bin/git.d.ts.map +1 -1
  65. package/dist/bin/git.js +60 -143
  66. package/dist/bin/git.js.map +1 -1
  67. package/dist/bin/main.js +126 -140
  68. package/dist/bin/main.js.map +1 -1
  69. package/dist/bin/notify.d.ts +35 -0
  70. package/dist/bin/notify.d.ts.map +1 -0
  71. package/dist/bin/notify.js +131 -0
  72. package/dist/bin/notify.js.map +1 -0
  73. package/dist/bin/run-tests.d.ts +21 -0
  74. package/dist/bin/run-tests.d.ts.map +1 -0
  75. package/dist/bin/run-tests.js +149 -0
  76. package/dist/bin/run-tests.js.map +1 -0
  77. package/dist/bin/slack.d.ts +2 -14
  78. package/dist/bin/slack.d.ts.map +1 -1
  79. package/dist/bin/slack.js +17 -46
  80. package/dist/bin/slack.js.map +1 -1
  81. package/dist/bin/stdin-input.d.ts +79 -0
  82. package/dist/bin/stdin-input.d.ts.map +1 -0
  83. package/dist/bin/stdin-input.js +124 -0
  84. package/dist/bin/stdin-input.js.map +1 -0
  85. package/dist/bin/test-files-glob.d.ts +18 -0
  86. package/dist/bin/test-files-glob.d.ts.map +1 -0
  87. package/dist/bin/test-files-glob.js +108 -0
  88. package/dist/bin/test-files-glob.js.map +1 -0
  89. package/dist/bin/test-report.d.ts +69 -8
  90. package/dist/bin/test-report.d.ts.map +1 -1
  91. package/dist/bin/test-report.js +76 -67
  92. package/dist/bin/test-report.js.map +1 -1
  93. package/dist/bin/types.d.ts +4 -7
  94. package/dist/bin/types.d.ts.map +1 -1
  95. package/dist/bin/utils/config/load-config.d.ts +8 -0
  96. package/dist/bin/utils/config/load-config.d.ts.map +1 -1
  97. package/dist/bin/utils/config/load-config.js +14 -1
  98. package/dist/bin/utils/config/load-config.js.map +1 -1
  99. package/dist/bin/utils/config/parser.d.ts +15 -2
  100. package/dist/bin/utils/config/parser.d.ts.map +1 -1
  101. package/dist/bin/utils/config/parser.js +29 -8
  102. package/dist/bin/utils/config/parser.js.map +1 -1
  103. package/dist/bin/utils/config/structures/grouped.js +2 -2
  104. package/dist/bin/utils/config/structures/grouped.js.map +1 -1
  105. package/dist/bin/utils/config/structures/legacy.js +1 -1
  106. package/dist/bin/utils/config/structures/legacy.js.map +1 -1
  107. package/dist/lib/extend-expect.d.ts.map +1 -1
  108. package/dist/lib/extend-expect.js +37 -218
  109. package/dist/lib/extend-expect.js.map +1 -1
  110. package/dist/lib/lib.d.ts +1 -5
  111. package/dist/lib/lib.d.ts.map +1 -1
  112. package/dist/lib/lib.js +19 -31
  113. package/dist/lib/lib.js.map +1 -1
  114. package/dist/lib/run-checks.d.ts +14 -0
  115. package/dist/lib/run-checks.d.ts.map +1 -0
  116. package/dist/lib/run-checks.js +82 -0
  117. package/dist/lib/run-checks.js.map +1 -0
  118. package/dist/lib/run-test-result.d.ts +3 -6
  119. package/dist/lib/run-test-result.d.ts.map +1 -1
  120. package/dist/lib/run-test-result.js +41 -54
  121. package/dist/lib/run-test-result.js.map +1 -1
  122. package/dist/test/bundle/cli-bundle.test.js +76 -56
  123. package/dist/test/bundle/cli-bundle.test.js.map +1 -1
  124. package/dist/test/bundle/token-wrapper.test.d.ts +2 -0
  125. package/dist/test/bundle/token-wrapper.test.d.ts.map +1 -0
  126. package/dist/test/bundle/token-wrapper.test.js +80 -0
  127. package/dist/test/bundle/token-wrapper.test.js.map +1 -0
  128. package/dist/test/e2e/cli-bundle.e2e.test.js +33 -68
  129. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -1
  130. package/dist/test/e2e/git-build-release.e2e.test.d.ts +2 -0
  131. package/dist/test/e2e/git-build-release.e2e.test.d.ts.map +1 -0
  132. package/dist/test/e2e/git-build-release.e2e.test.js +87 -0
  133. package/dist/test/e2e/git-build-release.e2e.test.js.map +1 -0
  134. package/dist/test/e2e/helpers.d.ts +37 -0
  135. package/dist/test/e2e/helpers.d.ts.map +1 -0
  136. package/dist/test/e2e/helpers.js +57 -0
  137. package/dist/test/e2e/helpers.js.map +1 -0
  138. package/dist/test/unit/bin/actor-filtering.test.js +7 -0
  139. package/dist/test/unit/bin/actor-filtering.test.js.map +1 -1
  140. package/dist/test/unit/bin/actor-ids.test.d.ts +2 -0
  141. package/dist/test/unit/bin/actor-ids.test.d.ts.map +1 -0
  142. package/dist/test/unit/bin/actor-ids.test.js +40 -0
  143. package/dist/test/unit/bin/actor-ids.test.js.map +1 -0
  144. package/dist/test/unit/bin/affected-actors-cli.test.d.ts +2 -0
  145. package/dist/test/unit/bin/affected-actors-cli.test.d.ts.map +1 -0
  146. package/dist/test/unit/bin/affected-actors-cli.test.js +224 -0
  147. package/dist/test/unit/bin/affected-actors-cli.test.js.map +1 -0
  148. package/dist/test/unit/bin/affected-actors.test.d.ts +2 -0
  149. package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
  150. package/dist/test/unit/bin/affected-actors.test.js +153 -0
  151. package/dist/test/unit/bin/affected-actors.test.js.map +1 -0
  152. package/dist/test/unit/bin/build-cli.test.d.ts +2 -0
  153. package/dist/test/unit/bin/build-cli.test.d.ts.map +1 -0
  154. package/dist/test/unit/bin/build-cli.test.js +49 -0
  155. package/dist/test/unit/bin/build-cli.test.js.map +1 -0
  156. package/dist/test/unit/bin/build-from-local.test.js +12 -0
  157. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  158. package/dist/test/unit/bin/build.test.js +116 -6
  159. package/dist/test/unit/bin/build.test.js.map +1 -1
  160. package/dist/test/unit/bin/compiled-cli.d.ts +6 -0
  161. package/dist/test/unit/bin/compiled-cli.d.ts.map +1 -0
  162. package/dist/test/unit/bin/compiled-cli.js +23 -0
  163. package/dist/test/unit/bin/compiled-cli.js.map +1 -0
  164. package/dist/test/unit/bin/diff-changes.test.js +168 -114
  165. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  166. package/dist/test/unit/bin/diff.test.js +25 -28
  167. package/dist/test/unit/bin/diff.test.js.map +1 -1
  168. package/dist/test/unit/bin/git.test.js +67 -288
  169. package/dist/test/unit/bin/git.test.js.map +1 -1
  170. package/dist/test/unit/bin/notify.test.d.ts +2 -0
  171. package/dist/test/unit/bin/notify.test.d.ts.map +1 -0
  172. package/dist/test/unit/bin/notify.test.js +222 -0
  173. package/dist/test/unit/bin/notify.test.js.map +1 -0
  174. package/dist/test/unit/bin/stdin-input.test.d.ts +2 -0
  175. package/dist/test/unit/bin/stdin-input.test.d.ts.map +1 -0
  176. package/dist/test/unit/bin/stdin-input.test.js +92 -0
  177. package/dist/test/unit/bin/stdin-input.test.js.map +1 -0
  178. package/dist/test/unit/bin/test-cli.test.d.ts +2 -0
  179. package/dist/test/unit/bin/test-cli.test.d.ts.map +1 -0
  180. package/dist/test/unit/bin/test-cli.test.js +165 -0
  181. package/dist/test/unit/bin/test-cli.test.js.map +1 -0
  182. package/dist/test/unit/bin/test-files-glob.test.d.ts +2 -0
  183. package/dist/test/unit/bin/test-files-glob.test.d.ts.map +1 -0
  184. package/dist/test/unit/bin/test-files-glob.test.js +102 -0
  185. package/dist/test/unit/bin/test-files-glob.test.js.map +1 -0
  186. package/dist/test/unit/bin/test-report.test.d.ts +2 -0
  187. package/dist/test/unit/bin/test-report.test.d.ts.map +1 -0
  188. package/dist/test/unit/bin/test-report.test.js +100 -0
  189. package/dist/test/unit/bin/test-report.test.js.map +1 -0
  190. package/dist/test/unit/bin/utils/config/load-config.test.js +19 -1
  191. package/dist/test/unit/bin/utils/config/load-config.test.js.map +1 -1
  192. package/dist/test/unit/bin/utils/config/parser.test.js +36 -4
  193. package/dist/test/unit/bin/utils/config/parser.test.js.map +1 -1
  194. package/dist/test/unit/bin/utils/config/structures/grouped.test.js +6 -0
  195. package/dist/test/unit/bin/utils/config/structures/grouped.test.js.map +1 -1
  196. package/dist/test/unit/bin/utils/config/structures/legacy.test.js +1 -0
  197. package/dist/test/unit/bin/utils/config/structures/legacy.test.js.map +1 -1
  198. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts +2 -0
  199. package/dist/test/unit/github-scripts/mask-config-secrets.test.d.ts.map +1 -0
  200. package/dist/test/unit/github-scripts/mask-config-secrets.test.js +50 -0
  201. package/dist/test/unit/github-scripts/mask-config-secrets.test.js.map +1 -0
  202. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts +2 -0
  203. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.d.ts.map +1 -0
  204. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js +102 -0
  205. package/dist/test/unit/github-scripts/run-with-apify-tokens.test.js.map +1 -0
  206. package/dist/test/unit/run-checks.test.d.ts +2 -0
  207. package/dist/test/unit/run-checks.test.d.ts.map +1 -0
  208. package/dist/test/unit/run-checks.test.js +62 -0
  209. package/dist/test/unit/run-checks.test.js.map +1 -0
  210. package/dist/test/unit/run-test-result.test.d.ts +2 -0
  211. package/dist/test/unit/run-test-result.test.d.ts.map +1 -0
  212. package/dist/test/unit/run-test-result.test.js +34 -0
  213. package/dist/test/unit/run-test-result.test.js.map +1 -0
  214. package/dist/test/unit/should-built-and-test.test.js +41 -38
  215. package/dist/test/unit/should-built-and-test.test.js.map +1 -1
  216. package/dist/tsconfig.tsbuildinfo +1 -1
  217. package/dist/workflow-scripts-src/run-with-apify-tokens.d.ts +3 -0
  218. package/dist/workflow-scripts-src/run-with-apify-tokens.d.ts.map +1 -0
  219. package/dist/workflow-scripts-src/run-with-apify-tokens.js +100 -0
  220. package/dist/workflow-scripts-src/run-with-apify-tokens.js.map +1 -0
  221. package/docs/cli.md +473 -0
  222. package/docs/lib.md +276 -0
  223. package/docs/workflows.md +455 -0
  224. package/eslint.config.mjs +10 -1
  225. package/lib/extend-expect.ts +48 -258
  226. package/lib/lib.ts +25 -34
  227. package/lib/run-checks.ts +101 -0
  228. package/lib/run-test-result.ts +45 -55
  229. package/package.json +4 -2
  230. package/platform-tests-investigate-and-fix/skills/assess-impact/SKILL.md +42 -0
  231. package/platform-tests-investigate-and-fix/skills/measure-flakiness/SKILL.md +4 -2
  232. package/test/bundle/cli-bundle.test.ts +97 -67
  233. package/test/bundle/token-wrapper.test.ts +110 -0
  234. package/test/e2e/cli-bundle.e2e.test.ts +60 -93
  235. package/test/e2e/git-build-release.e2e.test.ts +113 -0
  236. package/test/e2e/helpers.ts +82 -0
  237. package/test/unit/bin/actor-filtering.test.ts +8 -0
  238. package/test/unit/bin/actor-ids.test.ts +47 -0
  239. package/test/unit/bin/affected-actors-cli.test.ts +271 -0
  240. package/test/unit/bin/affected-actors.test.ts +221 -0
  241. package/test/unit/bin/build-cli.test.ts +61 -0
  242. package/test/unit/bin/build-from-local.test.ts +16 -0
  243. package/test/unit/bin/build.test.ts +149 -5
  244. package/test/unit/bin/compiled-cli.ts +23 -0
  245. package/test/unit/bin/diff-changes.test.ts +181 -114
  246. package/test/unit/bin/diff.test.ts +25 -28
  247. package/test/unit/bin/git.test.ts +76 -361
  248. package/test/unit/bin/notify.test.ts +299 -0
  249. package/test/unit/bin/stdin-input.test.ts +120 -0
  250. package/test/unit/bin/test-cli.test.ts +207 -0
  251. package/test/unit/bin/test-files-glob.test.ts +118 -0
  252. package/test/unit/bin/test-report.test.ts +118 -0
  253. package/test/unit/bin/utils/config/load-config.test.ts +26 -1
  254. package/test/unit/bin/utils/config/parser.test.ts +46 -5
  255. package/test/unit/bin/utils/config/structures/grouped.test.ts +12 -0
  256. package/test/unit/bin/utils/config/structures/legacy.test.ts +1 -0
  257. package/test/unit/github-scripts/mask-config-secrets.test.ts +61 -0
  258. package/test/unit/github-scripts/run-with-apify-tokens.test.ts +115 -0
  259. package/test/unit/run-checks.test.ts +76 -0
  260. package/test/unit/run-test-result.test.ts +44 -0
  261. package/test/unit/should-built-and-test.test.ts +46 -39
  262. package/tsconfig.json +1 -1
  263. package/vitest.config.ts +9 -0
  264. package/workflow-scripts-src/run-with-apify-tokens.ts +112 -0
  265. package/.github/scripts/check-major-tag-refs.mjs +0 -72
  266. package/dist/test/unit/bin/logging.test.d.ts +0 -2
  267. package/dist/test/unit/bin/logging.test.d.ts.map +0 -1
  268. package/dist/test/unit/bin/logging.test.js +0 -43
  269. package/dist/test/unit/bin/logging.test.js.map +0 -1
  270. package/dist/test/unit/bin/slack.test.d.ts +0 -2
  271. package/dist/test/unit/bin/slack.test.d.ts.map +0 -1
  272. package/dist/test/unit/bin/slack.test.js +0 -54
  273. package/dist/test/unit/bin/slack.test.js.map +0 -1
  274. package/test/unit/bin/logging.test.ts +0 -52
  275. package/test/unit/bin/slack.test.ts +0 -65
@@ -0,0 +1,182 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import process from 'node:process';
7
+ import { createInterface } from 'node:readline';
8
+
9
+ import { withActorIds } from './actor-ids.js';
10
+ import { logger } from './logger.js';
11
+ import type { TestTarget } from './stdin-input.js';
12
+ import { resolveTestFilesGlob } from './test-files-glob.js';
13
+ import { failedRunReport, logTestReport, type TestReport, toTestReport, type VitestJsonReport } from './test-report.js';
14
+
15
+ /**
16
+ * Finds the vitest the repository installed, the way Node would resolve it from `cwd`. The CLI ships as a bundle
17
+ * that runs outside the repository's `node_modules`, so the tests keep the vitest (and the lib) version the
18
+ * repository locked. Read from `package.json` because vitest's exports don't expose its bin.
19
+ */
20
+ const resolveVitestBin = async (cwd: string): Promise<string> => {
21
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
22
+ const packageDir = path.join(dir, 'node_modules', 'vitest');
23
+ if (existsSync(path.join(packageDir, 'package.json'))) {
24
+ const { bin } = JSON.parse(await fs.readFile(path.join(packageDir, 'package.json'), 'utf8'));
25
+ return path.join(packageDir, typeof bin === 'string' ? bin : bin.vitest);
26
+ }
27
+ if (path.dirname(dir) === dir) {
28
+ throw new Error(`vitest is not installed in ${cwd}, install the repository's dependencies first`);
29
+ }
30
+ }
31
+ };
32
+
33
+ /**
34
+ * Turns the glob into vitest filters. Vitest filters are parts of a file path, not globs, so the glob is resolved here.
35
+ * The filters are the folders that hold only matched files and the other matched files one by one, so the command
36
+ * line stays short. They go to vitest as absolute paths. Vitest also matches their path relative to the root anywhere
37
+ * in a test file's path, so a copy at `dist/test/platform/a.test.ts` would run too: vitest's own `exclude` keeps such
38
+ * copies out.
39
+ */
40
+ const resolveVitestFilters = async (testFilesGlob: string, cwd: string): Promise<string[]> => {
41
+ const { files, paths } = await resolveTestFilesGlob(testFilesGlob, cwd);
42
+ // Without filters vitest would run every test of the repo
43
+ if (files.length === 0) {
44
+ throw new Error(
45
+ `No files match --test-files-glob "${testFilesGlob}". The glob is relative to the repo root, e.g. "test/platform/**".`,
46
+ );
47
+ }
48
+ logger.info(`Running the ${files.length} file(s) that match --test-files-glob "${testFilesGlob}"`);
49
+ logger.debug(`Files: ${files.join(', ')}`);
50
+ // Vitest compares against real paths, and cwd can be a symlink (e.g. /tmp on macOS). join keeps a folder's
51
+ // trailing slash, which stops `test/platform/daily/` from also matching `test/platform/daily-without-pr/`.
52
+ const root = await fs.realpath(cwd);
53
+ return paths.map((filePath) => path.join(root, filePath));
54
+ };
55
+
56
+ interface RunTestsOptions {
57
+ /** Glob from the repo root (`cwd`) of the test files to run, e.g. `test/platform/**` */
58
+ testFilesGlob: string;
59
+ /** The Actors to test, each with its build or, without one, its default build */
60
+ targets: TestTarget[];
61
+ /** Keeps vitest's own JSON report there, e.g. to upload it */
62
+ vitestOutputFile?: string;
63
+ /** Vitest reporters to add to the default one and the JSON one the report is read from, e.g. `github-actions` */
64
+ reporters?: string[];
65
+ cwd?: string;
66
+ }
67
+
68
+ /**
69
+ * Runs the repository's vitest on the given builds and returns what happened as a `TestReport`. Vitest's own output
70
+ * goes to stderr, so stdout stays free for the report. A failing test or a crashing vitest is a failed report, not
71
+ * an error: the report is how the failure gets to `notify`.
72
+ */
73
+ export const runTests = async ({
74
+ testFilesGlob,
75
+ targets,
76
+ vitestOutputFile,
77
+ reporters = [],
78
+ cwd = process.cwd(),
79
+ }: RunTestsOptions): Promise<TestReport> => {
80
+ // Without it the lib skips every platform test, and a run that tested nothing would pass
81
+ if (!process.env.TESTER_APIFY_TOKEN) {
82
+ throw new Error('TESTER_APIFY_TOKEN is not set. The platform tests need it to run the Actors.');
83
+ }
84
+ const vitestBin = await resolveVitestBin(cwd);
85
+ const vitestFilters = await resolveVitestFilters(testFilesGlob, cwd);
86
+ // A test only runs when the ID it names its Actor by is listed, so each Actor gets both its full name and raw ID
87
+ const actorBuilds = await withActorIds(targets);
88
+
89
+ const tempDir = vitestOutputFile ? null : await fs.mkdtemp(path.join(os.tmpdir(), 'apify-test-tools-'));
90
+ const outputFile = path.resolve(cwd, vitestOutputFile ?? path.join(tempDir!, 'vitest-report.json'));
91
+ // A report left over from an earlier run must not pass for this one's
92
+ await fs.rm(outputFile, { force: true });
93
+
94
+ // Stdin is the only source of what to test, so whatever the caller's environment holds doesn't leak into the tests
95
+ const env = { ...process.env };
96
+ // Vitest writes to a pipe, so it would drop colors that the terminal it ends up in shows
97
+ if (process.stderr.isTTY && env.FORCE_COLOR === undefined && env.NO_COLOR === undefined) env.FORCE_COLOR = '1';
98
+ // The lib runs the tests of the Actors listed here, each on the build given, or on its default build without one
99
+ // Lib versions before 0.9.0 read the IDs as actorName and actorId, so they are given under both names
100
+ env.ACTOR_BUILDS = JSON.stringify(
101
+ actorBuilds.map((build) => ({ ...build, actorName: build.actorFullName, actorId: build.actorRawId })),
102
+ );
103
+ // Lib versions before 0.10.0 skip every test in their `describe` unless this is set. Newer versions read
104
+ // TESTER_APIFY_TOKEN instead and ignore it. It only unlocks `describe`: which tests run still follows ACTOR_BUILDS.
105
+ env.RUN_PLATFORM_TESTS = '1';
106
+ // In those versions this runs the tests of every Actor, whatever ACTOR_BUILDS lists
107
+ delete env.RUN_ALL_PLATFORM_TESTS;
108
+ const described = actorBuilds.map(
109
+ ({ actorFullName, actorRawId, buildNumber }) =>
110
+ `${actorFullName ?? actorRawId} (${buildNumber ?? 'default build'})`,
111
+ );
112
+ logger.info(`Testing ${targets.length} Actor(s): ${described.join(', ') || 'none'}`);
113
+
114
+ const args = [
115
+ vitestBin,
116
+ 'run',
117
+ ...vitestFilters,
118
+ '--reporter=default',
119
+ '--reporter=json',
120
+ ...reporters.map((reporter) => `--reporter=${reporter}`),
121
+ `--outputFile.json=${outputFile}`,
122
+ // Platform tests mostly wait on Actor runs, not on the CPU, so they run far more at once than vitest's defaults
123
+ '--maxConcurrency=20',
124
+ '--fileParallelism',
125
+ '--maxWorkers=100',
126
+ ];
127
+ logger.debug(`Running vitest: ${args.join(' ')}`);
128
+
129
+ try {
130
+ const exitCode = await spawnVitest(args, { cwd, env });
131
+ let vitestReport: VitestJsonReport;
132
+ try {
133
+ vitestReport = JSON.parse(await fs.readFile(outputFile, 'utf8'));
134
+ } catch (error) {
135
+ logger.debug(`Could not read the vitest report: ${(error as Error).message}`);
136
+ const report = failedRunReport(
137
+ `Vitest exited with code ${exitCode} without a report, see its output above`,
138
+ );
139
+ logTestReport(report);
140
+ return report;
141
+ }
142
+ const report = toTestReport(vitestReport, { cwd, exitCode });
143
+ logTestReport(report);
144
+ const { total, skipped } = report.summary;
145
+ if (targets.length !== 0 && total !== 0 && skipped === total) {
146
+ logger.warn(
147
+ `Every test was skipped, although ${targets.length} Actor(s) were given. Check that the tests name ` +
148
+ 'these Actors by their full name or raw ID.',
149
+ );
150
+ }
151
+ return report;
152
+ } finally {
153
+ if (tempDir) await fs.rm(tempDir, { recursive: true, force: true });
154
+ }
155
+ };
156
+
157
+ /** Resolves with vitest's exit code. Signals we get (a cancelled CI job) are passed on, so vitest can clean up. */
158
+ const spawnVitest = async (args: string[], { cwd, env }: { cwd: string; env: NodeJS.ProcessEnv }) =>
159
+ new Promise<number>((resolve, reject) => {
160
+ // Vitest's stdout goes to our stderr: stdout is reserved for the report. It is piped through us rather than
161
+ // handed over, to leave out the JSON reporter's note about the temporary file it wrote.
162
+ const child = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 2] });
163
+ createInterface({ input: child.stdout!, crlfDelay: Infinity }).on('line', (line) => {
164
+ if (!line.startsWith('JSON report written to ')) process.stderr.write(`${line}\n`);
165
+ });
166
+ const forward = (signal: NodeJS.Signals) => child.kill(signal);
167
+ process.on('SIGINT', forward);
168
+ process.on('SIGTERM', forward);
169
+ const cleanup = () => {
170
+ process.off('SIGINT', forward);
171
+ process.off('SIGTERM', forward);
172
+ };
173
+ child.on('error', (error) => {
174
+ cleanup();
175
+ reject(new Error(`Failed to start vitest: ${error.message}`));
176
+ });
177
+ child.on('close', (code, signal) => {
178
+ cleanup();
179
+ if (signal) logger.warn(`Vitest was stopped by ${signal}`);
180
+ resolve(code ?? 1);
181
+ });
182
+ });
package/bin/slack.ts CHANGED
@@ -1,20 +1,8 @@
1
1
  import { WebClient } from '@slack/web-api';
2
2
 
3
3
  import { logger } from './logger.js';
4
- import type { Commit } from './types.js';
5
4
  import { getEnvVar } from './utils.js';
6
5
 
7
- type NotifyToSlackOptions = {
8
- repository: string;
9
- changedFiles: string[];
10
- changelog: string | null;
11
- commits: Commit[];
12
- dryRun: boolean;
13
- author: string;
14
- releaseSlackChannel?: string;
15
- reportSlackChannel?: string;
16
- };
17
-
18
6
  /**
19
7
  * Creates the client the real run posts with. A dry run posts nothing but still requires the token and checks
20
8
  * Slack accepts it (`auth.test` is read-only), so it fails wherever the real run would.
@@ -34,75 +22,27 @@ export const getSlackClient = async (tokenEnvVar: string, dryRun: boolean): Prom
34
22
 
35
23
  export const sendingLabel = (dryRun: boolean) => (dryRun ? '[DRY RUN] Would send' : 'Sending');
36
24
 
37
- // Git authors come as "Name<email>", Slack only needs the name
38
- const displayName = (author: string) => author.replace(/\s*<[^>]*>$/, '');
39
-
40
- export const notifyToSlack = async ({
41
- changedFiles,
42
- commits,
43
- changelog,
44
- repository,
45
- dryRun,
46
- author,
47
- releaseSlackChannel,
48
- reportSlackChannel,
49
- }: NotifyToSlackOptions) => {
50
- if (!changelog) {
51
- logger.warn('No new changelog entries found, did you forget to update it?');
52
- }
53
-
54
- if (!releaseSlackChannel && !reportSlackChannel) {
55
- logger.info('Skipping slack notification, no --release-slack-channel or --report-slack-channel passed');
56
- return;
57
- }
58
- const slack = await getSlackClient('SLACK_TOKEN_RELEASES_BOT', dryRun);
59
-
60
- let shortMessage = `*${repository}* – New release (by ${displayName(author)}):\n\n`;
61
-
62
- // This one is just for broader public that only cares about public facing changes
63
- if (changelog && releaseSlackChannel) {
64
- shortMessage += `*Additions to the changelog*:\n\n${changelog}\n`;
65
- logger.info(`=========================================`);
66
- logger.info(`**${sendingLabel(dryRun)} slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
67
- logger.info(`=========================================`);
68
- if (!dryRun) {
69
- await slack.chat.postMessage({
70
- channel: releaseSlackChannel,
71
- text: shortMessage,
72
- });
73
- }
74
- }
75
-
76
- const commitsMessage = `${commits
77
- .map(
78
- ({ author: commitAuthor, message }, index) =>
79
- `${index + 1}. Commit message: ${message}\n\tAuthor: ${displayName(commitAuthor)}.`,
80
- )
81
- .join('\n')}`;
82
- const changedFilesMessage = `*Files changed*: ${changedFiles.map((file) => `\`${file}\``).join(', ')}`;
83
- const longMessage = `${shortMessage}\n*Commit list*:\n${commitsMessage}\n\n${changedFilesMessage}`;
84
-
85
- // This one is for devs and project managers that need to know more details
86
- if (reportSlackChannel) {
87
- logger.info(`=========================================`);
88
- logger.info(`${sendingLabel(dryRun)} slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
89
- logger.info(`=========================================`);
90
- if (!dryRun) {
91
- await slack.chat.postMessage({
92
- text: longMessage,
93
- channel: reportSlackChannel,
94
- });
95
- }
96
- }
97
- };
25
+ // Slack's limits: 50 blocks per message, 3000 characters per section
26
+ const MAX_BLOCKS_PER_MESSAGE = 50;
27
+ const MAX_SECTION_LENGTH = 3000;
98
28
 
99
- export const sendSlackMessage = async (slack: WebClient, channel: string, text: string, blocks: string[]) => {
29
+ /** Posts `text`, with `details` as sections in its thread, as many replies as Slack's block limit needs. */
30
+ export const sendSlackMessage = async (slack: WebClient, channel: string, text: string, details: string[]) => {
100
31
  const { ts } = await slack.chat.postMessage({ text, channel });
101
- if (blocks.length > 0 && ts) {
32
+ if (!ts) return;
33
+ for (let i = 0; i < details.length; i += MAX_BLOCKS_PER_MESSAGE) {
34
+ const sections = details.slice(i, i + MAX_BLOCKS_PER_MESSAGE);
102
35
  await slack.chat.postMessage({
103
36
  channel,
104
37
  thread_ts: ts,
105
- blocks: blocks.map((t) => ({ text: { type: 'mrkdwn', text: t }, type: 'section' })),
38
+ text: sections.join('\n').slice(0, MAX_SECTION_LENGTH),
39
+ blocks: sections.map((t) => ({
40
+ text: {
41
+ type: 'mrkdwn',
42
+ text: t.length > MAX_SECTION_LENGTH ? `${t.slice(0, MAX_SECTION_LENGTH - 1)}…` : t,
43
+ },
44
+ type: 'section',
45
+ })),
106
46
  });
107
47
  }
108
48
  };
@@ -0,0 +1,156 @@
1
+ import process from 'node:process';
2
+
3
+ import z, { prettifyError, type ZodType } from 'zod';
4
+
5
+ export const readStdin = async (): Promise<string> => {
6
+ // Nothing piped in: fail right away instead of waiting for someone to type
7
+ if (process.stdin.isTTY) {
8
+ throw new Error('Expected JSON on stdin, but nothing was piped in.');
9
+ }
10
+ let raw = '';
11
+ for await (const chunk of process.stdin) {
12
+ raw += chunk;
13
+ }
14
+ return raw;
15
+ };
16
+
17
+ const parseStdinJson = <T>(
18
+ raw: string,
19
+ schema: ZodType<T>,
20
+ { expected, example }: { expected: string; example: string },
21
+ ) => {
22
+ // Empty input is an error, not "nothing to do": it is what a failed command earlier in the pipe leaves behind
23
+ if (!raw.trim()) {
24
+ throw new Error(`Expected ${expected} as JSON on stdin, got nothing (${example}).`);
25
+ }
26
+ let json: unknown;
27
+ try {
28
+ json = JSON.parse(raw);
29
+ } catch (error) {
30
+ throw new Error(`The input on stdin is not valid JSON: ${(error as Error).message}`);
31
+ }
32
+ return validateStdinJson(json, schema, { expected, example });
33
+ };
34
+
35
+ const validateStdinJson = <T>(
36
+ json: unknown,
37
+ schema: ZodType<T>,
38
+ { expected, example }: { expected: string; example: string },
39
+ ) => {
40
+ const result = schema.safeParse(json);
41
+ if (!result.success) {
42
+ throw new Error(`The input on stdin is not ${expected} (${example}):\n${prettifyError(result.error)}`);
43
+ }
44
+ return result.data;
45
+ };
46
+
47
+ /**
48
+ * What `build` reads: the Actors to build. Only their names are read, the config file stays the source of truth
49
+ * for everything else about them. Other fields are ignored, so a get-affected-actors document only needs its Actor
50
+ * list picked, e.g. `jq '{ actors: .functionallyChangedActors }'`.
51
+ */
52
+ const buildInputSchema = z.object({
53
+ actors: z.array(z.object({ actorFullName: z.string() })),
54
+ });
55
+
56
+ export const parseBuildInput = (raw: string) =>
57
+ parseStdinJson(raw, buildInputSchema, {
58
+ expected: 'the Actors to build',
59
+ example: `e.g. echo '{"actors":[{"actorFullName":"owner/name"}]}' | apify-test-tools build --version-number 0.1`,
60
+ });
61
+
62
+ /**
63
+ * What `test` reads: the Actors to test, each with the build to test or, without one, its default build. The builds
64
+ * `build` and `build-from-local` print fit, and so does `get-actor-configs`, whose other fields are ignored. An Actor
65
+ * needs its full name or its raw ID, `test` looks up the other. A dry-run plan is an object on purpose, so it is
66
+ * rejected here rather than tested as if it were built.
67
+ */
68
+ const testTargetsSchema = z.array(
69
+ z
70
+ .object({
71
+ actorFullName: z.string().optional(),
72
+ buildId: z.string().optional(),
73
+ actorRawId: z.string().optional(),
74
+ buildNumber: z.string().optional(),
75
+ })
76
+ .refine(({ actorFullName, actorRawId }) => !!actorFullName || !!actorRawId, {
77
+ message: 'An Actor needs actorFullName or actorRawId',
78
+ }),
79
+ );
80
+
81
+ export type TestTarget = z.infer<typeof testTargetsSchema>[number];
82
+
83
+ const isDryRunPlan = (raw: string) => {
84
+ try {
85
+ return (JSON.parse(raw) as { dryRun?: unknown } | null)?.dryRun === true;
86
+ } catch {
87
+ // Not JSON at all, parseStdinJson says so
88
+ return false;
89
+ }
90
+ };
91
+
92
+ export const parseTestTargets = (raw: string) => {
93
+ const example =
94
+ 'e.g. ... | apify-test-tools build --version-number 0.99 | apify-test-tools test, ' +
95
+ "or apify-test-tools get-actor-configs | apify-test-tools test for every Actor's default build";
96
+ if (isDryRunPlan(raw)) {
97
+ throw new Error(`The input on stdin is a dry-run plan, not builds, so there is nothing to test (${example}).`);
98
+ }
99
+ return parseStdinJson(raw, testTargetsSchema, { expected: 'the Actors to test', example });
100
+ };
101
+
102
+ /** The report `test` prints, see TestReport. */
103
+ const testRunSchema = z.object({ actorId: z.string(), runId: z.string(), runLink: z.string() });
104
+ const testReportSchema = z.object({
105
+ kind: z.literal('test-report'),
106
+ version: z.literal(1),
107
+ success: z.boolean(),
108
+ summary: z.object({ total: z.number(), passed: z.number(), failed: z.number(), skipped: z.number() }),
109
+ tests: z.array(
110
+ z.object({
111
+ name: z.string(),
112
+ file: z.string(),
113
+ status: z.enum(['passed', 'failed', 'skipped']),
114
+ durationMs: z.number().nullable(),
115
+ failureMessages: z.array(z.string()),
116
+ run: testRunSchema.nullable(),
117
+ }),
118
+ ),
119
+ errors: z.array(z.string()),
120
+ });
121
+
122
+ /** The parts of a get-affected-actors document that describe what a release changed. Other fields are ignored. */
123
+ const releaseChangesSchema = z.object({
124
+ commits: z.array(z.object({ sha: z.string(), author: z.string(), date: z.string(), message: z.string() })),
125
+ changelog: z.string().nullable(),
126
+ });
127
+
128
+ export type ReleaseChanges = z.infer<typeof releaseChangesSchema>;
129
+
130
+ export type NotifyInput =
131
+ | { kind: 'tests'; report: z.infer<typeof testReportSchema> }
132
+ | { kind: 'release'; changes: ReleaseChanges };
133
+
134
+ /**
135
+ * What `notify` reads: a test run, as `test` reports it, or a release, as `get-affected-actors` reports it. The test
136
+ * report says so in its `kind`, a release is told by its `commits`, so either can be piped in as it is.
137
+ */
138
+ export const parseNotifyInput = (raw: string): NotifyInput => {
139
+ const example =
140
+ 'e.g. ... | apify-test-tools test | apify-test-tools notify --slack-channel "#my-channel", ' +
141
+ 'or apify-test-tools get-affected-actors --since <sha> | apify-test-tools notify --slack-channel "#my-channel"';
142
+ const expected = 'the output of test or get-affected-actors';
143
+ const json = parseStdinJson(raw, z.looseObject({}), { expected, example });
144
+ if (json.kind === 'test-report') {
145
+ const report = validateStdinJson(json, testReportSchema, { expected: 'the output of test', example });
146
+ return { kind: 'tests', report };
147
+ }
148
+ if ('commits' in json) {
149
+ const changes = validateStdinJson(json, releaseChangesSchema, {
150
+ expected: 'the output of get-affected-actors',
151
+ example,
152
+ });
153
+ return { kind: 'release', changes };
154
+ }
155
+ throw new Error(`The input on stdin is not ${expected} (${example}).`);
156
+ };
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import picomatch from 'picomatch';
5
+
6
+ /**
7
+ * Test globs are from the repo root, e.g. `test/platform/**`. `get-affected-actors` matches changed files against the
8
+ * config's `platformTestsGlob` and `test` runs the files `--test-files-glob` matches, so both read globs the same way.
9
+ */
10
+ const PICOMATCH_OPTIONS = { nocase: true, dot: true } as const;
11
+
12
+ /** Never walked: they hold no test files of the repo, and node_modules can be huge. */
13
+ const SKIPPED_DIRS = new Set(['node_modules', '.git']);
14
+
15
+ const assertRelativeGlob = (glob: string) => {
16
+ if (path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob)) {
17
+ throw new Error(`Test file globs must be relative to the repo root, got "${glob}".`);
18
+ }
19
+ };
20
+
21
+ /** Tells whether a repo-root-relative path matches the glob. Without a glob, nothing matches. */
22
+ export const createTestFilesMatcher = (glob?: string): ((filePath: string) => boolean) => {
23
+ if (!glob) return () => false;
24
+ assertRelativeGlob(glob);
25
+ const isMatch = picomatch(glob, PICOMATCH_OPTIONS);
26
+ // One argument only: picomatch's second one returns a truthy object, and Array#filter passes the index there
27
+ return (filePath) => isMatch(filePath);
28
+ };
29
+
30
+ interface Listing {
31
+ /** POSIX paths relative to `cwd` */
32
+ files: string[];
33
+ /** What wasn't walked (skipped directories, symlinks), so the folders holding them never count as fully matched */
34
+ skipped: string[];
35
+ }
36
+
37
+ /** Lists the files under `dir`. A missing `dir` has no files. */
38
+ const listFiles = async (cwd: string, dir: string): Promise<Listing> => {
39
+ let entries;
40
+ try {
41
+ entries = await fs.readdir(path.join(cwd, dir), { withFileTypes: true });
42
+ } catch (error) {
43
+ const { code } = error as NodeJS.ErrnoException;
44
+ if (code === 'ENOENT') return { files: [], skipped: [] };
45
+ // A glob without wildcards has the file itself as its base
46
+ if (code === 'ENOTDIR') return { files: [dir], skipped: [] };
47
+ throw error;
48
+ }
49
+ const listing: Listing = { files: [], skipped: [] };
50
+ for (const entry of entries) {
51
+ const entryPath = path.posix.join(dir, entry.name);
52
+ if (entry.isDirectory() && !SKIPPED_DIRS.has(entry.name)) {
53
+ const inner = await listFiles(cwd, entryPath);
54
+ listing.files.push(...inner.files);
55
+ listing.skipped.push(...inner.skipped);
56
+ } else if (entry.isFile()) {
57
+ listing.files.push(entryPath);
58
+ } else {
59
+ listing.skipped.push(entryPath);
60
+ }
61
+ }
62
+ return listing;
63
+ };
64
+
65
+ const isWithin = (dir: string, base: string) => base === '.' || dir === base || dir.startsWith(`${base}/`);
66
+
67
+ /**
68
+ * The directories from `base` down to the file's own directory, `base` first. None when `base` is the file itself,
69
+ * as for a glob without wildcards.
70
+ */
71
+ const dirsFromBase = (file: string, base: string): string[] => {
72
+ const dirs: string[] = [];
73
+ for (let dir = path.posix.dirname(file); isWithin(dir, base); dir = path.posix.dirname(dir)) {
74
+ dirs.unshift(dir);
75
+ if (dir === base || dir === '.') break;
76
+ }
77
+ return dirs;
78
+ };
79
+
80
+ /**
81
+ * Turns the matched files into as few paths as possible: a directory, with a trailing `/`, where the glob matches
82
+ * every file in it, and a single file otherwise. `test/platform/{daily,hourly}/**` gives two paths however many
83
+ * tests the folders hold, so the vitest command line stays short.
84
+ */
85
+ const toCoveringPaths = ({ files, skipped }: Listing, matched: Set<string>, base: string): string[] => {
86
+ const counts = new Map<string, { total: number; matched: number }>();
87
+ for (const file of [...files, ...skipped]) {
88
+ for (const dir of dirsFromBase(file, base)) {
89
+ const count = counts.get(dir) ?? { total: 0, matched: 0 };
90
+ count.total += 1;
91
+ if (matched.has(file)) count.matched += 1;
92
+ counts.set(dir, count);
93
+ }
94
+ }
95
+ const isCovered = (dir: string) => counts.get(dir)!.total === counts.get(dir)!.matched;
96
+ const paths = new Set<string>();
97
+ for (const file of matched) {
98
+ // The highest directory that holds only matched files, so its children are not listed one by one
99
+ const coveredDir = dirsFromBase(file, base).find(isCovered);
100
+ paths.add(coveredDir === undefined ? file : `${coveredDir}/`);
101
+ }
102
+ return [...paths].sort();
103
+ };
104
+
105
+ interface ResolvedTestFiles {
106
+ /** Every file the glob matches, as repo-root-relative POSIX paths, sorted */
107
+ files: string[];
108
+ /**
109
+ * The same files as few paths as possible: directories (ending with `/`) that hold only matched files, and the
110
+ * other matched files one by one. Sorted.
111
+ */
112
+ paths: string[];
113
+ }
114
+
115
+ /**
116
+ * Finds the files the glob matches. `cwd` is the repo root. Only the glob's static base (`test/platform` for
117
+ * `test/platform/{daily,hourly}/**`) is walked.
118
+ */
119
+ export const resolveTestFilesGlob = async (glob: string, cwd: string): Promise<ResolvedTestFiles> => {
120
+ const isMatch = createTestFilesMatcher(glob);
121
+ // normalize turns `./test` into `test`, the way git prints paths
122
+ const base = path.posix.normalize(picomatch.scan(glob).base || '.');
123
+ const listing = await listFiles(cwd, base);
124
+ const files = listing.files.filter(isMatch).sort();
125
+ return { files, paths: toCoveringPaths(listing, new Set(files), base) };
126
+ };