apify-test-tools 0.10.2-beta.0 → 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.
Files changed (78) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/actions/checkout-restore-dependencies/action.yaml +13 -29
  3. package/.github/cli/apify-test-tools.mjs +362 -0
  4. package/.github/scripts/run-with-apify-tokens.mjs +2 -2
  5. package/.github/workflows/_check_code.yaml +25 -0
  6. package/.github/workflows/_move_major_tag.yaml +3 -81
  7. package/.github/workflows/_tests.yaml +34 -0
  8. package/.github/workflows/_update_release_metadata.yaml +0 -12
  9. package/.github/workflows/manual_move_major_tag.yaml +3 -3
  10. package/.github/workflows/manual_release_stable.yaml +3 -13
  11. package/.github/workflows/on_master.yaml +9 -5
  12. package/.github/workflows/on_pull_request.yaml +3 -48
  13. package/.github/workflows/public_platform-tests.yaml +1 -1
  14. package/.github/workflows/public_pr-build-test.yaml +5 -28
  15. package/.github/workflows/public_push-build-latest.yaml +2 -2
  16. package/.husky/pre-commit +5 -0
  17. package/.prettierignore +3 -0
  18. package/CHANGELOG.md +5 -0
  19. package/CONTRIBUTING.md +8 -18
  20. package/README.md +40 -42
  21. package/bin/build-from-local.ts +47 -13
  22. package/bin/build.ts +35 -19
  23. package/bin/diff-changes.ts +24 -3
  24. package/bin/main.ts +35 -7
  25. package/bin/slack.ts +28 -6
  26. package/bin/test-report.ts +6 -9
  27. package/bin/types.ts +10 -0
  28. package/dist/bin/build-from-local.d.ts +10 -2
  29. package/dist/bin/build-from-local.d.ts.map +1 -1
  30. package/dist/bin/build-from-local.js +29 -11
  31. package/dist/bin/build-from-local.js.map +1 -1
  32. package/dist/bin/build.d.ts +15 -5
  33. package/dist/bin/build.d.ts.map +1 -1
  34. package/dist/bin/build.js +24 -15
  35. package/dist/bin/build.js.map +1 -1
  36. package/dist/bin/diff-changes.d.ts +2 -1
  37. package/dist/bin/diff-changes.d.ts.map +1 -1
  38. package/dist/bin/diff-changes.js +16 -5
  39. package/dist/bin/diff-changes.js.map +1 -1
  40. package/dist/bin/main.js +32 -6
  41. package/dist/bin/main.js.map +1 -1
  42. package/dist/bin/slack.d.ts +8 -1
  43. package/dist/bin/slack.d.ts.map +1 -1
  44. package/dist/bin/slack.js +26 -5
  45. package/dist/bin/slack.js.map +1 -1
  46. package/dist/bin/test-report.d.ts.map +1 -1
  47. package/dist/bin/test-report.js +6 -8
  48. package/dist/bin/test-report.js.map +1 -1
  49. package/dist/bin/types.d.ts +9 -0
  50. package/dist/bin/types.d.ts.map +1 -1
  51. package/dist/test/bundle/cli-bundle.test.d.ts +2 -0
  52. package/dist/test/bundle/cli-bundle.test.d.ts.map +1 -0
  53. package/dist/test/bundle/cli-bundle.test.js +162 -0
  54. package/dist/test/bundle/cli-bundle.test.js.map +1 -0
  55. package/dist/test/e2e/cli-bundle.e2e.test.d.ts +2 -0
  56. package/dist/test/e2e/cli-bundle.e2e.test.d.ts.map +1 -0
  57. package/dist/test/e2e/cli-bundle.e2e.test.js +172 -0
  58. package/dist/test/e2e/cli-bundle.e2e.test.js.map +1 -0
  59. package/dist/test/unit/bin/build-from-local.test.js +53 -1
  60. package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
  61. package/dist/test/unit/bin/build.test.js +113 -3
  62. package/dist/test/unit/bin/build.test.js.map +1 -1
  63. package/dist/test/unit/bin/diff-changes.test.js +91 -0
  64. package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
  65. package/dist/test/unit/bin/slack.test.d.ts +2 -0
  66. package/dist/test/unit/bin/slack.test.d.ts.map +1 -0
  67. package/dist/test/unit/bin/slack.test.js +54 -0
  68. package/dist/test/unit/bin/slack.test.js.map +1 -0
  69. package/dist/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +15 -3
  71. package/test/bundle/cli-bundle.test.ts +192 -0
  72. package/test/e2e/cli-bundle.e2e.test.ts +237 -0
  73. package/test/unit/bin/build-from-local.test.ts +55 -0
  74. package/test/unit/bin/build.test.ts +135 -3
  75. package/test/unit/bin/diff-changes.test.ts +106 -0
  76. package/test/unit/bin/slack.test.ts +65 -0
  77. package/.github/scripts/check-package-version-bump.mjs +0 -74
  78. 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.0",
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": ">=18.0.0"
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"
@@ -21,6 +28,7 @@
21
28
  "adm-zip": "^0.6.1",
22
29
  "apify-client": "^2.25.0",
23
30
  "ignore": "^7.0.5",
31
+ "picomatch": "^4.0.7",
24
32
  "yargs": "^18.0.0",
25
33
  "zod": "^4.6.5"
26
34
  },
@@ -29,7 +37,9 @@
29
37
  "@apify/tsconfig": "^0.1.1",
30
38
  "@types/adm-zip": "^0.5.8",
31
39
  "@types/node": "^24.0.0",
40
+ "@types/picomatch": "^4.0.3",
32
41
  "@types/yargs": "^17.0.33",
42
+ "esbuild": "0.25.12",
33
43
  "eslint": "^9.29.0",
34
44
  "eslint-config-prettier": "^10.1.5",
35
45
  "globals": "^17.0.0",
@@ -49,12 +59,14 @@
49
59
  "start": "npm run dist/bin/main.js",
50
60
  "start:dev": "cd local-clone && tsx ../bin/main.ts",
51
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",
52
63
  "lint": "eslint",
53
64
  "lint:fix": "eslint --fix",
54
65
  "format": "prettier --write .",
55
66
  "format:check": "prettier --check .",
56
67
  "type-check": "tsc --noEmit",
57
- "test": "vitest run",
68
+ "test": "vitest run --exclude 'test/e2e/**'",
69
+ "test:e2e": "vitest run test/e2e",
58
70
  "check-unused": "npx knip",
59
71
  "prepare": "husky .husky || true"
60
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
  });