apify-test-tools 0.10.2-beta.13 → 0.10.2-beta.15

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 (114) hide show
  1. package/.github/cli/apify-test-tools.mjs +123 -128
  2. package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +1 -1
  3. package/.github/workflows/public_platform-tests.yaml +36 -22
  4. package/.github/workflows/public_pr-build-test.yaml +7 -6
  5. package/.github/workflows/public_push-build-latest.yaml +6 -4
  6. package/.github/workflows/public_stale-issues.yaml +51 -0
  7. package/CHANGELOG.md +3 -0
  8. package/CONTRIBUTING.md +9 -7
  9. package/README.md +11 -12
  10. package/bin/actor-ids.ts +38 -0
  11. package/bin/build.ts +1 -1
  12. package/bin/git.ts +20 -92
  13. package/bin/main.ts +87 -73
  14. package/bin/notify.ts +198 -0
  15. package/bin/run-tests.ts +141 -0
  16. package/bin/slack.ts +16 -87
  17. package/bin/stdin-input.ts +98 -7
  18. package/bin/test-report.ts +141 -140
  19. package/bin/types.ts +0 -9
  20. package/dist/bin/actor-ids.d.ts +16 -0
  21. package/dist/bin/actor-ids.d.ts.map +1 -0
  22. package/dist/bin/actor-ids.js +24 -0
  23. package/dist/bin/actor-ids.js.map +1 -0
  24. package/dist/bin/build.js +1 -1
  25. package/dist/bin/build.js.map +1 -1
  26. package/dist/bin/git.d.ts +6 -16
  27. package/dist/bin/git.d.ts.map +1 -1
  28. package/dist/bin/git.js +15 -75
  29. package/dist/bin/git.js.map +1 -1
  30. package/dist/bin/main.js +80 -51
  31. package/dist/bin/main.js.map +1 -1
  32. package/dist/bin/notify.d.ts +35 -0
  33. package/dist/bin/notify.d.ts.map +1 -0
  34. package/dist/bin/notify.js +131 -0
  35. package/dist/bin/notify.js.map +1 -0
  36. package/dist/bin/run-tests.d.ts +21 -0
  37. package/dist/bin/run-tests.d.ts.map +1 -0
  38. package/dist/bin/run-tests.js +116 -0
  39. package/dist/bin/run-tests.js.map +1 -0
  40. package/dist/bin/slack.d.ts +2 -17
  41. package/dist/bin/slack.d.ts.map +1 -1
  42. package/dist/bin/slack.js +17 -58
  43. package/dist/bin/slack.js.map +1 -1
  44. package/dist/bin/stdin-input.d.ts +71 -9
  45. package/dist/bin/stdin-input.d.ts.map +1 -1
  46. package/dist/bin/stdin-input.js +76 -6
  47. package/dist/bin/stdin-input.js.map +1 -1
  48. package/dist/bin/test-report.d.ts +69 -8
  49. package/dist/bin/test-report.d.ts.map +1 -1
  50. package/dist/bin/test-report.js +76 -67
  51. package/dist/bin/test-report.js.map +1 -1
  52. package/dist/bin/types.d.ts +0 -8
  53. package/dist/bin/types.d.ts.map +1 -1
  54. package/dist/lib/lib.d.ts +0 -3
  55. package/dist/lib/lib.d.ts.map +1 -1
  56. package/dist/lib/lib.js +5 -10
  57. package/dist/lib/lib.js.map +1 -1
  58. package/dist/test/bundle/cli-bundle.test.js +40 -48
  59. package/dist/test/bundle/cli-bundle.test.js.map +1 -1
  60. package/dist/test/e2e/cli-bundle.e2e.test.js +15 -16
  61. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -1
  62. package/dist/test/e2e/git-build-release.e2e.test.js +2 -2
  63. package/dist/test/e2e/git-build-release.e2e.test.js.map +1 -1
  64. package/dist/test/unit/bin/actor-ids.test.d.ts +2 -0
  65. package/dist/test/unit/bin/actor-ids.test.d.ts.map +1 -0
  66. package/dist/test/unit/bin/actor-ids.test.js +32 -0
  67. package/dist/test/unit/bin/actor-ids.test.js.map +1 -0
  68. package/dist/test/unit/bin/build-cli.test.js +8 -3
  69. package/dist/test/unit/bin/build-cli.test.js.map +1 -1
  70. package/dist/test/unit/bin/diff-changes.test.js +1 -1
  71. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  72. package/dist/test/unit/bin/git.test.js +4 -142
  73. package/dist/test/unit/bin/git.test.js.map +1 -1
  74. package/dist/test/unit/bin/notify.test.d.ts +2 -0
  75. package/dist/test/unit/bin/notify.test.d.ts.map +1 -0
  76. package/dist/test/unit/bin/notify.test.js +222 -0
  77. package/dist/test/unit/bin/notify.test.js.map +1 -0
  78. package/dist/test/unit/bin/stdin-input.test.js +51 -9
  79. package/dist/test/unit/bin/stdin-input.test.js.map +1 -1
  80. package/dist/test/unit/bin/test-cli.test.d.ts +2 -0
  81. package/dist/test/unit/bin/test-cli.test.d.ts.map +1 -0
  82. package/dist/test/unit/bin/test-cli.test.js +115 -0
  83. package/dist/test/unit/bin/test-cli.test.js.map +1 -0
  84. package/dist/test/unit/bin/test-report.test.d.ts +2 -0
  85. package/dist/test/unit/bin/test-report.test.d.ts.map +1 -0
  86. package/dist/test/unit/bin/test-report.test.js +100 -0
  87. package/dist/test/unit/bin/test-report.test.js.map +1 -0
  88. package/dist/tsconfig.tsbuildinfo +1 -1
  89. package/docs/cli.md +131 -53
  90. package/docs/lib.md +21 -16
  91. package/docs/workflows.md +63 -23
  92. package/lib/lib.ts +5 -10
  93. package/package.json +1 -1
  94. package/test/bundle/cli-bundle.test.ts +47 -60
  95. package/test/e2e/cli-bundle.e2e.test.ts +18 -25
  96. package/test/e2e/git-build-release.e2e.test.ts +2 -2
  97. package/test/unit/bin/actor-ids.test.ts +43 -0
  98. package/test/unit/bin/build-cli.test.ts +9 -3
  99. package/test/unit/bin/diff-changes.test.ts +1 -1
  100. package/test/unit/bin/git.test.ts +3 -197
  101. package/test/unit/bin/notify.test.ts +299 -0
  102. package/test/unit/bin/stdin-input.test.ts +68 -9
  103. package/test/unit/bin/test-cli.test.ts +137 -0
  104. package/test/unit/bin/test-report.test.ts +118 -0
  105. package/dist/test/unit/bin/logging.test.d.ts +0 -2
  106. package/dist/test/unit/bin/logging.test.d.ts.map +0 -1
  107. package/dist/test/unit/bin/logging.test.js +0 -37
  108. package/dist/test/unit/bin/logging.test.js.map +0 -1
  109. package/dist/test/unit/bin/slack.test.d.ts +0 -2
  110. package/dist/test/unit/bin/slack.test.d.ts.map +0 -1
  111. package/dist/test/unit/bin/slack.test.js +0 -71
  112. package/dist/test/unit/bin/slack.test.js.map +0 -1
  113. package/test/unit/bin/logging.test.ts +0 -48
  114. package/test/unit/bin/slack.test.ts +0 -84
