apify-test-tools 0.10.2-beta.1 → 0.10.2-beta.3
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 +361 -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 +5 -0
- package/CONTRIBUTING.md +8 -18
- package/README.md +94 -44
- package/bin/affected-actors.ts +166 -0
- package/bin/build-from-local.ts +47 -13
- package/bin/build.ts +35 -19
- package/bin/diff-changes.ts +25 -17
- package/bin/git.ts +52 -41
- package/bin/main.ts +63 -78
- package/bin/slack.ts +28 -6
- package/bin/test-report.ts +6 -9
- package/bin/types.ts +9 -0
- package/dist/bin/affected-actors.d.ts +50 -0
- package/dist/bin/affected-actors.d.ts.map +1 -0
- package/dist/bin/affected-actors.js +89 -0
- package/dist/bin/affected-actors.js.map +1 -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/diff-changes.d.ts +8 -2
- package/dist/bin/diff-changes.d.ts.map +1 -1
- package/dist/bin/diff-changes.js +16 -16
- package/dist/bin/diff-changes.js.map +1 -1
- package/dist/bin/git.d.ts +23 -13
- package/dist/bin/git.d.ts.map +1 -1
- package/dist/bin/git.js +44 -32
- package/dist/bin/git.js.map +1 -1
- package/dist/bin/main.js +57 -57
- 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 +169 -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/affected-actors.test.d.ts +2 -0
- package/dist/test/unit/bin/affected-actors.test.d.ts.map +1 -0
- package/dist/test/unit/bin/affected-actors.test.js +160 -0
- package/dist/test/unit/bin/affected-actors.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/diff-changes.test.js +78 -49
- package/dist/test/unit/bin/diff-changes.test.js.map +1 -1
- package/dist/test/unit/bin/git.test.js +50 -53
- package/dist/test/unit/bin/git.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/test/unit/should-built-and-test.test.js +23 -17
- package/dist/test/unit/should-built-and-test.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -3
- package/test/bundle/cli-bundle.test.ts +199 -0
- package/test/e2e/cli-bundle.e2e.test.ts +237 -0
- package/test/unit/bin/affected-actors.test.ts +190 -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/diff-changes.test.ts +87 -49
- package/test/unit/bin/git.test.ts +56 -53
- package/test/unit/bin/slack.test.ts +65 -0
- package/test/unit/should-built-and-test.test.ts +27 -17
- 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.3",
|
|
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,199 @@
|
|
|
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
|
+
// `feature` is checked out, and the range always ends at HEAD
|
|
134
|
+
const { status, stdout, stderr } = runCli('get-affected-actors', '--base', 'main');
|
|
135
|
+
expect(status, stderr).toBe(0);
|
|
136
|
+
expect(JSON.parse(stdout)).toMatchObject({
|
|
137
|
+
version: 1,
|
|
138
|
+
head: headSha,
|
|
139
|
+
functionallyChangedActors: [{ actorFullName: 'tester/foo' }],
|
|
140
|
+
// The root README isn't in tester/bar's folder, so it doesn't count for it
|
|
141
|
+
cosmeticallyChangedActors: [],
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('build --dry-run with nothing to build needs no token', () => {
|
|
146
|
+
const { status, stdout, stderr } = runCli('build', ...gitRange, '--actors', 'tester/bar', '--dry-run');
|
|
147
|
+
expect(status, stderr).toBe(0);
|
|
148
|
+
expect(JSON.parse(stdout)).toEqual({ dryRun: true, builds: [] });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('build --dry-run fails cleanly when the Actor token is missing', () => {
|
|
152
|
+
const { status, stderr } = runCli('build', ...gitRange, '--dry-run');
|
|
153
|
+
expect(status).toBe(1);
|
|
154
|
+
expect(stderr).toContain('Env var FOO_TOKEN is not set (needed for actor "tester/foo")');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('release --dry-run stops when HEAD is the base commit', () => {
|
|
158
|
+
const { status, stderr } = runCli('release', '--base-commit', headSha, '--dry-run');
|
|
159
|
+
expect(status, stderr).toBe(0);
|
|
160
|
+
expect(stderr).toContain(`HEAD is the base commit ${headSha}, there is nothing new to release`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('release --dry-run with nothing to build skips Slack without a token', () => {
|
|
164
|
+
const { status, stderr } = runCli('release', '--base-commit', baseSha, '--actors', 'tester/bar', '--dry-run');
|
|
165
|
+
expect(status, stderr).toBe(0);
|
|
166
|
+
expect(stderr).toContain('{"dryRun":true,"builds":[]}');
|
|
167
|
+
expect(stderr).toContain('Skipping slack notification');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('report-tests summarizes a vitest JSON report', async () => {
|
|
171
|
+
const assertion = (title: string, status: 'passed' | 'failed', runId: string) => ({
|
|
172
|
+
ancestorTitles: [],
|
|
173
|
+
fullName: `foo ${title}`,
|
|
174
|
+
title,
|
|
175
|
+
status,
|
|
176
|
+
failureMessages: status === 'failed' ? ['AssertionError: expected 1 to be 2\n at foo.test.ts:1:1'] : [],
|
|
177
|
+
meta: { runId, runLink: `https://console.apify.com/view/runs/${runId}`, actorId: 'tester/foo' },
|
|
178
|
+
});
|
|
179
|
+
await writeRepoFile(
|
|
180
|
+
'test-output.json',
|
|
181
|
+
JSON.stringify({
|
|
182
|
+
success: false,
|
|
183
|
+
testResults: [
|
|
184
|
+
{
|
|
185
|
+
name: 'test/platform/foo.test.ts',
|
|
186
|
+
status: 'failed',
|
|
187
|
+
assertionResults: [assertion('works', 'passed', 'run1'), assertion('fails', 'failed', 'run2')],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const { status, stderr } = runCli('report-tests', '--report-file', 'test-output.json', '--dry-run');
|
|
194
|
+
expect(status, stderr).toBe(0);
|
|
195
|
+
expect(stderr).toContain('PASSED: 1, FAILED: 1');
|
|
196
|
+
expect(stderr).toContain('1) foo fails ... https://console.apify.com/view/runs/run2');
|
|
197
|
+
expect(stderr).toContain('Skipping slack notification');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { MockInstance } from 'vitest';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { getAffectedActors, toAffectedActorsDocument } from '../../../bin/affected-actors.js';
|
|
5
|
+
import * as DiffChanges from '../../../bin/diff-changes.js';
|
|
6
|
+
import * as Git from '../../../bin/git.js';
|
|
7
|
+
import type { ActorConfig, Commit } from '../../../bin/types.js';
|
|
8
|
+
import * as LoadConfig from '../../../bin/utils/config/load-config.js';
|
|
9
|
+
|
|
10
|
+
// Everything that reads the configs is mocked, so only the identity and name of an Actor matter here
|
|
11
|
+
const actorA = { actorFullName: 'owner/a' } as ActorConfig;
|
|
12
|
+
const actorB = { actorFullName: 'owner/b' } as ActorConfig;
|
|
13
|
+
|
|
14
|
+
const headSha = 'f'.repeat(40);
|
|
15
|
+
const sinceSha = '5'.repeat(40);
|
|
16
|
+
const commit = (sha: string): Commit => ({ sha, author: 'Dev<dev@example.com>', date: 'Date', message: 'msg' });
|
|
17
|
+
const commits = [commit('1'.repeat(40)), commit('2'.repeat(40))];
|
|
18
|
+
const changedFiles = ['src/main.ts', 'CHANGELOG.md'];
|
|
19
|
+
|
|
20
|
+
const selection = { actors: [], ignore: [] };
|
|
21
|
+
|
|
22
|
+
describe('getAffectedActors', () => {
|
|
23
|
+
let changedActorsSpy: MockInstance;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.spyOn(LoadConfig, 'readConfigFile').mockResolvedValue([actorA, actorB]);
|
|
27
|
+
changedActorsSpy = vi
|
|
28
|
+
.spyOn(DiffChanges, 'getChangedActors')
|
|
29
|
+
.mockResolvedValue({ functional: [actorA], cosmetic: [actorB] });
|
|
30
|
+
vi.spyOn(Git, 'resolveCommitSha').mockResolvedValue(headSha);
|
|
31
|
+
vi.spyOn(Git, 'resolveBranchName').mockResolvedValue('feature');
|
|
32
|
+
vi.spyOn(Git, 'getChangedFilesBetween').mockResolvedValue(changedFiles);
|
|
33
|
+
vi.spyOn(Git, 'getChangelogAdditions').mockResolvedValue('- Added foo');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('requires --base or --since', async () => {
|
|
37
|
+
const options = { head: 'HEAD', ...selection };
|
|
38
|
+
await expect(getAffectedActors(options)).rejects.toThrow('Pass --base, --since, or both');
|
|
39
|
+
// An empty --since (e.g. from an empty cache file) counts as not passed
|
|
40
|
+
await expect(getAffectedActors({ ...options, since: '' })).rejects.toThrow('Pass --base, --since, or both');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('with --base', () => {
|
|
44
|
+
let getCommitsSpy: MockInstance;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.spyOn(Git, 'hasMergeFromTarget').mockResolvedValue(false);
|
|
48
|
+
getCommitsSpy = vi.spyOn(Git, 'getCommits').mockResolvedValue(commits);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('inspects the branch commits after --since', async () => {
|
|
52
|
+
const result = await getAffectedActors({
|
|
53
|
+
head: 'origin/feature',
|
|
54
|
+
base: 'origin/master',
|
|
55
|
+
since: sinceSha,
|
|
56
|
+
...selection,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result).toStrictEqual({
|
|
60
|
+
headSha,
|
|
61
|
+
ref: 'feature',
|
|
62
|
+
commits,
|
|
63
|
+
changedFiles,
|
|
64
|
+
changelog: '- Added foo',
|
|
65
|
+
functionallyChangedActors: [actorA],
|
|
66
|
+
cosmeticallyChangedActors: [actorB],
|
|
67
|
+
});
|
|
68
|
+
expect(getCommitsSpy).toHaveBeenCalledWith({
|
|
69
|
+
sourceBranch: 'origin/feature',
|
|
70
|
+
targetBranch: 'origin/master',
|
|
71
|
+
baseCommit: sinceSha,
|
|
72
|
+
});
|
|
73
|
+
// From the parent of the oldest commit to the newest one, for the files and the changelog alike
|
|
74
|
+
expect(Git.getChangedFilesBetween).toHaveBeenCalledWith(`${commits[0].sha}~`, commits[1].sha);
|
|
75
|
+
expect(Git.getChangelogAdditions).toHaveBeenCalledWith(`${commits[0].sha}~`, commits[1].sha, changedFiles);
|
|
76
|
+
expect(changedActorsSpy).toHaveBeenCalledWith({
|
|
77
|
+
filepathsChanged: changedFiles,
|
|
78
|
+
actorConfigs: [actorA, actorB],
|
|
79
|
+
commits,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('when the branch merged --base in', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.spyOn(Git, 'hasMergeFromTarget').mockResolvedValue(true);
|
|
86
|
+
vi.spyOn(Git, 'getBranchOnlyChangedFiles').mockResolvedValue(['own-file']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const getWithOwnChanges = async (own: DiffChanges.ChangedActors) => {
|
|
90
|
+
changedActorsSpy.mockImplementation(async ({ filepathsChanged }) =>
|
|
91
|
+
filepathsChanged[0] === 'own-file' ? own : { functional: [actorA], cosmetic: [actorB] },
|
|
92
|
+
);
|
|
93
|
+
return getAffectedActors({ head: 'feature', base: 'master', since: sinceSha, ...selection });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
it("classifies the branch's own files against the whole branch, ignoring --since", async () => {
|
|
97
|
+
await getWithOwnChanges({ functional: [actorA], cosmetic: [] });
|
|
98
|
+
expect(changedActorsSpy).toHaveBeenCalledWith({
|
|
99
|
+
filepathsChanged: ['own-file'],
|
|
100
|
+
actorConfigs: [actorA, actorB],
|
|
101
|
+
commits,
|
|
102
|
+
});
|
|
103
|
+
expect(getCommitsSpy).toHaveBeenCalledWith({ sourceBranch: 'feature', targetBranch: 'master' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reports everything when the branch has functional changes of its own', async () => {
|
|
107
|
+
const result = await getWithOwnChanges({ functional: [actorA], cosmetic: [] });
|
|
108
|
+
expect(result.functionallyChangedActors).toStrictEqual([actorA]);
|
|
109
|
+
expect(result.cosmeticallyChangedActors).toStrictEqual([actorB]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('reports only cosmetic Actors when its own changes are only cosmetic', async () => {
|
|
113
|
+
const result = await getWithOwnChanges({ functional: [], cosmetic: [actorB] });
|
|
114
|
+
expect(result.functionallyChangedActors).toStrictEqual([]);
|
|
115
|
+
expect(result.cosmeticallyChangedActors).toStrictEqual([actorB]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reports nothing when it has no changes of its own', async () => {
|
|
119
|
+
const result = await getWithOwnChanges({ functional: [], cosmetic: [] });
|
|
120
|
+
expect(result.functionallyChangedActors).toStrictEqual([]);
|
|
121
|
+
expect(result.cosmeticallyChangedActors).toStrictEqual([]);
|
|
122
|
+
// The range is still reported
|
|
123
|
+
expect(result.commits).toStrictEqual(commits);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('with --since only', () => {
|
|
129
|
+
let resolveBaseSpy: MockInstance;
|
|
130
|
+
let commitsBetweenSpy: MockInstance;
|
|
131
|
+
|
|
132
|
+
beforeEach(() => {
|
|
133
|
+
resolveBaseSpy = vi.spyOn(Git, 'resolveReleaseBaseCommit').mockResolvedValue(sinceSha);
|
|
134
|
+
commitsBetweenSpy = vi.spyOn(Git, 'getCommitsBetween').mockResolvedValue(commits);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('inspects everything after --since up to --head', async () => {
|
|
138
|
+
const result = await getAffectedActors({ head: 'release', since: 'HEAD~2', ...selection });
|
|
139
|
+
|
|
140
|
+
expect(result).toStrictEqual({
|
|
141
|
+
headSha,
|
|
142
|
+
ref: 'feature',
|
|
143
|
+
commits,
|
|
144
|
+
changedFiles,
|
|
145
|
+
changelog: '- Added foo',
|
|
146
|
+
functionallyChangedActors: [actorA],
|
|
147
|
+
cosmeticallyChangedActors: [actorB],
|
|
148
|
+
});
|
|
149
|
+
expect(resolveBaseSpy).toHaveBeenCalledWith('HEAD~2', 'release');
|
|
150
|
+
expect(commitsBetweenSpy).toHaveBeenCalledWith(sinceSha, headSha);
|
|
151
|
+
expect(Git.getChangedFilesBetween).toHaveBeenCalledWith(sinceSha, headSha);
|
|
152
|
+
expect(Git.getChangelogAdditions).toHaveBeenCalledWith(sinceSha, headSha, changedFiles);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('has no commits when head is the --since commit', async () => {
|
|
156
|
+
resolveBaseSpy.mockResolvedValue(headSha);
|
|
157
|
+
|
|
158
|
+
const result = await getAffectedActors({ head: 'HEAD', since: headSha, ...selection });
|
|
159
|
+
|
|
160
|
+
expect(result.commits).toStrictEqual([]);
|
|
161
|
+
// Empty `git log x..x` output doesn't parse as a commit, so it's never run
|
|
162
|
+
expect(commitsBetweenSpy).not.toHaveBeenCalled();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('toAffectedActorsDocument', () => {
|
|
168
|
+
it('keeps only the Actor names, the config file stays the source of truth for the rest', () => {
|
|
169
|
+
expect(
|
|
170
|
+
toAffectedActorsDocument({
|
|
171
|
+
headSha,
|
|
172
|
+
ref: null,
|
|
173
|
+
commits,
|
|
174
|
+
changedFiles: ['src/main.ts'],
|
|
175
|
+
changelog: null,
|
|
176
|
+
functionallyChangedActors: [actorA],
|
|
177
|
+
cosmeticallyChangedActors: [actorB],
|
|
178
|
+
}),
|
|
179
|
+
).toStrictEqual({
|
|
180
|
+
version: 1,
|
|
181
|
+
ref: null,
|
|
182
|
+
head: headSha,
|
|
183
|
+
commits,
|
|
184
|
+
changedFiles: ['src/main.ts'],
|
|
185
|
+
changelog: null,
|
|
186
|
+
functionallyChangedActors: [{ actorFullName: 'owner/a' }],
|
|
187
|
+
cosmeticallyChangedActors: [{ actorFullName: 'owner/b' }],
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|