apify-test-tools 0.10.2-beta.1 → 0.10.2-beta.2
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/.gitattributes +2 -0
- package/.github/actions/checkout-restore-dependencies/action.yaml +13 -29
- package/.github/cli/apify-test-tools.mjs +362 -0
- package/.github/scripts/run-with-apify-tokens.mjs +2 -2
- package/.github/workflows/_check_code.yaml +25 -0
- package/.github/workflows/_move_major_tag.yaml +3 -81
- package/.github/workflows/_tests.yaml +34 -0
- package/.github/workflows/_update_release_metadata.yaml +0 -12
- package/.github/workflows/manual_move_major_tag.yaml +3 -3
- package/.github/workflows/manual_release_stable.yaml +3 -13
- package/.github/workflows/on_master.yaml +9 -5
- package/.github/workflows/on_pull_request.yaml +3 -48
- package/.github/workflows/public_platform-tests.yaml +1 -1
- package/.github/workflows/public_pr-build-test.yaml +2 -2
- package/.github/workflows/public_push-build-latest.yaml +2 -2
- package/.husky/pre-commit +5 -0
- package/.prettierignore +3 -0
- package/CHANGELOG.md +4 -0
- package/CONTRIBUTING.md +8 -18
- package/README.md +39 -44
- package/bin/build-from-local.ts +47 -13
- package/bin/build.ts +35 -19
- package/bin/main.ts +20 -5
- package/bin/slack.ts +28 -6
- package/bin/test-report.ts +6 -9
- package/bin/types.ts +9 -0
- package/dist/bin/build-from-local.d.ts +10 -2
- package/dist/bin/build-from-local.d.ts.map +1 -1
- package/dist/bin/build-from-local.js +29 -11
- package/dist/bin/build-from-local.js.map +1 -1
- package/dist/bin/build.d.ts +15 -5
- package/dist/bin/build.d.ts.map +1 -1
- package/dist/bin/build.js +24 -15
- package/dist/bin/build.js.map +1 -1
- package/dist/bin/main.js +18 -4
- package/dist/bin/main.js.map +1 -1
- package/dist/bin/slack.d.ts +8 -1
- package/dist/bin/slack.d.ts.map +1 -1
- package/dist/bin/slack.js +26 -5
- package/dist/bin/slack.js.map +1 -1
- package/dist/bin/test-report.d.ts.map +1 -1
- package/dist/bin/test-report.js +6 -8
- package/dist/bin/test-report.js.map +1 -1
- package/dist/bin/types.d.ts +8 -0
- package/dist/bin/types.d.ts.map +1 -1
- package/dist/test/bundle/cli-bundle.test.d.ts +2 -0
- package/dist/test/bundle/cli-bundle.test.d.ts.map +1 -0
- package/dist/test/bundle/cli-bundle.test.js +162 -0
- package/dist/test/bundle/cli-bundle.test.js.map +1 -0
- package/dist/test/e2e/cli-bundle.e2e.test.d.ts +2 -0
- package/dist/test/e2e/cli-bundle.e2e.test.d.ts.map +1 -0
- package/dist/test/e2e/cli-bundle.e2e.test.js +172 -0
- package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -0
- package/dist/test/unit/bin/build-from-local.test.js +53 -1
- package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
- package/dist/test/unit/bin/build.test.js +113 -3
- package/dist/test/unit/bin/build.test.js.map +1 -1
- package/dist/test/unit/bin/slack.test.d.ts +2 -0
- package/dist/test/unit/bin/slack.test.d.ts.map +1 -0
- package/dist/test/unit/bin/slack.test.js +54 -0
- package/dist/test/unit/bin/slack.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -3
- package/test/bundle/cli-bundle.test.ts +192 -0
- package/test/e2e/cli-bundle.e2e.test.ts +237 -0
- package/test/unit/bin/build-from-local.test.ts +55 -0
- package/test/unit/bin/build.test.ts +135 -3
- package/test/unit/bin/slack.test.ts +65 -0
- package/.github/scripts/check-package-version-bump.mjs +0 -74
- package/.github/workflows-package-version +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apify-test-tools",
|
|
3
|
-
"version": "0.10.2-beta.
|
|
3
|
+
"version": "0.10.2-beta.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TBD",
|
|
6
6
|
"repository": {
|
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
"url": "https://github.com/apify/apify-test-tools.git"
|
|
9
9
|
},
|
|
10
10
|
"engines": {
|
|
11
|
-
"node": ">=
|
|
11
|
+
"node": ">=22.0.0"
|
|
12
|
+
},
|
|
13
|
+
"devEngines": {
|
|
14
|
+
"packageManager": {
|
|
15
|
+
"name": "npm",
|
|
16
|
+
"version": ">=11.10.0",
|
|
17
|
+
"onFail": "warn"
|
|
18
|
+
}
|
|
12
19
|
},
|
|
13
20
|
"bin": {
|
|
14
21
|
"apify-test-tools": "dist/bin/main.js"
|
|
@@ -32,6 +39,7 @@
|
|
|
32
39
|
"@types/node": "^24.0.0",
|
|
33
40
|
"@types/picomatch": "^4.0.3",
|
|
34
41
|
"@types/yargs": "^17.0.33",
|
|
42
|
+
"esbuild": "0.25.12",
|
|
35
43
|
"eslint": "^9.29.0",
|
|
36
44
|
"eslint-config-prettier": "^10.1.5",
|
|
37
45
|
"globals": "^17.0.0",
|
|
@@ -51,12 +59,14 @@
|
|
|
51
59
|
"start": "npm run dist/bin/main.js",
|
|
52
60
|
"start:dev": "cd local-clone && tsx ../bin/main.ts",
|
|
53
61
|
"build": "tsc && chmod +x ./dist/bin/main.js",
|
|
62
|
+
"bundle": "esbuild bin/main.ts --bundle --minify --platform=node --format=esm --target=node24 --log-level=warning --banner:js=\"import{createRequire as __createRequire}from'node:module';const require=__createRequire(import.meta.url);\" --outfile=.github/cli/apify-test-tools.mjs",
|
|
54
63
|
"lint": "eslint",
|
|
55
64
|
"lint:fix": "eslint --fix",
|
|
56
65
|
"format": "prettier --write .",
|
|
57
66
|
"format:check": "prettier --check .",
|
|
58
67
|
"type-check": "tsc --noEmit",
|
|
59
|
-
"test": "vitest run",
|
|
68
|
+
"test": "vitest run --exclude 'test/e2e/**'",
|
|
69
|
+
"test:e2e": "vitest run test/e2e",
|
|
60
70
|
"check-unused": "npx knip",
|
|
61
71
|
"prepare": "husky .husky || true"
|
|
62
72
|
},
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
9
|
+
|
|
10
|
+
// Runs the committed bundle the composite action ships, not the sources, so these catch anything
|
|
11
|
+
// that only breaks once bundled (a dependency loading code at runtime, a missing shim). Everything
|
|
12
|
+
// here works without tokens or network: read-only commands, dry runs that end up with nothing to
|
|
13
|
+
// build, and the missing-token error. The `CLI bundle` check keeps the bundle in sync with bin/.
|
|
14
|
+
const bundlePath = fileURLToPath(new URL('../../.github/cli/apify-test-tools.mjs', import.meta.url));
|
|
15
|
+
|
|
16
|
+
// Only what node and git need, so no Apify or Slack token from the developer's shell leaks in.
|
|
17
|
+
const cliEnv = { PATH: process.env.PATH ?? '', HOME: os.tmpdir() };
|
|
18
|
+
const gitEnv = {
|
|
19
|
+
...cliEnv,
|
|
20
|
+
GIT_AUTHOR_NAME: 'Tester',
|
|
21
|
+
GIT_AUTHOR_EMAIL: 'tester@example.com',
|
|
22
|
+
GIT_COMMITTER_NAME: 'Tester',
|
|
23
|
+
GIT_COMMITTER_EMAIL: 'tester@example.com',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let repoDir: string;
|
|
27
|
+
let baseSha: string;
|
|
28
|
+
let headSha: string;
|
|
29
|
+
|
|
30
|
+
const git = (...args: string[]) => execFileSync('git', args, { cwd: repoDir, env: gitEnv, encoding: 'utf8' }).trim();
|
|
31
|
+
|
|
32
|
+
const writeRepoFile = async (file: string, content: string) => {
|
|
33
|
+
await mkdir(path.dirname(path.join(repoDir, file)), { recursive: true });
|
|
34
|
+
await writeFile(path.join(repoDir, file), content);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const runCli = (...args: string[]) => {
|
|
38
|
+
const result = spawnSync(process.execPath, [bundlePath, ...args], { cwd: repoDir, env: cliEnv, encoding: 'utf8' });
|
|
39
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const gitRange = ['--source-branch', 'feature', '--target-branch', 'main'];
|
|
43
|
+
|
|
44
|
+
describe('CLI bundle', () => {
|
|
45
|
+
// Two Actors on `main`; `feature` changes only tester/foo and the root README.
|
|
46
|
+
beforeAll(async () => {
|
|
47
|
+
repoDir = await mkdtemp(path.join(os.tmpdir(), 'apify-cli-bundle-'));
|
|
48
|
+
git('init', '-q', '-b', 'main');
|
|
49
|
+
git('remote', 'add', 'origin', 'https://github.com/apify/fixture-repo.git');
|
|
50
|
+
for (const name of ['foo', 'bar']) {
|
|
51
|
+
await writeRepoFile(
|
|
52
|
+
`actors/${name}/.actor/actor.json`,
|
|
53
|
+
JSON.stringify({ actorSpecification: 1, name, version: '0.1' }),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
await writeRepoFile(
|
|
57
|
+
'apify-test-tools.config.json',
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
actors: [
|
|
60
|
+
{ folder: 'actors/foo', actorFullName: 'tester/foo', tokenEnvVar: 'FOO_TOKEN' },
|
|
61
|
+
{ folder: 'actors/bar', actorFullName: 'tester/bar', tokenEnvVar: 'BAR_TOKEN' },
|
|
62
|
+
],
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
git('add', '-A');
|
|
66
|
+
git('commit', '-q', '-m', 'chore: init');
|
|
67
|
+
baseSha = git('rev-parse', 'HEAD');
|
|
68
|
+
|
|
69
|
+
git('checkout', '-q', '-b', 'feature');
|
|
70
|
+
await writeRepoFile('actors/foo/main.js', 'console.log("changed");\n');
|
|
71
|
+
git('add', '-A');
|
|
72
|
+
git('commit', '-q', '-m', 'fix(foo): change foo');
|
|
73
|
+
await writeRepoFile('README.md', 'readme\n');
|
|
74
|
+
git('add', '-A');
|
|
75
|
+
git('commit', '-q', '-m', 'docs: readme');
|
|
76
|
+
headSha = git('rev-parse', 'HEAD');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
if (repoDir) await rm(repoDir, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('lists every command in --help', () => {
|
|
84
|
+
const { status, stdout } = runCli('--help');
|
|
85
|
+
expect(status).toBe(0);
|
|
86
|
+
for (const command of [
|
|
87
|
+
'get-commits',
|
|
88
|
+
'get-latest-commit',
|
|
89
|
+
'get-changed-files',
|
|
90
|
+
'get-actor-configs',
|
|
91
|
+
'get-affected-actors',
|
|
92
|
+
'report-tests',
|
|
93
|
+
'build',
|
|
94
|
+
'release',
|
|
95
|
+
'build-from-local',
|
|
96
|
+
'delete-old-builds',
|
|
97
|
+
]) {
|
|
98
|
+
expect(stdout).toContain(command);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('get-commits returns the commits on the source branch', () => {
|
|
103
|
+
const { status, stdout, stderr } = runCli('get-commits', ...gitRange);
|
|
104
|
+
expect(status, stderr).toBe(0);
|
|
105
|
+
expect(JSON.parse(stdout).map((commit: { message: string }) => commit.message)).toEqual([
|
|
106
|
+
'fix(foo): change foo',
|
|
107
|
+
'docs: readme',
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('get-latest-commit returns the head of the source branch', () => {
|
|
112
|
+
const { status, stdout, stderr } = runCli('get-latest-commit', ...gitRange);
|
|
113
|
+
expect(status, stderr).toBe(0);
|
|
114
|
+
expect(JSON.parse(stdout)).toMatchObject({ sha: headSha, message: 'docs: readme' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('get-changed-files returns the files changed on the source branch', () => {
|
|
118
|
+
const { status, stdout, stderr } = runCli('get-changed-files', ...gitRange);
|
|
119
|
+
expect(status, stderr).toBe(0);
|
|
120
|
+
expect(JSON.parse(stdout)).toEqual(['README.md', 'actors/foo/main.js']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('get-actor-configs reads both Actors from the config file', () => {
|
|
124
|
+
const { status, stdout, stderr } = runCli('get-actor-configs');
|
|
125
|
+
expect(status, stderr).toBe(0);
|
|
126
|
+
expect(JSON.parse(stdout).map((actor: { actorFullName: string }) => actor.actorFullName)).toEqual([
|
|
127
|
+
'tester/foo',
|
|
128
|
+
'tester/bar',
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('get-affected-actors returns only the Actor whose files changed', () => {
|
|
133
|
+
const { status, stdout, stderr } = runCli('get-affected-actors', ...gitRange);
|
|
134
|
+
expect(status, stderr).toBe(0);
|
|
135
|
+
expect(JSON.parse(stdout)).toMatchObject([{ actorFullName: 'tester/foo', folder: 'actors/foo' }]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('build --dry-run with nothing to build needs no token', () => {
|
|
139
|
+
const { status, stdout, stderr } = runCli('build', ...gitRange, '--actors', 'tester/bar', '--dry-run');
|
|
140
|
+
expect(status, stderr).toBe(0);
|
|
141
|
+
expect(JSON.parse(stdout)).toEqual({ dryRun: true, builds: [] });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('build --dry-run fails cleanly when the Actor token is missing', () => {
|
|
145
|
+
const { status, stderr } = runCli('build', ...gitRange, '--dry-run');
|
|
146
|
+
expect(status).toBe(1);
|
|
147
|
+
expect(stderr).toContain('Env var FOO_TOKEN is not set (needed for actor "tester/foo")');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('release --dry-run stops when HEAD is the base commit', () => {
|
|
151
|
+
const { status, stderr } = runCli('release', '--base-commit', headSha, '--dry-run');
|
|
152
|
+
expect(status, stderr).toBe(0);
|
|
153
|
+
expect(stderr).toContain(`HEAD is the base commit ${headSha}, there is nothing new to release`);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('release --dry-run with nothing to build skips Slack without a token', () => {
|
|
157
|
+
const { status, stderr } = runCli('release', '--base-commit', baseSha, '--actors', 'tester/bar', '--dry-run');
|
|
158
|
+
expect(status, stderr).toBe(0);
|
|
159
|
+
expect(stderr).toContain('{"dryRun":true,"builds":[]}');
|
|
160
|
+
expect(stderr).toContain('Skipping slack notification');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('report-tests summarizes a vitest JSON report', async () => {
|
|
164
|
+
const assertion = (title: string, status: 'passed' | 'failed', runId: string) => ({
|
|
165
|
+
ancestorTitles: [],
|
|
166
|
+
fullName: `foo ${title}`,
|
|
167
|
+
title,
|
|
168
|
+
status,
|
|
169
|
+
failureMessages: status === 'failed' ? ['AssertionError: expected 1 to be 2\n at foo.test.ts:1:1'] : [],
|
|
170
|
+
meta: { runId, runLink: `https://console.apify.com/view/runs/${runId}`, actorId: 'tester/foo' },
|
|
171
|
+
});
|
|
172
|
+
await writeRepoFile(
|
|
173
|
+
'test-output.json',
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
success: false,
|
|
176
|
+
testResults: [
|
|
177
|
+
{
|
|
178
|
+
name: 'test/platform/foo.test.ts',
|
|
179
|
+
status: 'failed',
|
|
180
|
+
assertionResults: [assertion('works', 'passed', 'run1'), assertion('fails', 'failed', 'run2')],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const { status, stderr } = runCli('report-tests', '--report-file', 'test-output.json', '--dry-run');
|
|
187
|
+
expect(status, stderr).toBe(0);
|
|
188
|
+
expect(stderr).toContain('PASSED: 1, FAILED: 1');
|
|
189
|
+
expect(stderr).toContain('1) foo fails ... https://console.apify.com/view/runs/run2');
|
|
190
|
+
expect(stderr).toContain('Skipping slack notification');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { ApifyClient } from 'apify-client';
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
10
|
+
|
|
11
|
+
// End-to-end run of the shipped CLI bundle against two dummy Actors in an isolated sandbox account:
|
|
12
|
+
// dry-run plans read the real Actors, `build-from-local` really builds them, the lib runs them
|
|
13
|
+
// through `testActor` on exactly those builds (the ACTOR_BUILDS handoff), `report-tests` reads the
|
|
14
|
+
// result, and `delete-old-builds` cleans up. Git builds (`build`/`release` without --dry-run) are
|
|
15
|
+
// not covered: the Apify builder would have to clone the fixture repo, which only exists locally.
|
|
16
|
+
//
|
|
17
|
+
// Runs with `npm run test:e2e`, kept out of `npm test`. Skipped without the token, e.g. on fork PRs.
|
|
18
|
+
const TOKEN_ENV_VAR = 'E2E_TEST_APIFY_TOKEN_DBYPODOIHUZABNQTR';
|
|
19
|
+
const token = process.env[TOKEN_ENV_VAR];
|
|
20
|
+
|
|
21
|
+
const ACTORS = ['test-tools-testing-sandbox/dummy-for-testing-1', 'test-tools-testing-sandbox/dummy-for-testing-2'];
|
|
22
|
+
// Only used as the source URL in dry-run plans; nothing is cloned from it.
|
|
23
|
+
const REPO_URL = 'https://github.com/apify/apify-test-tools-e2e-fixture';
|
|
24
|
+
|
|
25
|
+
const projectRoot = fileURLToPath(new URL('../../', import.meta.url));
|
|
26
|
+
const bundlePath = path.join(projectRoot, '.github/cli/apify-test-tools.mjs');
|
|
27
|
+
const vitestPath = path.join(projectRoot, 'node_modules/vitest/vitest.mjs');
|
|
28
|
+
|
|
29
|
+
// Proxy and CA settings pass through so the API is reachable behind a proxy; no other variable does.
|
|
30
|
+
const networkEnv = Object.fromEntries(
|
|
31
|
+
['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'NO_PROXY', 'no_proxy', 'NODE_EXTRA_CA_CERTS']
|
|
32
|
+
.filter((name) => process.env[name] !== undefined)
|
|
33
|
+
.map((name) => [name, process.env[name]!]),
|
|
34
|
+
);
|
|
35
|
+
const baseEnv = { PATH: process.env.PATH ?? '', HOME: os.tmpdir() };
|
|
36
|
+
// The CLI gets only the sandbox token, under the name the fixture config points at.
|
|
37
|
+
const cliEnv = { ...baseEnv, ...networkEnv, [TOKEN_ENV_VAR]: token ?? '' };
|
|
38
|
+
const gitEnv = {
|
|
39
|
+
...baseEnv,
|
|
40
|
+
GIT_AUTHOR_NAME: 'Tester',
|
|
41
|
+
GIT_AUTHOR_EMAIL: 'tester@example.com',
|
|
42
|
+
GIT_COMMITTER_NAME: 'Tester',
|
|
43
|
+
GIT_COMMITTER_EMAIL: 'tester@example.com',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let repoDir: string;
|
|
47
|
+
let baseSha: string;
|
|
48
|
+
|
|
49
|
+
const git = (...args: string[]) => execFileSync('git', args, { cwd: repoDir, env: gitEnv, encoding: 'utf8' }).trim();
|
|
50
|
+
|
|
51
|
+
const writeRepoFile = async (file: string, content: string) => {
|
|
52
|
+
await mkdir(path.dirname(path.join(repoDir, file)), { recursive: true });
|
|
53
|
+
await writeFile(path.join(repoDir, file), content);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Spawned processes are blocking, so vitest's own timeouts can't stop one that hangs. apify-client
|
|
57
|
+
// retries an unreachable API for minutes, so a hang is killed here and fails with a clear error.
|
|
58
|
+
const PROCESS_TIMEOUT_MS = 8 * 60_000;
|
|
59
|
+
|
|
60
|
+
const runCli = (...args: string[]) => {
|
|
61
|
+
const result = spawnSync(process.execPath, [bundlePath, ...args], {
|
|
62
|
+
cwd: repoDir,
|
|
63
|
+
env: cliEnv,
|
|
64
|
+
encoding: 'utf8',
|
|
65
|
+
timeout: PROCESS_TIMEOUT_MS,
|
|
66
|
+
});
|
|
67
|
+
const error = result.error ? `\n${result.error.message}` : '';
|
|
68
|
+
return { status: result.status, stdout: result.stdout, stderr: result.stderr + error };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
interface ActorBuild {
|
|
72
|
+
buildId: string;
|
|
73
|
+
actorRawId: string;
|
|
74
|
+
buildNumber: string;
|
|
75
|
+
actorFullName: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe.runIf(token)('CLI bundle against real Actors', { timeout: 2 * 60_000 }, () => {
|
|
79
|
+
let builds: ActorBuild[];
|
|
80
|
+
|
|
81
|
+
// Both Actors on `main`; `feature` changes only dummy-for-testing-1.
|
|
82
|
+
beforeAll(async () => {
|
|
83
|
+
repoDir = await mkdtemp(path.join(os.tmpdir(), 'apify-cli-e2e-'));
|
|
84
|
+
git('init', '-q', '-b', 'main');
|
|
85
|
+
git('remote', 'add', 'origin', `${REPO_URL}.git`);
|
|
86
|
+
const actors = [];
|
|
87
|
+
for (const [i, actorFullName] of ACTORS.entries()) {
|
|
88
|
+
const folder = `actors/dummy-${i + 1}`;
|
|
89
|
+
await writeRepoFile(
|
|
90
|
+
`${folder}/.actor/actor.json`,
|
|
91
|
+
JSON.stringify({
|
|
92
|
+
actorSpecification: 1,
|
|
93
|
+
name: actorFullName.split('/')[1],
|
|
94
|
+
version: '0.0',
|
|
95
|
+
dockerfile: '../Dockerfile',
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
await writeRepoFile(
|
|
99
|
+
`${folder}/Dockerfile`,
|
|
100
|
+
'FROM apify/actor-node:22\nCOPY . ./\nCMD ["node", "main.js"]\n',
|
|
101
|
+
);
|
|
102
|
+
await writeRepoFile(`${folder}/main.js`, `console.log('dummy ${i + 1}');\n`);
|
|
103
|
+
actors.push({ folder, actorFullName, tokenEnvVar: TOKEN_ENV_VAR });
|
|
104
|
+
}
|
|
105
|
+
await writeRepoFile('apify-test-tools.config.json', JSON.stringify({ actors }));
|
|
106
|
+
git('add', '-A');
|
|
107
|
+
git('commit', '-q', '-m', 'chore: init');
|
|
108
|
+
baseSha = git('rev-parse', 'HEAD');
|
|
109
|
+
|
|
110
|
+
git('checkout', '-q', '-b', 'feature');
|
|
111
|
+
await writeRepoFile('actors/dummy-1/main.js', "console.log('dummy 1 changed');\n");
|
|
112
|
+
git('add', '-A');
|
|
113
|
+
git('commit', '-q', '-m', 'fix(dummy-1): change');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterAll(async () => {
|
|
117
|
+
if (repoDir) await rm(repoDir, { recursive: true, force: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('build --dry-run plans a test build of the changed Actor only', () => {
|
|
121
|
+
const { status, stdout, stderr } = runCli(
|
|
122
|
+
'build',
|
|
123
|
+
'--source-branch',
|
|
124
|
+
'feature',
|
|
125
|
+
'--target-branch',
|
|
126
|
+
'main',
|
|
127
|
+
'--repo-url',
|
|
128
|
+
REPO_URL,
|
|
129
|
+
'--dry-run',
|
|
130
|
+
);
|
|
131
|
+
expect(status, stderr).toBe(0);
|
|
132
|
+
expect(JSON.parse(stdout)).toEqual({
|
|
133
|
+
dryRun: true,
|
|
134
|
+
builds: [
|
|
135
|
+
{
|
|
136
|
+
actorFullName: ACTORS[0],
|
|
137
|
+
versionNumber: '0.99',
|
|
138
|
+
gitRepoUrl: `${REPO_URL}#feature:actors/dummy-1`,
|
|
139
|
+
useDockerCache: false,
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('release --dry-run plans a build of the default version and tag', () => {
|
|
146
|
+
const { status, stderr } = runCli('release', '--base-commit', baseSha, '--repo-url', REPO_URL, '--dry-run');
|
|
147
|
+
expect(status, stderr).toBe(0);
|
|
148
|
+
expect(stderr).toContain(
|
|
149
|
+
`[DRY RUN] Would build:\n ${ACTORS[0]} (0.0) from ${REPO_URL}#feature:actors/dummy-1`,
|
|
150
|
+
);
|
|
151
|
+
expect(stderr).toContain('Skipping slack notification');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('build-from-local --dry-run plans both Actors from the working tree', () => {
|
|
155
|
+
const { status, stdout, stderr } = runCli('build-from-local', '--dry-run');
|
|
156
|
+
expect(status, stderr).toBe(0);
|
|
157
|
+
expect(JSON.parse(stdout)).toMatchObject({
|
|
158
|
+
dryRun: true,
|
|
159
|
+
builds: ACTORS.map((actorFullName) => ({ actorFullName, versionNumber: '0.98', fileCount: 3 })),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('build-from-local builds both Actors', { timeout: 10 * 60_000 }, () => {
|
|
164
|
+
const { status, stdout, stderr } = runCli('build-from-local');
|
|
165
|
+
expect(status, stderr).toBe(0);
|
|
166
|
+
builds = JSON.parse(stdout);
|
|
167
|
+
expect(builds.map((build) => build.actorFullName)).toEqual(ACTORS);
|
|
168
|
+
for (const build of builds) expect(build.buildNumber).toMatch(/^0\.98\.\d+$/);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('runs the builds through testActor and reports the results', { timeout: 10 * 60_000 }, async () => {
|
|
172
|
+
expect(builds, 'needs the builds from the previous test').toBeDefined();
|
|
173
|
+
|
|
174
|
+
// A consumer's platform test, importing the lib from this checkout. The symlink lets it
|
|
175
|
+
// resolve vitest and the lib's dependencies the way an installed package would.
|
|
176
|
+
await symlink(path.join(projectRoot, 'node_modules'), path.join(repoDir, 'node_modules'), 'dir');
|
|
177
|
+
const libPath = path.join(projectRoot, 'index.ts');
|
|
178
|
+
const actorTests = ACTORS.map(
|
|
179
|
+
(actorId) => `
|
|
180
|
+
testActor(${JSON.stringify(actorId)}, 'finishes', async ({ expect, run }) => {
|
|
181
|
+
const result = await run({ input: {} });
|
|
182
|
+
expect(result.status).toBe('SUCCEEDED');
|
|
183
|
+
});`,
|
|
184
|
+
).join('\n');
|
|
185
|
+
await writeRepoFile(
|
|
186
|
+
'test/platform/dummies.test.ts',
|
|
187
|
+
`import { describe, testActor } from ${JSON.stringify(libPath)};
|
|
188
|
+
|
|
189
|
+
describe('sandbox dummies', () => {${actorTests}
|
|
190
|
+
});
|
|
191
|
+
`,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const vitest = spawnSync(
|
|
195
|
+
process.execPath,
|
|
196
|
+
[vitestPath, 'run', 'test/platform', '--reporter=json', '--outputFile=./test-output.json'],
|
|
197
|
+
{
|
|
198
|
+
cwd: repoDir,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
timeout: PROCESS_TIMEOUT_MS,
|
|
201
|
+
env: { ...cliEnv, TESTER_APIFY_TOKEN: token, ACTOR_BUILDS: JSON.stringify(builds) },
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
expect(
|
|
205
|
+
vitest.status,
|
|
206
|
+
`${vitest.stdout}\n${vitest.stderr}${vitest.error ? `\n${vitest.error.message}` : ''}`,
|
|
207
|
+
).toBe(0);
|
|
208
|
+
|
|
209
|
+
// Each run used the build from ACTOR_BUILDS, not the Actor's default build.
|
|
210
|
+
const report = JSON.parse(await readFile(path.join(repoDir, 'test-output.json'), 'utf8'));
|
|
211
|
+
const assertions = report.testResults.flatMap(
|
|
212
|
+
(file: { assertionResults: { meta: { runId: string } }[] }) => file.assertionResults,
|
|
213
|
+
);
|
|
214
|
+
expect(assertions).toHaveLength(ACTORS.length);
|
|
215
|
+
const client = new ApifyClient({ token });
|
|
216
|
+
const runBuildIds = await Promise.all(
|
|
217
|
+
assertions.map(
|
|
218
|
+
async ({ meta }: { meta: { runId: string } }) => (await client.run(meta.runId).get())?.buildId,
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
expect(runBuildIds.sort()).toEqual(builds.map((build) => build.buildId).sort());
|
|
222
|
+
|
|
223
|
+
const { status, stderr } = runCli('report-tests', '--report-file', 'test-output.json', '--dry-run');
|
|
224
|
+
expect(status, stderr).toBe(0);
|
|
225
|
+
expect(stderr).toContain(`PASSED: ${ACTORS.length}, FAILED: 0`);
|
|
226
|
+
expect(stderr).toContain('https://console.apify.com/view/runs/');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Last, so builds from earlier runs of this suite don't pile up in the sandbox.
|
|
230
|
+
it('delete-old-builds keeps the default builds', () => {
|
|
231
|
+
const { status, stderr } = runCli('delete-old-builds');
|
|
232
|
+
expect(status, stderr).toBe(0);
|
|
233
|
+
for (const actorFullName of ACTORS) {
|
|
234
|
+
expect(stderr).toContain(`[DELETE OLD BUILDS][${actorFullName}]: Skipping default build`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -4,11 +4,13 @@ import path from 'node:path';
|
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
|
|
7
|
+
import { ApifyBuilder } from '../../../bin/build.js';
|
|
7
8
|
import {
|
|
8
9
|
collectNonIgnoredFiles,
|
|
9
10
|
collectSourceFiles,
|
|
10
11
|
flattenMonorepoContext,
|
|
11
12
|
rewriteActorJsonPaths,
|
|
13
|
+
runBuildsFromLocal,
|
|
12
14
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
13
15
|
// @ts-ignore: editor-only TS6059 — test/tsconfig.json's rootDir doesn't span bin/, but the root
|
|
14
16
|
// tsconfig (used for the real build and for eslint's type-aware linting) has no such restriction.
|
|
@@ -235,4 +237,57 @@ describe('build-from-local helpers', () => {
|
|
|
235
237
|
expect(rewritten.changelog).toBe('./CHANGELOG.md');
|
|
236
238
|
});
|
|
237
239
|
});
|
|
240
|
+
|
|
241
|
+
describe('runBuildsFromLocal dry run', () => {
|
|
242
|
+
it('looks the Actor up and zips its sources, but builds nothing', async () => {
|
|
243
|
+
const repoRoot = await mkTempDir('apify-test-tools-dry-run-');
|
|
244
|
+
tempDirs.push(repoRoot);
|
|
245
|
+
const actorDir = path.join(repoRoot, 'actors', 'owner_actor');
|
|
246
|
+
await fs.mkdir(path.join(actorDir, '.actor'), { recursive: true });
|
|
247
|
+
await fs.writeFile(path.join(actorDir, '.actor', 'actor.json'), JSON.stringify({ name: 'actor' }));
|
|
248
|
+
await fs.writeFile(path.join(actorDir, 'main.js'), '');
|
|
249
|
+
vi.spyOn(Utils, 'listRepoFilePaths').mockResolvedValue([
|
|
250
|
+
'actors/owner_actor/.actor/actor.json',
|
|
251
|
+
'actors/owner_actor/main.js',
|
|
252
|
+
]);
|
|
253
|
+
vi.spyOn(Utils, 'getGitignoredPaths').mockResolvedValue(new Set());
|
|
254
|
+
|
|
255
|
+
const getActorInfo = vi.fn(async () => ({}));
|
|
256
|
+
const fromActorConfig = vi
|
|
257
|
+
.spyOn(ApifyBuilder, 'fromActorConfig')
|
|
258
|
+
.mockReturnValue({ getActorInfo } as unknown as ApifyBuilder);
|
|
259
|
+
|
|
260
|
+
const originalCwd = process.cwd();
|
|
261
|
+
process.chdir(repoRoot);
|
|
262
|
+
try {
|
|
263
|
+
const plan = await runBuildsFromLocal({
|
|
264
|
+
actorConfigs: [
|
|
265
|
+
{
|
|
266
|
+
actorFullName: 'owner/actor',
|
|
267
|
+
folder: new ExistingDir('actors/owner_actor'),
|
|
268
|
+
tokenEnvVar: 'APIFY_TOKEN_OWNER',
|
|
269
|
+
actorJson: {
|
|
270
|
+
file: new ExistingFile('actors/owner_actor/.actor/actor.json'),
|
|
271
|
+
dockerContextDir: new RelativeDir('actors/owner_actor'),
|
|
272
|
+
},
|
|
273
|
+
dockerContextDir: new RelativeDir('actors/owner_actor'),
|
|
274
|
+
contextPaths: [new RelativeDir('actors/owner_actor')],
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
dryRun: true,
|
|
278
|
+
});
|
|
279
|
+
expect(plan).toEqual({
|
|
280
|
+
dryRun: true,
|
|
281
|
+
builds: [
|
|
282
|
+
expect.objectContaining({ actorFullName: 'owner/actor', versionNumber: '0.98', fileCount: 2 }),
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
} finally {
|
|
286
|
+
process.chdir(originalCwd);
|
|
287
|
+
}
|
|
288
|
+
expect(getActorInfo).toHaveBeenCalledTimes(1);
|
|
289
|
+
// Only the read-only lookup ran: builders for uploads and builds are created after the dry-run cutoff
|
|
290
|
+
expect(fromActorConfig).toHaveBeenCalledTimes(1);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
238
293
|
});
|