package/bin/main.ts CHANGED
@@ -9,12 +9,11 @@ import { hideBin } from 'yargs/helpers';
9
9
  import { getAffectedActors } from './affected-actors.js';
10
10
  import { buildActors, DEFAULT_VERSION_KEYWORD, deleteOldBuilds } from './build.js';
11
11
  import { runBuildsFromLocal } from './build-from-local.js';
12
- import { getChangedFiles, getCommits } from './git.js';
13
12
  import { logger, logLevels } from './logger.js';
13
+ import { notify, notifyOnChoices } from './notify.js';
14
14
  import { writeJson } from './output.js';
15
- import { notifyToSlack } from './slack.js';
16
- import { parseBuildInput, parseReleaseChanges, readStdin } from './stdin-input.js';
17
- import { reportTestResults } from './test-report.js';
15
+ import { runTests } from './run-tests.js';
16
+ import { parseBuildInput, parseNotifyInput, parseTestTargets, readStdin } from './stdin-input.js';
18
17
  import { readConfigFile } from './utils/config/load-config.js';
19
18
 
20
19
  /**
@@ -24,18 +23,11 @@ const middlewares = [(args: { logLevel: (typeof logLevels)[number] }) => logger.
24
23
 
25
24
  /*
26
25
  * Option groups shared across commands. Each is a plain object passed to `.options()`, so a command's
27
- * builder composes them by chaining (`y.options(gitRangeOptions).options(actorSelectionOptions)`)
26
+ * builder composes them by chaining (`y.options(affectedRangeOptions).options(actorSelectionOptions)`)
28
27
  * instead of nesting wrapper functions. `as const` keeps literals like `demandOption: true` so yargs
29
28
  * can infer non-optional types; `satisfies` validates the shape without widening it.
30
29
  */
31
30
 
