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.
- package/.github/cli/apify-test-tools.mjs +123 -128
- package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +1 -1
- package/.github/workflows/public_platform-tests.yaml +36 -22
- package/.github/workflows/public_pr-build-test.yaml +7 -6
- package/.github/workflows/public_push-build-latest.yaml +6 -4
- package/.github/workflows/public_stale-issues.yaml +51 -0
- package/CHANGELOG.md +3 -0
- package/CONTRIBUTING.md +9 -7
- package/README.md +11 -12
- package/bin/actor-ids.ts +38 -0
- package/bin/build.ts +1 -1
- package/bin/git.ts +20 -92
- package/bin/main.ts +87 -73
- package/bin/notify.ts +198 -0
- package/bin/run-tests.ts +141 -0
- package/bin/slack.ts +16 -87
- package/bin/stdin-input.ts +98 -7
- package/bin/test-report.ts +141 -140
- package/bin/types.ts +0 -9
- package/dist/bin/actor-ids.d.ts +16 -0
- package/dist/bin/actor-ids.d.ts.map +1 -0
- package/dist/bin/actor-ids.js +24 -0
- package/dist/bin/actor-ids.js.map +1 -0
- package/dist/bin/build.js +1 -1
- package/dist/bin/build.js.map +1 -1
- package/dist/bin/git.d.ts +6 -16
- package/dist/bin/git.d.ts.map +1 -1
- package/dist/bin/git.js +15 -75
- package/dist/bin/git.js.map +1 -1
- package/dist/bin/main.js +80 -51
- package/dist/bin/main.js.map +1 -1
- package/dist/bin/notify.d.ts +35 -0
- package/dist/bin/notify.d.ts.map +1 -0
- package/dist/bin/notify.js +131 -0
- package/dist/bin/notify.js.map +1 -0
- package/dist/bin/run-tests.d.ts +21 -0
- package/dist/bin/run-tests.d.ts.map +1 -0
- package/dist/bin/run-tests.js +116 -0
- package/dist/bin/run-tests.js.map +1 -0
- package/dist/bin/slack.d.ts +2 -17
- package/dist/bin/slack.d.ts.map +1 -1
- package/dist/bin/slack.js +17 -58
- package/dist/bin/slack.js.map +1 -1
- package/dist/bin/stdin-input.d.ts +71 -9
- package/dist/bin/stdin-input.d.ts.map +1 -1
- package/dist/bin/stdin-input.js +76 -6
- package/dist/bin/stdin-input.js.map +1 -1
- package/dist/bin/test-report.d.ts +69 -8
- package/dist/bin/test-report.d.ts.map +1 -1
- package/dist/bin/test-report.js +76 -67
- package/dist/bin/test-report.js.map +1 -1
- package/dist/bin/types.d.ts +0 -8
- package/dist/bin/types.d.ts.map +1 -1
- package/dist/lib/lib.d.ts +0 -3
- package/dist/lib/lib.d.ts.map +1 -1
- package/dist/lib/lib.js +5 -10
- package/dist/lib/lib.js.map +1 -1
- package/dist/test/bundle/cli-bundle.test.js +40 -48
- package/dist/test/bundle/cli-bundle.test.js.map +1 -1
- package/dist/test/e2e/cli-bundle.e2e.test.js +15 -16
- package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -1
- package/dist/test/e2e/git-build-release.e2e.test.js +2 -2
- package/dist/test/e2e/git-build-release.e2e.test.js.map +1 -1
- package/dist/test/unit/bin/actor-ids.test.d.ts +2 -0
- package/dist/test/unit/bin/actor-ids.test.d.ts.map +1 -0
- package/dist/test/unit/bin/actor-ids.test.js +32 -0
- package/dist/test/unit/bin/actor-ids.test.js.map +1 -0
- package/dist/test/unit/bin/build-cli.test.js +8 -3
- package/dist/test/unit/bin/build-cli.test.js.map +1 -1
- package/dist/test/unit/bin/diff-changes.test.js +1 -1
- package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
- package/dist/test/unit/bin/git.test.js +4 -142
- package/dist/test/unit/bin/git.test.js.map +1 -1
- package/dist/test/unit/bin/notify.test.d.ts +2 -0
- package/dist/test/unit/bin/notify.test.d.ts.map +1 -0
- package/dist/test/unit/bin/notify.test.js +222 -0
- package/dist/test/unit/bin/notify.test.js.map +1 -0
- package/dist/test/unit/bin/stdin-input.test.js +51 -9
- package/dist/test/unit/bin/stdin-input.test.js.map +1 -1
- package/dist/test/unit/bin/test-cli.test.d.ts +2 -0
- package/dist/test/unit/bin/test-cli.test.d.ts.map +1 -0
- package/dist/test/unit/bin/test-cli.test.js +115 -0
- package/dist/test/unit/bin/test-cli.test.js.map +1 -0
- package/dist/test/unit/bin/test-report.test.d.ts +2 -0
- package/dist/test/unit/bin/test-report.test.d.ts.map +1 -0
- package/dist/test/unit/bin/test-report.test.js +100 -0
- package/dist/test/unit/bin/test-report.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/docs/cli.md +131 -53
- package/docs/lib.md +21 -16
- package/docs/workflows.md +63 -23
- package/lib/lib.ts +5 -10
- package/package.json +1 -1
- package/test/bundle/cli-bundle.test.ts +47 -60
- package/test/e2e/cli-bundle.e2e.test.ts +18 -25
- package/test/e2e/git-build-release.e2e.test.ts +2 -2
- package/test/unit/bin/actor-ids.test.ts +43 -0
- package/test/unit/bin/build-cli.test.ts +9 -3
- package/test/unit/bin/diff-changes.test.ts +1 -1
- package/test/unit/bin/git.test.ts +3 -197
- package/test/unit/bin/notify.test.ts +299 -0
- package/test/unit/bin/stdin-input.test.ts +68 -9
- package/test/unit/bin/test-cli.test.ts +137 -0
- package/test/unit/bin/test-report.test.ts +118 -0
- package/dist/test/unit/bin/logging.test.d.ts +0 -2
- package/dist/test/unit/bin/logging.test.d.ts.map +0 -1
- package/dist/test/unit/bin/logging.test.js +0 -37
- package/dist/test/unit/bin/logging.test.js.map +0 -1
- package/dist/test/unit/bin/slack.test.d.ts +0 -2
- package/dist/test/unit/bin/slack.test.d.ts.map +0 -1
- package/dist/test/unit/bin/slack.test.js +0 -71
- package/dist/test/unit/bin/slack.test.js.map +0 -1
- package/test/unit/bin/logging.test.ts +0 -48
- 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 {
|
|
16
|
-
import { parseBuildInput,
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
114
|
-
const
|
|
115
|
-
'
|
|
116
|
-
|
|
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
|
-
'
|
|
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
|
-
.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
};
|
package/bin/run-tests.ts
ADDED
|
@@ -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
|
-
//
|
|
39
|
-
const
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
};
|