32
- /** Commit range to inspect: everything on `source-branch` since it diverged from `target-branch`. */
33
- const gitRangeOptions = {
34
- 'target-branch': { type: 'string', demandOption: true },
35
- 'source-branch': { type: 'string', demandOption: true },
36
- 'base-commit': { type: 'string', demandOption: false },
37
- } as const satisfies Record<string, Options>;
38
-
39
31
  /**
40
32
  * Where to build the Actors from. Defaults to the `origin` remote, which is then checked against each
41
33
  * Actor's default version. Passing it explicitly skips that check (see assertRepoUrlMatchesDefaultVersion).
@@ -47,8 +39,7 @@ const repoUrlOptions = {
47
39
  /**
48
40
  * Actor-selection flags, applied to every command that reads the actor config so a caller can
49
41
  * narrow the set it operates on (e.g. two-stage releases: `--ignore X`, then `--actors X`).
50
- * Kept separate from `gitRangeOptions` so the read-only git commands don't advertise flags they ignore.
51
- */
42
+ * */
52
43
  const actorSelectionOptions = {
53
44
  actors: { type: 'string', array: true, default: [] as string[] },
54
45
  ignore: { type: 'string', array: true, default: [] as string[] },
@@ -110,10 +101,54 @@ const envVarsSyncOptions = {
110
101
  },
111
102
  } as const satisfies Record<string, Options>;
112
103
 
113
- /** Where `notify-release` posts the release. */
114
- const releaseSlackOptions = {
115
- 'report-slack-channel': { type: 'string', describe: 'Channel for the full report: commits and changed files' },
116
- 'release-slack-channel': { type: 'string', describe: 'Channel for the changelog additions only' },
104
+ /** Flags of `test`, which runs the repository's vitest on the Actors piped in, e.g. the builds from `build`. */
105
+ const testOptions = {
106
+ 'vitest-output-file': { type: 'string', describe: "Also keep vitest's own JSON report there, e.g. to upload it" },
107
+ reporter: {
108
+ type: 'string',
109
+ describe:
110
+ 'Vitest reporter (https://vitest.dev/guide/reporters) to add to `default` and `json`, which are always on, ' +
111
+ 'e.g. github-actions. Repeat it for more',
112
+ // A plain string, not `array: true`: yargs would fill an array option with the arguments after it too, so
113
+ // `test --reporter github-actions test/platform` would take the test path as a reporter. A repeated flag
114
+ // still comes as an array, a single one as a string, so both are normalized to an array.
115
+ coerce: (value: string | string[] | undefined) => (value === undefined ? [] : [value].flat()),
116
+ },
117
+ } as const satisfies Record<string, Options>;
118
+
119
+ /**
120
+ * Flags of `notify`, one target per call: posting to more places is calling it again. Some flags only fit one kind
121
+ * of input; `notify` fails on the others rather than ignoring them.
122
+ */
123
+ const notifyOptions = {
124
+ 'slack-channel': {
125
+ type: 'string',
126
+ describe: 'Slack channel to post to',
127
+ // yargs collects a repeated flag into an array, which would reach Slack as one comma-joined channel
128
+ coerce: (value: string | string[]) => {
129
+ if (Array.isArray(value)) {
130
+ throw new Error('--slack-channel takes one channel, call notify once for each channel');
131
+ }
132
+ return value;
133
+ },
134
+ },
135
+ on: {
136
+ choices: notifyOnChoices,
137
+ default: 'failure' as const,
138
+ describe: 'Tests only: notify only when tests fail, or always',
139
+ },
140
+ title: {
141
+ type: 'string',
142
+ describe: 'Tests only: what the message calls the run, e.g. the CI workflow name',
143
+ },
144
+ 'job-url': {
145
+ type: 'string',
146
+ describe: 'Tests only: link to the CI job, included in the message',
147
+ },
148
+ 'repo-url': {
149
+ type: 'string',
150
+ describe: 'Releases only: the repository the release is named after. Defaults to the origin remote',
151
+ },
117
152
  } as const satisfies Record<string, Options>;
118
153
 
119
154
  /** Global flags, available to every command. */
@@ -130,36 +165,6 @@ await yargs()
130
165
  .scriptName('public-actors-utils')
131
166
  .options(globalOptions)
132
167
  .middleware(middlewares)
133
- .command(
134
- 'get-commits',
135
- '',
136
- (y) => y.options(gitRangeOptions),
137
- async (args) => {
138
- const commits = await getCommits(args);
139
- writeJson(commits);
140
- },
141
- )
142
- .command(
143
- 'get-latest-commit',
144
- '',
145
- (y) => y.options(gitRangeOptions),
146
- async (args) => {
147
- const commits = await getCommits(args);
148
- if (commits.length > 0) {
149
- writeJson(commits[commits.length - 1]);
150
- }
151
- },
152
- )
153
- .command(
154
- 'get-changed-files',
155
- '',
156
- (y) => y.options(gitRangeOptions),
157
- async (args) => {
158
- const commits = await getCommits(args);
159
- const changedFiles = await getChangedFiles(commits);
160
- writeJson(changedFiles);
161
- },
162
- )
163
168
  .command(
164
169
  'get-actor-configs',
165
170
  '',
@@ -178,17 +183,43 @@ await yargs()
178
183
  },
179
184
  )
180
185
  .command(
181
- 'report-tests',
182
- '',
186
+ 'test [filters..]',
187
+ 'Run the tests of the Actors listed on stdin (builds from build, or get-actor-configs for default builds), and print a report for notify',
183
188
  (y) =>
184
189
  y
185
- .options(dryRunOptions)
186
- .option('report-file', { type: 'string', demandOption: true })
187
- .option('report-slack-channel', { type: 'string' })
188
- .option('job-url', { type: 'string' })
189
- .option('workflow-name', { type: 'string' }),
190
+ .positional('filters', {
191
+ type: 'string',
192
+ array: true,
193
+ default: ['test/platform'],
194
+ describe: 'Test files to run, as vitest filters',
195
+ })
196
+ .options(testOptions),
197
+ async (args) => {
198
+ const report = await runTests({
199
+ filters: args.filters,
200
+ targets: parseTestTargets(await readStdin()),
201
+ vitestOutputFile: args.vitestOutputFile,
202
+ reporters: args.reporter,
203
+ });
204
+ writeJson(report);
205
+ // The report is printed either way, so `notify` later in the pipe still hears about the failure
206
+ if (!report.success) process.exitCode = 1;
207
+ },
208
+ )
209
+ .command(
210
+ 'notify',
211
+ 'Tell the target about a test run (the output of test) or a release (of get-affected-actors) on stdin',
212
+ (y) => y.options(notifyOptions).options(dryRunOptions),
190
213
  async (args) => {
191
- await reportTestResults(args);
214
+ await notify({
215
+ input: parseNotifyInput(await readStdin()),
216
+ slackChannel: args.slackChannel,
217
+ on: args.on,
218
+ title: args.title,
219
+ jobUrl: args.jobUrl,
220
+ repoUrl: args.repoUrl,
221
+ dryRun: args.dryRun,
222
+ });
192
223
  },
193
224
  )
194
225
  .command(
@@ -210,23 +241,6 @@ await yargs()
210
241
  writeJson(builds);
211
242
  },
212
243
  )
213
- .command(
214
- 'notify-release',
215
- 'Announce a release on Slack, from the get-affected-actors output on stdin',
216
- (y) => y.options(releaseSlackOptions).options(repoUrlOptions).options(dryRunOptions),
217
- async (args) => {
218
- const { commits, changedFiles, changelog } = parseReleaseChanges(await readStdin());
219
- await notifyToSlack({
220
- commits,
221
- changedFiles,
222
- changelog,
223
- repoUrl: args.repoUrl,
224
- dryRun: args.dryRun,
225
- reportSlackChannel: args.reportSlackChannel,
226
- releaseSlackChannel: args.releaseSlackChannel,
227
- });
228
- },
229
- )
230
244
  .command(
231
245
  'build-from-local',
232
246
  '',
package/bin/notify.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { getCommitUrl, getRepoName, resolveRepoUrl } from './git.js';
2
+ import { logger } from './logger.js';
3
+ import { getSlackClient, sendingLabel, sendSlackMessage } from './slack.js';
4
+ import type { NotifyInput, ReleaseChanges } from './stdin-input.js';
5
+ import type { TestCaseReport, TestReport } from './test-report.js';
6
+
7
+ export const notifyOnChoices = ['failure', 'always'] as const;
8
+ type NotifyOn = (typeof notifyOnChoices)[number];
9
+
10
+ interface Message {
11
+ text: string;
12
+ /** Posted in the message's thread, so a long list keeps the channel readable */
13
+ details: string[];
14
+ }
15
+
16
+ /**
17
+ * A place a notification goes, one per `notify` call: a caller with more places calls `notify` once for each. A
18
+ * target checks its credentials when it is created, before we know whether there is anything to send, so a broken
19
+ * token shows up on a green run (and in dry runs) rather than on the day tests fail. Slack is the only one for now;
20
+ * another (e.g. email) is one more factory next to `createSlackTarget`.
21
+ */
22
+ interface NotificationTarget {
23
+ send(message: Message): Promise<void>;
24
+ }
25
+
26
+ const SLACK_TOKEN_ENV_VAR = 'SLACK_BOT_TOKEN';
27
+
28
+ // Long enough for any sensible commit title, short enough that one runaway title doesn't swamp the message
29
+ const MAX_COMMIT_TITLE_LENGTH = 200;
30
+
31
+ interface NotifyOptions {
32
+ input: NotifyInput;
33
+ slackChannel?: string;
34
+ /** Tests only */
35
+ on: NotifyOn;
36
+ /** Tests only: what the message calls the run, e.g. the CI workflow name */
37
+ title?: string;
38
+ /** Tests only: link to the CI job, included in the message */
39
+ jobUrl?: string;
40
+ /** Releases only, names the release. Defaults to the `origin` remote, see resolveRepoUrl */
41
+ repoUrl?: string;
42
+ dryRun: boolean;
43
+ }
44
+
45
+ /** Tells the target about what is on stdin: a test run (the output of `test`) or a release (of `get-affected-actors`). */
46
+ export const notify = async (options: NotifyOptions) => {
47
+ assertFlagsFitInput(options);
48
+ const { input, slackChannel, dryRun } = options;
49
+ // A target is created only once we know a message may go out, so a release with no commits needs no token
50
+ const createTarget = async () => {
51
+ if (!slackChannel) {
52
+ logger.info('Skipping notification, no --slack-channel passed');
53
+ return null;
54
+ }
55
+ return createSlackTarget(SLACK_TOKEN_ENV_VAR, slackChannel, dryRun);
56
+ };
57
+ if (input.kind === 'tests') {
58
+ await notifyTestReport({ ...options, report: input.report, createTarget });
59
+ } else {
60
+ await notifyRelease({ ...options, changes: input.changes, createTarget });
61
+ }
62
+ };
63
+
64
+ /** A flag the input has no use for would be dropped silently, so it fails instead: the caller meant something by it. */
65
+ const assertFlagsFitInput = ({ input, on, title, jobUrl, repoUrl }: NotifyOptions) => {
66
+ const misfits =
67
+ input.kind === 'tests'
68
+ ? [repoUrl && '--repo-url']
69
+ : [on !== 'failure' && '--on', title && '--title', jobUrl && '--job-url'];
70
+ const passed = misfits.filter(Boolean);
71
+ if (passed.length > 0) {
72
+ const what =
73
+ input.kind === 'tests'
74
+ ? 'a test report (the output of test)'
75
+ : 'a release (the output of get-affected-actors)';
76
+ throw new Error(`${passed.join(', ')} can't be used with ${what} on stdin`);
77
+ }
78
+ };
79
+
80
+ type CreateTarget = () => Promise<NotificationTarget | null>;
81
+
82
+ /** How a test run went: by default only when it failed, with `on: 'always'` always. */
83
+ const notifyTestReport = async ({
84
+ report,
85
+ on,
86
+ title,
87
+ jobUrl,
88
+ createTarget,
89
+ }: Pick<NotifyOptions, 'on' | 'title' | 'jobUrl'> & { report: TestReport; createTarget: CreateTarget }) => {
90
+ const target = await createTarget();
91
+ if (!target) return;
92
+ if (report.success && on === 'failure') {
93
+ logger.info('The tests passed, so there is nothing to notify about (pass --on always to notify anyway)');
94
+ return;
95
+ }
96
+
97
+ await target.send(formatTestMessage({ report, title: title ?? '-', jobUrl }));
98
+ };
99
+
100
+ /**
101
+ * What a release changed: its changelog additions, then its commits, each linked to its commit page. Posts nothing
102
+ * when there are no commits, which means there is nothing new to release.
103
+ */
104
+ const notifyRelease = async ({
105
+ changes: { commits, changelog },
106
+ repoUrl,
107
+ createTarget,
108
+ }: Pick<NotifyOptions, 'repoUrl'> & { changes: ReleaseChanges; createTarget: CreateTarget }) => {
109
+ if (commits.length === 0) {
110
+ logger.info('No new commits, so there is no release to announce');
111
+ return;
112
+ }
113
+ if (!changelog) {
114
+ logger.warn('No new changelog entries found, did you forget to update it?');
115
+ }
116
+ const target = await createTarget();
117
+ if (!target) return;
118
+
119
+ const resolvedRepoUrl = (await resolveRepoUrl(repoUrl)).repoUrl;
120
+ await target.send({ text: formatReleaseMessage({ commits, changelog }, resolvedRepoUrl), details: [] });
121
+ };
122
+
123
+ /** The release, named after the repository and the author of its newest commit, the pushed head. */
124
+ export const formatReleaseMessage = ({ commits, changelog }: ReleaseChanges, repoUrl: string) => {
125
+ const { author } = commits[commits.length - 1];
126
+ let text = `*${getRepoName(repoUrl)}* – New release (by ${displayName(author)}):\n\n`;
127
+ if (changelog) text += `${changelog.trim()}\n\n`;
128
+ text += '*Commits*:\n';
129
+ text += commits
130
+ .map(({ sha, author: commitAuthor, message }, index) => {
131
+ const title =
132
+ message.length > MAX_COMMIT_TITLE_LENGTH
133
+ ? `${message.slice(0, MAX_COMMIT_TITLE_LENGTH - 1)}…`
134
+ : message;
135
+ const url = getCommitUrl(repoUrl, sha);
136
+ const linked = url ? `<${url}|${escapeSlack(title)}>` : escapeSlack(title);
137
+ return `${index + 1}. ${linked} – ${displayName(commitAuthor)}`;
138
+ })
139
+ .join('\n');
140
+ return text;
141
+ };
142
+
143
+ // Git authors come as "Name<email>", Slack only needs the name
144
+ const displayName = (author: string) => author.replace(/\s*<[^>]*>$/, '');
145
+
146
+ const createSlackTarget = async (
147
+ tokenEnvVar: string,
148
+ channel: string,
149
+ dryRun: boolean,
150
+ ): Promise<NotificationTarget> => {
151
+ const slack = await getSlackClient(tokenEnvVar, dryRun);
152
+ return {
153
+ async send({ text, details }) {
154
+ logger.info(`${sendingLabel(dryRun)} slack message to channel ${channel}:\n${text}`);
155
+ if (details.length > 0) logger.info(`\tin its thread:\n\t${details.join('\n\t')}`);
156
+ if (!dryRun) await sendSlackMessage(slack, channel, text, details);
157
+ },
158
+ };
159
+ };
160
+
161
+ // Slack reads these as markup, so they have to be escaped wherever text comes from the tests
162
+ const escapeSlack = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
163
+
164
+ /** One line per failure message, linked to the run that failed, if the test started one. */
165
+ const formatFailures = ({ failureMessages, run }: TestCaseReport) => {
166
+ const link = run ? ` --- <${run.runLink}|${escapeSlack(run.actorId)}>` : '';
167
+ return failureMessages.map((message) => `${escapeSlack(message)}${link}`);
168
+ };
169
+
170
+ /**
171
+ * The message is the outcome and the first failure; the other failures go to its thread, so a channel with many
172
+ * failing runs stays readable.
173
+ */
174
+ export const formatTestMessage = ({
175
+ report,
176
+ title,
177
+ jobUrl,
178
+ }: {
179
+ report: TestReport;
180
+ title: string;
181
+ jobUrl?: string;
182
+ }) => {
183
+ const { total, failed, passed } = report.summary;
184
+ const failedAssertions = report.tests.filter(({ status }) => status === 'failed').flatMap(formatFailures);
185
+ const failures = [...failedAssertions, ...report.errors.map((error) => `Error: ${escapeSlack(error)}`)];
186
+ const jobLink = jobUrl ? ` Check <${jobUrl}|the job>.` : '';
187
+
188
+ let text = `\`${escapeSlack(title)}\``;
189
+ if (report.success) {
190
+ text += `: all ${passed} tests passed.${jobLink}`;
191
+ return { text, details: [] };
192
+ }
193
+ text += `: has ${failedAssertions.length} failed assertions. Failing test suites: ${failed}/${total}.`;
194
+ if (report.errors.length > 0) text += ` Errors outside of tests: ${report.errors.length}.`;
195
+ text += jobLink;
196
+ if (failures.length > 0) text += `\n\n${failures[0]}`;
197
+ return { text, details: failures.slice(1).map((failure) => `• ${failure}`) };
198
+ };
@@ -0,0 +1,141 @@
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 { failedRunReport, logTestReport, type TestReport, toTestReport, type VitestJsonReport } from './test-report.js';
13
+
14
+ /**
15
+ * Finds the vitest the repository installed, the way Node would resolve it from `cwd`. The CLI ships as a bundle
16
+ * that runs outside the repository's `node_modules`, so the tests keep the vitest (and the lib) version the
17
+ * repository locked. Read from `package.json` because vitest's exports don't expose its bin.
18
+ */
19
+ const resolveVitestBin = async (cwd: string): Promise<string> => {
20
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
21
+ const packageDir = path.join(dir, 'node_modules', 'vitest');
22
+ if (existsSync(path.join(packageDir, 'package.json'))) {
23
+ const { bin } = JSON.parse(await fs.readFile(path.join(packageDir, 'package.json'), 'utf8'));
24
+ return path.join(packageDir, typeof bin === 'string' ? bin : bin.vitest);
25
+ }
26
+ if (path.dirname(dir) === dir) {
27
+ throw new Error(`vitest is not installed in ${cwd}, install the repository's dependencies first`);
28
+ }
29
+ }
30
+ };
31
+
32
+ interface RunTestsOptions {
33
+ /** Vitest file filters, e.g. `test/platform/core` */
34
+ filters: string[];
35
+ /** The Actors to test, each with its build or, without one, its default build */
36
+ targets: TestTarget[];
37
+ /** Keeps vitest's own JSON report there, e.g. to upload it */
38
+ vitestOutputFile?: string;
39
+ /** Vitest reporters to add to the default one and the JSON one the report is read from, e.g. `github-actions` */
40
+ reporters?: string[];
41
+ cwd?: string;
42
+ }
43
+
44
+ /**
45
+ * Runs the repository's vitest on the given builds and returns what happened as a `TestReport`. Vitest's own output
46
+ * goes to stderr, so stdout stays free for the report. A failing test or a crashing vitest is a failed report, not
47
+ * an error: the report is how the failure gets to `notify`.
48
+ */
49
+ export const runTests = async ({
50
+ filters,
51
+ targets,
52
+ vitestOutputFile,
53
+ reporters = [],
54
+ cwd = process.cwd(),
55
+ }: RunTestsOptions): Promise<TestReport> => {
56
+ // Without it the lib skips every platform test, and a run that tested nothing would pass
57
+ if (!process.env.TESTER_APIFY_TOKEN) {
58
+ throw new Error('TESTER_APIFY_TOKEN is not set. The platform tests need it to run the Actors.');
59
+ }
60
+ const vitestBin = await resolveVitestBin(cwd);
61
+ // 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
62
+ const actorBuilds = await withActorIds(targets);
63
+
64
+ const tempDir = vitestOutputFile ? null : await fs.mkdtemp(path.join(os.tmpdir(), 'apify-test-tools-'));
65
+ const outputFile = path.resolve(cwd, vitestOutputFile ?? path.join(tempDir!, 'vitest-report.json'));
66
+ // A report left over from an earlier run must not pass for this one's
67
+ await fs.rm(outputFile, { force: true });
68
+
69
+ // Stdin is the only source of what to test, so whatever the caller's environment holds doesn't leak into the tests
70
+ const env = { ...process.env };
71
+ // Vitest writes to a pipe, so it would drop colors that the terminal it ends up in shows
72
+ if (process.stderr.isTTY && env.FORCE_COLOR === undefined && env.NO_COLOR === undefined) env.FORCE_COLOR = '1';
73
+ // The lib runs the tests of the Actors listed here, each on the build given, or on its default build without one
74
+ env.ACTOR_BUILDS = JSON.stringify(actorBuilds);
75
+ const described = actorBuilds.map(
76
+ ({ actorFullName, buildNumber }) => `${actorFullName} (${buildNumber ?? 'default build'})`,
77
+ );
78
+ logger.info(`Testing ${targets.length} Actor(s): ${described.join(', ') || 'none'}`);
79
+
80
+ const args = [
81
+ vitestBin,
82
+ 'run',
83
+ ...filters,
84
+ '--reporter=default',
85
+ '--reporter=json',
86
+ ...reporters.map((reporter) => `--reporter=${reporter}`),
87
+ `--outputFile.json=${outputFile}`,
88
+ // Platform tests mostly wait on Actor runs, not on the CPU, so they run far more at once than vitest's defaults
89
+ '--maxConcurrency=20',
90
+ '--fileParallelism',
91
+ '--maxWorkers=100',
92
+ ];
93
+ logger.debug(`Running vitest: ${args.join(' ')}`);
94
+
95
+ try {
96
+ const exitCode = await spawnVitest(args, { cwd, env });
97
+ let vitestReport: VitestJsonReport;
98
+ try {
99
+ vitestReport = JSON.parse(await fs.readFile(outputFile, 'utf8'));
100
+ } catch (error) {
101
+ logger.debug(`Could not read the vitest report: ${(error as Error).message}`);
102
+ const report = failedRunReport(
103
+ `Vitest exited with code ${exitCode} without a report, see its output above`,
104
+ );
105
+ logTestReport(report);
106
+ return report;
107
+ }
108
+ const report = toTestReport(vitestReport, { cwd, exitCode });
109
+ logTestReport(report);
110
+ return report;
111
+ } finally {
112
+ if (tempDir) await fs.rm(tempDir, { recursive: true, force: true });
113
+ }
114
+ };
115
+
116
+ /** Resolves with vitest's exit code. Signals we get (a cancelled CI job) are passed on, so vitest can clean up. */
117
+ const spawnVitest = async (args: string[], { cwd, env }: { cwd: string; env: NodeJS.ProcessEnv }) =>
118
+ new Promise<number>((resolve, reject) => {
119
+ // Vitest's stdout goes to our stderr: stdout is reserved for the report. It is piped through us rather than
120
+ // handed over, to leave out the JSON reporter's note about the temporary file it wrote.
121
+ const child = spawn(process.execPath, args, { cwd, env, stdio: ['ignore', 'pipe', 2] });
122
+ createInterface({ input: child.stdout!, crlfDelay: Infinity }).on('line', (line) => {
123
+ if (!line.startsWith('JSON report written to ')) process.stderr.write(`${line}\n`);
124
+ });
125
+ const forward = (signal: NodeJS.Signals) => child.kill(signal);
126
+ process.on('SIGINT', forward);
127
+ process.on('SIGTERM', forward);
128
+ const cleanup = () => {
129
+ process.off('SIGINT', forward);
130
+ process.off('SIGTERM', forward);
131
+ };
132
+ child.on('error', (error) => {
133
+ cleanup();
134
+ reject(new Error(`Failed to start vitest: ${error.message}`));
135
+ });
136
+ child.on('close', (code, signal) => {
137
+ cleanup();
138
+ if (signal) logger.warn(`Vitest was stopped by ${signal}`);
139
+ resolve(code ?? 1);
140
+ });
141
+ });
package/bin/slack.ts CHANGED
@@ -1,21 +1,8 @@
1
1
  import { WebClient } from '@slack/web-api';
2
2
 
3
- import { getRepoName, resolveRepoUrl } from './git.js';
4
3
  import { logger } from './logger.js';
5
- import type { Commit } from './types.js';
6
4
  import { getEnvVar } from './utils.js';
7
5
 
8
- type NotifyToSlackOptions = {
9
- changedFiles: string[];
10
- changelog: string | null;
11
- commits: Commit[];
12
- // Defaults to the `origin` remote, see resolveRepoUrl
13
- repoUrl?: string;
14
- dryRun: boolean;
15
- releaseSlackChannel?: string;
16
- reportSlackChannel?: string;
17
- };
18
-
19
6
  /**
20
7
  * Creates the client the real run posts with. A dry run posts nothing but still requires the token and checks
21
8
  * Slack accepts it (`auth.test` is read-only), so it fails wherever the real run would.
@@ -35,85 +22,27 @@ export const getSlackClient = async (tokenEnvVar: string, dryRun: boolean): Prom
35
22
 
36
23
  export const sendingLabel = (dryRun: boolean) => (dryRun ? '[DRY RUN] Would send' : 'Sending');
37
24
 
38
- // Git authors come as "Name<email>", Slack only needs the name
39
- const displayName = (author: string) => author.replace(/\s*<[^>]*>$/, '');
40
-
41
- /**
42
- * Posts what a release changed: its commits, changed files and changelog, as get-affected-actors reports them.
43
- * Posts nothing when there are no commits, which means there is nothing new to release.
44
- */
45
- export const notifyToSlack = async ({
46
- changedFiles,
47
- commits,
48
- changelog,
49
- repoUrl,
50
- dryRun,
51
- releaseSlackChannel,
52
- reportSlackChannel,
53
- }: NotifyToSlackOptions) => {
54
- if (commits.length === 0) {
55
- logger.info('No new commits, so there is no release to announce');
56
- return;
57
- }
58
- if (!changelog) {
59
- logger.warn('No new changelog entries found, did you forget to update it?');
60
- }
61
-
62
- if (!releaseSlackChannel && !reportSlackChannel) {
63
- logger.info('Skipping slack notification, no --release-slack-channel or --report-slack-channel passed');
64
- return;
65
- }
66
- const slack = await getSlackClient('SLACK_TOKEN_RELEASES_BOT', dryRun);
67
-
68
- const repository = getRepoName((await resolveRepoUrl(repoUrl)).repoUrl);
69
- // The newest commit, same as the pushed head commit
70
- const { author } = commits[commits.length - 1];
71
- let shortMessage = `*${repository}* – New release (by ${displayName(author)}):\n\n`;
72
-
73
- // This one is just for broader public that only cares about public facing changes
74
- if (changelog && releaseSlackChannel) {
75
- shortMessage += `*Additions to the changelog*:\n\n${changelog}\n`;
76
- logger.info(`=========================================`);
77
- logger.info(`**${sendingLabel(dryRun)} slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
78
- logger.info(`=========================================`);
79
- if (!dryRun) {
80
- await slack.chat.postMessage({
81
- channel: releaseSlackChannel,
82
- text: shortMessage,
83
- });
84
- }
85
- }
86
-
87
- const commitsMessage = `${commits
88
- .map(
89
- ({ author: commitAuthor, message }, index) =>
90
- `${index + 1}. Commit message: ${message}\n\tAuthor: ${displayName(commitAuthor)}.`,
91
- )
92
- .join('\n')}`;
93
- const changedFilesMessage = `*Files changed*: ${changedFiles.map((file) => `\`${file}\``).join(', ')}`;
94
- const longMessage = `${shortMessage}\n*Commit list*:\n${commitsMessage}\n\n${changedFilesMessage}`;
95
-
96
- // This one is for devs and project managers that need to know more details
97
- if (reportSlackChannel) {
98
- logger.info(`=========================================`);
99
- logger.info(`${sendingLabel(dryRun)} slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
100
- logger.info(`=========================================`);
101
- if (!dryRun) {
102
- await slack.chat.postMessage({
103
- text: longMessage,
104
- channel: reportSlackChannel,
105
- });
106
- }
107
- }
108
- };
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;
109
28
 
110
- 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[]) => {
111
31
  const { ts } = await slack.chat.postMessage({ text, channel });
112
- 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);
113
35
  await slack.chat.postMessage({
114
36
  channel,
115
37
  thread_ts: ts,
116
- 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
+ })),
117
46
  });
118
47
  }
119
48
  };