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.
- 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 +5 -28
- 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 +40 -42
- package/bin/build-from-local.ts +47 -13
- package/bin/build.ts +35 -19
- package/bin/diff-changes.ts +24 -3
- package/bin/main.ts +35 -7
- package/bin/slack.ts +28 -6
- package/bin/test-report.ts +6 -9
- package/bin/types.ts +10 -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 +2 -1
- package/dist/bin/diff-changes.d.ts.map +1 -1
- package/dist/bin/diff-changes.js +16 -5
- package/dist/bin/diff-changes.js.map +1 -1
- package/dist/bin/main.js +32 -6
- 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 +9 -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/diff-changes.test.js +91 -0
- package/dist/test/unit/bin/diff-changes.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 +15 -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/diff-changes.test.ts +106 -0
- 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/README.md
CHANGED
|
@@ -57,7 +57,7 @@ When a PR is opened or code is pushed, the tool determines which actors need to
|
|
|
57
57
|
2. **CHANGELOG classification** — a `CHANGELOG.md` file is always `cosmetic` (only triggers a release build, not tests), for every actor, regardless of context or folder. (See [issue #106](https://github.com/apify/apify-test-tools/issues/106).)
|
|
58
58
|
3. **Context matching** — the file must fall within one of the actor's context paths (`dockerContextDir` from `actor.json` by default, or `overrideActorContext` from config if set). Files outside every context path are skipped.
|
|
59
59
|
4. **Hardcoded ignore list, context-aware** — the file path is first "hoisted" relative to the context path it matched (e.g. a standalone actor's own `.eslintrc` is checked as just `.eslintrc`, not the full repo-root-relative path), then checked against repo-level dev file patterns (`.vscode/`, `.gitignore`, `.husky/`, `.eslintrc`, `eslint.config.mjs`, `.prettierrc`, `.editorconfig`). There's no hardcoded special-casing for legacy `code/`/`shared/` layouts — repos that need those directories treated as top-level must list them explicitly in `overrideActorContext`.
|
|
60
|
-
5. **`.dockerignore` filtering** — if a `.dockerignore` exists at the root of the actor's `dockerContextDir`, matching files are ignored. Patterns are resolved relative to `dockerContextDir`, matching Docker's own behavior.
|
|
60
|
+
5. **`.dockerignore` filtering** — if a `.dockerignore` exists at the root of the actor's `dockerContextDir`, matching files are ignored. Patterns are resolved relative to `dockerContextDir`, matching Docker's own behavior. Test files are the exception: `build --test-files-glob <glob>` (relative to the repo root) skips this check for matching files, so a changed test triggers a build, and with it its tests, even when `.dockerignore` lists it. The PR workflow passes `test/platform/**`. Context matching still applies, so tests outside an actor's `dockerContextDir` (e.g. a root `test/` with standalone actors) need to be listed in its `overrideActorContext`; `build` logs a warning for changed test files outside every actor's context.
|
|
61
61
|
6. **README classification** — a `README.md` file is `cosmetic` (only triggers a release build, not tests) if it lives inside the actor's own `folder`; otherwise it's ignored entirely, since it isn't documentation for this actor.
|
|
62
62
|
7. **Cosmetic JSON classification** — `.json` files inside the actor's own `.actor/` directory with only cosmetic schema changes (whitespace, key ordering) only trigger a release build.
|
|
63
63
|
8. **Functional** — everything else triggers both build and tests.
|
|
@@ -308,48 +308,35 @@ Slack credentials in scope, so a postinstall script in the dependency tree canno
|
|
|
308
308
|
|
|
309
309
|
## Versioning and releases
|
|
310
310
|
|
|
311
|
-
The workflows and the npm package live in one repo but ship on their own schedules
|
|
312
|
-
decide what a consumer repo actually runs:
|
|
311
|
+
The workflows and the npm package live in one repo but ship on their own schedules:
|
|
313
312
|
|
|
314
|
-
|
|
|
315
|
-
|
|
|
316
|
-
| the `@workflows-v0` tag in `uses:`
|
|
317
|
-
|
|
|
313
|
+
| What | Consumers get it through | Ships when |
|
|
314
|
+
| -------------------------------------------- | ------------------------------------------ | ----------------------- |
|
|
315
|
+
| the workflows and the CLI they call | the `@workflows-v0` tag in `uses:` | every master push |
|
|
316
|
+
| the lib (`testActor`, `describe`, matchers…) | `apify-test-tools` in their `package.json` | a stable release on npm |
|
|
318
317
|
|
|
319
|
-
The
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
318
|
+
The CLI is bundled with its dependencies into `.github/cli/apify-test-tools.mjs` (`npm run bundle`,
|
|
319
|
+
run by the pre-commit hook and checked in CI), and the setup action runs it from there. Nothing is
|
|
320
|
+
installed for it, so a tag is a complete statement: these workflows and this CLI, with the exact
|
|
321
|
+
dependency versions from `package-lock.json` at that commit. The consumer's own `node_modules` is
|
|
322
|
+
left alone, so their tests keep the lib version they locked.
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
- **Workflow or CLI change** — merge it. `workflows-v0` moves once checks and tests pass, and it goes
|
|
325
|
+
live. No release needed, even when a workflow starts using a new CLI feature: both are in the
|
|
326
|
+
same commit.
|
|
327
|
+
- **Lib change** — merge it, then cut a release when you want it out. Consumers pick it up when
|
|
328
|
+
they update `apify-test-tools`.
|
|
327
329
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
-
|
|
331
|
-
- **Package-only change** — merge it, then cut a release when you want it out. The workflows are
|
|
332
|
-
unchanged, so consumers see nothing until the release lands.
|
|
333
|
-
- **A workflow that calls a new CLI feature** — the one case that can break consumers, and the only
|
|
334
|
-
one with any ceremony. Put the package change, the workflow change, and the new pin in one PR. On
|
|
335
|
-
merge the tag is **held**: the CI job reports that the pinned version isn't on npm and leaves `workflows-v0`
|
|
336
|
-
where it is, so consumers keep running the previous workflows. Cut a stable release, and the tag
|
|
337
|
-
moves on its own. Run **Move major version tag** if you don't want to wait for the next master
|
|
338
|
-
push.
|
|
330
|
+
The CLI and the lib in one job can be different versions now, so the formats between them are a
|
|
331
|
+
contract: the `ACTOR_BUILDS` JSON that `build` writes and the lib reads, and the vitest JSON report
|
|
332
|
+
`report-tests` reads. Add fields freely; renaming or removing one needs a new major tag.
|
|
339
333
|
|
|
340
334
|
`workflows-v0` moving on every master push means `@workflows-v0` is as live as `@master` was —
|
|
341
|
-
there's no staging step
|
|
342
|
-
|
|
343
|
-
|
|
335
|
+
there's no staging step. What the tag buys you is a `workflows-v1` for breaking workflow changes,
|
|
336
|
+
so repos migrate one at a time instead of all at once, and a way to roll back by pointing the tag at
|
|
337
|
+
an earlier commit (**Move major version tag** workflow). Bump `MAJOR_TAG` in
|
|
344
338
|
`.github/workflows/_move_major_tag.yaml` to cut the next major; the old tag then freezes where it is
|
|
345
|
-
and keeps working.
|
|
346
|
-
|
|
347
|
-
Freezing is real, which is the whole reason the version is pinned rather than a floor. A frozen `workflows-v0`
|
|
348
|
-
points at a commit whose pinned version never changes, so it keeps installing the library it was
|
|
349
|
-
tested against no matter how far the package moves on. Had the workflows installed `>=<floor>`, a
|
|
350
|
-
frozen `workflows-v0` would still resolve to whatever is newest — and since the release that enables
|
|
351
|
-
`workflows-v1` is usually the same release that breaks `workflows-v0`, the migration window would
|
|
352
|
-
have been zero.
|
|
339
|
+
and keeps working, CLI included, since the bundle is part of the frozen commit.
|
|
353
340
|
|
|
354
341
|
The tag tracks the **workflows'** contract, not the npm package version. They move independently on
|
|
355
342
|
purpose, so `@workflows-v0` is expected to stay `@workflows-v0` after the package reaches 1.0 —
|
|
@@ -360,9 +347,8 @@ and those refs disagree, which is the mistake that would otherwise ship silently
|
|
|
360
347
|
### Testing workflow changes
|
|
361
348
|
|
|
362
349
|
- Point [testing-repo-for-github-actions](https://github.com/apify-store/testing-repo-for-github-actions)
|
|
363
|
-
at your branch (`uses: ...@your-branch`). It has real attached Actors and tests.
|
|
364
|
-
|
|
365
|
-
action installs that exact version — so one branch tests both halves of a change together.
|
|
350
|
+
at your branch (`uses: ...@your-branch`). It has real attached Actors and tests. The branch carries
|
|
351
|
+
its own CLI bundle, so one branch tests the workflows and the CLI together.
|
|
366
352
|
- To change the composite action itself, repoint the `uses:` refs inside the reusable workflows at
|
|
367
353
|
your branch as well, and change them back before merging.
|
|
368
354
|
- Make sure the shell code actually works on your laptop first.
|
|
@@ -580,6 +566,15 @@ describe('standby tests', () => {
|
|
|
580
566
|
|
|
581
567
|
The package includes a CLI binary used by CI workflows to build Actors, detect changes, and report test results. You can also run it locally.
|
|
582
568
|
|
|
569
|
+
### Dry runs (`--dry-run`)
|
|
570
|
+
|
|
571
|
+
Every command that changes something (`build`, `build-from-local`, `release`, `delete-old-builds`, `report-tests`) takes `--dry-run`. A dry run:
|
|
572
|
+
|
|
573
|
+
- **Changes nothing.** No versions or builds are created, nothing is uploaded or deleted, and no Slack message is posted.
|
|
574
|
+
- **Does every read and check the real run does**, so it fails wherever the real run would: change detection, the Apify token and Actor lookup, the [repository check](#repository-check), collecting and zipping local sources, and the Slack token (verified with Slack's read-only `auth.test`).
|
|
575
|
+
- **Needs the same tokens as the real run.** Read-only tokens are enough, since nothing is written. A dry run can't tell whether a token is also allowed to write, because the Apify API has no dry-run mode of its own.
|
|
576
|
+
- **Prints a plan, not results.** Logs start with `[DRY RUN] Would …`. `build` and `build-from-local` write `{ "dryRun": true, "builds": [...] }` to stdout instead of the build array, so the plan can't be passed to tests as `ACTOR_BUILDS` by mistake.
|
|
577
|
+
|
|
583
578
|
### Running locally
|
|
584
579
|
|
|
585
580
|
Running the testing library locally is useful when you only want to update the testing code in /test because you can iterate on it without pushing new code to the remote.
|
|
@@ -610,7 +605,7 @@ APIFY_TOKEN_JOHN_DOE=<token> \
|
|
|
610
605
|
--dry-run
|
|
611
606
|
```
|
|
612
607
|
|
|
613
|
-
Remove `--dry-run` to actually trigger builds and update the branch names
|
|
608
|
+
With `--dry-run`, it prints the plan (the version and Git URL each Actor would be built from). Remove `--dry-run` to actually trigger builds and update the branch names. The command outputs a JSON array of build objects to stdout:
|
|
614
609
|
|
|
615
610
|
```json
|
|
616
611
|
[{ "buildId": "...", "actorRawId": "...", "buildNumber": "...", "actorFullName": "john.doe/my-actor" }]
|
|
@@ -625,7 +620,7 @@ APIFY_TOKEN_JOHN_DOE=<token> \
|
|
|
625
620
|
npx apify-test-tools build-from-local --actors john.doe/my-actor
|
|
626
621
|
```
|
|
627
622
|
|
|
628
|
-
Pass a hardcoded actor name via `--actors` to build only that Actor (comma-separate multiple names). Omit `--actors` to build all Actors in the repo, or add `--dry-run` to
|
|
623
|
+
Pass a hardcoded actor name via `--actors` to build only that Actor (comma-separate multiple names). Omit `--actors` to build all Actors in the repo, or add `--dry-run` to check the token, the Actor and the zipped sources without uploading or building. It outputs the same JSON build array as `build`, so you run tests against it the same way as in step 5 below:
|
|
629
624
|
|
|
630
625
|
```bash
|
|
631
626
|
# Build from local source and capture output
|
|
@@ -649,12 +644,15 @@ TESTER_APIFY_TOKEN=<token> \
|
|
|
649
644
|
|
|
650
645
|
#### Full example
|
|
651
646
|
|
|
647
|
+
Tests only run for Actors in `ACTOR_BUILDS`, so a custom workflow has to build (and pass) the Actors even when only tests changed. Pass `--test-files-glob` so test changes count even when `.dockerignore` lists them.
|
|
648
|
+
|
|
652
649
|
```bash
|
|
653
650
|
# Build and capture output
|
|
654
651
|
BUILDS=$(APIFY_TOKEN_JOHN_DOE=apify_api_xxx \
|
|
655
652
|
npx apify-test-tools build \
|
|
656
653
|
--target-branch origin/master \
|
|
657
|
-
--source-branch origin/my-dummy-branch
|
|
654
|
+
--source-branch origin/my-dummy-branch \
|
|
655
|
+
--test-files-glob 'test/platform/**')
|
|
658
656
|
|
|
659
657
|
# Run tests with the builds
|
|
660
658
|
ACTOR_BUILDS="$BUILDS" \
|
package/bin/build-from-local.ts
CHANGED
|
@@ -6,11 +6,11 @@ import AdmZip from 'adm-zip';
|
|
|
6
6
|
import type * as ApifyClientTypes from 'apify-client';
|
|
7
7
|
import { ActorSourceType } from 'apify-client';
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { ApifyBuilder, LOCAL_SOURCE_VERSION_NUMBER, runAndSummarizeBuilds } from './build.js';
|
|
10
10
|
import { loadDockerIgnore } from './dockerignore.js';
|
|
11
11
|
import { logger } from './logger.js';
|
|
12
12
|
import { isPathWithinScope } from './path-utils.js';
|
|
13
|
-
import type { ActorConfig, BuildData } from './types.js';
|
|
13
|
+
import type { ActorConfig, BuildData, DryRunPlan } from './types.js';
|
|
14
14
|
import type { SourceFile } from './utils.js';
|
|
15
15
|
import { getGitignoredPaths, isOutsideDir, listRepoFilePaths, readSourceFile } from './utils.js';
|
|
16
16
|
import type { RelativeDir } from './utils/path/repo-relative.js';
|
|
@@ -179,32 +179,66 @@ export const rewriteActorJsonPaths = async (
|
|
|
179
179
|
await fs.writeFile(path.join(newActorDir, 'actor.json'), JSON.stringify(rewritten, null, 4));
|
|
180
180
|
};
|
|
181
181
|
|
|
182
|
+
type LocalBuildPlan = {
|
|
183
|
+
actorFullName: string;
|
|
184
|
+
versionNumber: string;
|
|
185
|
+
folder: string;
|
|
186
|
+
fileCount: number;
|
|
187
|
+
zipSizeBytes: number;
|
|
188
|
+
};
|
|
189
|
+
|
|
182
190
|
export const runBuildsFromLocal = async ({
|
|
183
191
|
actorConfigs,
|
|
184
192
|
dryRun,
|
|
185
193
|
}: {
|
|
186
194
|
actorConfigs: ActorConfig[];
|
|
187
195
|
dryRun: boolean;
|
|
188
|
-
}): Promise<BuildData[]
|
|
196
|
+
}): Promise<BuildData[] | DryRunPlan<LocalBuildPlan>> => {
|
|
197
|
+
// Read-only preparation shared with the real build, so a dry run fails on a missing token, an unknown
|
|
198
|
+
// Actor or unreadable sources just like the build would
|
|
199
|
+
const preparedBuilds = await Promise.all(
|
|
200
|
+
actorConfigs.map(async (actorConfig) => {
|
|
201
|
+
const actorInfo = await ApifyBuilder.fromActorConfig(actorConfig).getActorInfo();
|
|
202
|
+
// Zipped and uploaded like `apify push` does, since inline SOURCE_FILES are capped at ~9 MB.
|
|
203
|
+
const zip = new AdmZip();
|
|
204
|
+
const sourceFiles = await collectSourceFiles(actorConfig);
|
|
205
|
+
for (const { name, content } of sourceFiles) {
|
|
206
|
+
zip.addFile(name, content);
|
|
207
|
+
}
|
|
208
|
+
return { actorConfig, actorInfo, fileCount: sourceFiles.length, zipBuffer: zip.toBuffer() };
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
189
212
|
if (dryRun) {
|
|
190
213
|
logger.info('[DRY RUN] Would build from local source:');
|
|
191
|
-
for (const {
|
|
192
|
-
logger.info(
|
|
214
|
+
for (const { actorConfig, fileCount, zipBuffer } of preparedBuilds) {
|
|
215
|
+
logger.info(
|
|
216
|
+
` ${actorConfig.actorFullName} (${actorConfig.folder}): ${fileCount} files, ${zipBuffer.length} bytes zipped`,
|
|
217
|
+
);
|
|
193
218
|
}
|
|
194
|
-
return
|
|
219
|
+
return {
|
|
220
|
+
dryRun: true,
|
|
221
|
+
builds: preparedBuilds.map(({ actorConfig, fileCount, zipBuffer }) => ({
|
|
222
|
+
actorFullName: actorConfig.actorFullName,
|
|
223
|
+
versionNumber: LOCAL_SOURCE_VERSION_NUMBER,
|
|
224
|
+
folder: actorConfig.folder.path,
|
|
225
|
+
fileCount,
|
|
226
|
+
zipSizeBytes: zipBuffer.length,
|
|
227
|
+
})),
|
|
228
|
+
};
|
|
195
229
|
}
|
|
196
230
|
|
|
231
|
+
const preparedByActorFullName = new Map(
|
|
232
|
+
preparedBuilds.map((prepared) => [prepared.actorConfig.actorFullName, prepared]),
|
|
233
|
+
);
|
|
234
|
+
|
|
197
235
|
return runAndSummarizeBuilds(actorConfigs, 'LOCAL BUILDS', async (actorConfig, builder) => {
|
|
198
|
-
|
|
199
|
-
const zip = new AdmZip();
|
|
200
|
-
for (const { name, content } of await collectSourceFiles(actorConfig)) {
|
|
201
|
-
zip.addFile(name, content);
|
|
202
|
-
}
|
|
236
|
+
const { actorInfo, zipBuffer } = preparedByActorFullName.get(actorConfig.actorFullName)!;
|
|
203
237
|
const actorVersion: ApifyClientTypes.ActorVersion = {
|
|
204
238
|
versionNumber: LOCAL_SOURCE_VERSION_NUMBER,
|
|
205
|
-
tarballUrl: await builder.uploadSourceZip(LOCAL_SOURCE_VERSION_NUMBER,
|
|
239
|
+
tarballUrl: await builder.uploadSourceZip(LOCAL_SOURCE_VERSION_NUMBER, zipBuffer),
|
|
206
240
|
sourceType: ActorSourceType.Tarball,
|
|
207
241
|
};
|
|
208
|
-
return builder.createVersionAndBuild(LOCAL_SOURCE_VERSION_NUMBER, actorVersion, false);
|
|
242
|
+
return builder.createVersionAndBuild(LOCAL_SOURCE_VERSION_NUMBER, actorVersion, false, actorInfo);
|
|
209
243
|
});
|
|
210
244
|
};
|
package/bin/build.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { ActorSourceType, ApifyClient } from 'apify-client';
|
|
|
3
3
|
|
|
4
4
|
import { normalizeRepoUrl } from './git.js';
|
|
5
5
|
import { logger } from './logger.js';
|
|
6
|
-
import type { ActorConfig, BuildData } from './types.js';
|
|
6
|
+
import type { ActorConfig, BuildData, DryRunPlan } from './types.js';
|
|
7
7
|
|
|
8
8
|
type BuildPrActorOptions = {
|
|
9
9
|
buildTag?: string;
|
|
@@ -179,7 +179,7 @@ export class ApifyBuilder {
|
|
|
179
179
|
* Deletes build of all versions. Apify API doesn't allow to delete default build and we explicitly skip it
|
|
180
180
|
* We delete devel builds faster because we used the for every PR until recently so just to get rid of them faster
|
|
181
181
|
*/
|
|
182
|
-
async deleteOldBuilds(): Promise<void> {
|
|
182
|
+
async deleteOldBuilds({ dryRun }: { dryRun: boolean }): Promise<void> {
|
|
183
183
|
// Even though we don't version our current Actors, if we ever such Actors to GitHub CI, we would accidentally delete old supported versions
|
|
184
184
|
// This hardcoded solution is not ideal, but it should prevent most imaginable cases
|
|
185
185
|
// All currently popular versioned Actors use `version-${number}` format
|
|
@@ -258,9 +258,15 @@ export class ApifyBuilder {
|
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
logger.info(
|
|
261
|
-
`[DELETE OLD BUILDS][${this.actorFullName}]: Deleting ${buildsToDelete.length}
|
|
262
|
-
`older than 30 days from total ${items.length}`,
|
|
261
|
+
`[DELETE OLD BUILDS][${this.actorFullName}]: ${dryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${buildsToDelete.length} ` +
|
|
262
|
+
`old builds that are non-default and older than 30 days from total ${items.length}`,
|
|
263
263
|
);
|
|
264
|
+
if (dryRun) {
|
|
265
|
+
for (const build of buildsToDelete) {
|
|
266
|
+
logger.info(` ${build.buildNumber} (${build.id}, started ${build.startedAt.toISOString()})`);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
264
270
|
for (const build of buildsToDelete) {
|
|
265
271
|
await this.apifyClient.build(build.id).delete();
|
|
266
272
|
}
|
|
@@ -310,13 +316,13 @@ export const runAndSummarizeBuilds = async (
|
|
|
310
316
|
return waitAndSummarizeBuilds(startedBuilds, buildersByActorFullName, label);
|
|
311
317
|
};
|
|
312
318
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
319
|
+
type GitBuildPlan = {
|
|
320
|
+
actorFullName: string;
|
|
321
|
+
versionNumber: string;
|
|
322
|
+
buildTag?: string;
|
|
323
|
+
gitRepoUrl: string;
|
|
324
|
+
useDockerCache: boolean;
|
|
325
|
+
};
|
|
320
326
|
|
|
321
327
|
type RunBuildsOptions = {
|
|
322
328
|
actorConfigs: ActorConfig[];
|
|
@@ -337,7 +343,7 @@ export const runBuilds = async ({
|
|
|
337
343
|
isLatest = false,
|
|
338
344
|
dryRun,
|
|
339
345
|
useDockerCache,
|
|
340
|
-
}: RunBuildsOptions): Promise<BuildData[]
|
|
346
|
+
}: RunBuildsOptions): Promise<BuildData[] | DryRunPlan<GitBuildPlan>> => {
|
|
341
347
|
// Fetched once per Actor and reused for the checks, the version numbers and the build itself
|
|
342
348
|
const buildConfigs: BuildPrActorOptions[] = await Promise.all(
|
|
343
349
|
actorConfigs.map(async (actorConfig) => {
|
|
@@ -366,14 +372,24 @@ export const runBuilds = async ({
|
|
|
366
372
|
}),
|
|
367
373
|
);
|
|
368
374
|
|
|
375
|
+
// Everything above only reads, so a dry run has checked what the real run would, short of writing
|
|
369
376
|
if (dryRun) {
|
|
370
377
|
logger.info('[DRY RUN] Would build:');
|
|
371
|
-
for (const { actorConfig, versionNumber } of buildConfigs) {
|
|
372
|
-
logger.info(` ${actorConfig.actorFullName} (${versionNumber})`);
|
|
378
|
+
for (const { actorConfig, versionNumber, gitRepoUrl } of buildConfigs) {
|
|
379
|
+
logger.info(` ${actorConfig.actorFullName} (${versionNumber}) from ${gitRepoUrl}`);
|
|
373
380
|
}
|
|
374
|
-
return
|
|
375
|
-
|
|
376
|
-
|
|
381
|
+
return {
|
|
382
|
+
dryRun: true,
|
|
383
|
+
builds: buildConfigs.map(
|
|
384
|
+
({ actorConfig, versionNumber, buildTag, gitRepoUrl, useDockerCache: useCache }) => ({
|
|
385
|
+
actorFullName: actorConfig.actorFullName,
|
|
386
|
+
versionNumber,
|
|
387
|
+
buildTag,
|
|
388
|
+
gitRepoUrl,
|
|
389
|
+
useDockerCache: useCache,
|
|
390
|
+
}),
|
|
391
|
+
),
|
|
392
|
+
};
|
|
377
393
|
}
|
|
378
394
|
|
|
379
395
|
const buildConfigsByActorFullName = new Map(
|
|
@@ -398,8 +414,8 @@ export const runBuilds = async ({
|
|
|
398
414
|
});
|
|
399
415
|
};
|
|
400
416
|
|
|
401
|
-
export const deleteOldBuilds = async (actorConfigs: ActorConfig[]) => {
|
|
417
|
+
export const deleteOldBuilds = async (actorConfigs: ActorConfig[], { dryRun }: { dryRun: boolean }) => {
|
|
402
418
|
for (const actorConfig of actorConfigs) {
|
|
403
|
-
await ApifyBuilder.fromActorConfig(actorConfig).deleteOldBuilds();
|
|
419
|
+
await ApifyBuilder.fromActorConfig(actorConfig).deleteOldBuilds({ dryRun });
|
|
404
420
|
}
|
|
405
421
|
};
|
package/bin/diff-changes.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import picomatch from 'picomatch';
|
|
2
|
+
|
|
1
3
|
import { isCosmeticOnlyJsonSchemaChange } from './diff-json-schema.js';
|
|
2
4
|
import { type DockerIgnoreMatcher, loadDockerIgnore } from './dockerignore.js';
|
|
3
5
|
import { logger } from './logger.js';
|
|
@@ -9,6 +11,8 @@ interface ShouldBuildAndTestOptions {
|
|
|
9
11
|
actorConfigs: ActorConfig[];
|
|
10
12
|
isLatest?: boolean;
|
|
11
13
|
commits: Commit[];
|
|
14
|
+
// Glob relative to the repo root. Matching files count as changes even when .dockerignore lists them.
|
|
15
|
+
testFilesGlob?: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
const IGNORED_TOP_LEVEL_FILES = [
|
|
@@ -41,7 +45,7 @@ type FileChangeForActor =
|
|
|
41
45
|
* 2. Context matching (actorConfig.contextPaths) → outside-context if no match
|
|
42
46
|
* 3. Hardcoded ignore list, checked against the path hoisted relative to the matched context entry → ignored
|
|
43
47
|
* 4. .dockerignore filtering (patterns relative to dockerContextDir), skipped for the actor's own `.actor/`
|
|
44
|
-
* dir → ignored if matched
|
|
48
|
+
* dir and for test files → ignored if matched
|
|
45
49
|
* 5. README.md by filename → cosmetic if inside the actor's own folder, otherwise ignored
|
|
46
50
|
* 6. .json inside the actor's own `.actor/` dir with only cosmetic schema diffs → cosmetic (semantically verified)
|
|
47
51
|
* 7. Everything else → functional
|
|
@@ -51,6 +55,7 @@ const classifyFileChange = async (
|
|
|
51
55
|
actorConfig: ActorConfig,
|
|
52
56
|
commits: Commit[],
|
|
53
57
|
dockerIgnoreMatcher: DockerIgnoreMatcher,
|
|
58
|
+
isTestFile: (filePath: string) => boolean,
|
|
54
59
|
): Promise<FileChangeForActor> => {
|
|
55
60
|
const lowercaseFilePath = changedFile.path.toLowerCase();
|
|
56
61
|
|
|
@@ -75,7 +80,8 @@ const classifyFileChange = async (
|
|
|
75
80
|
// .actor/ can legitimately be listed in .dockerignore (the Apify platform evaluates it before
|
|
76
81
|
// the Docker build, so excluding it from the build context is a valid caching optimization) —
|
|
77
82
|
// that shouldn't cause changes to .actor/ itself to be ignored here.
|
|
78
|
-
|
|
83
|
+
// Test files are often in .dockerignore too, but a change to them must still build and test.
|
|
84
|
+
if (!isUnderActorDotDir && !isTestFile(changedFile.path) && dockerIgnoreMatcher(changedFile.path)) {
|
|
79
85
|
return { impact: 'ignored' };
|
|
80
86
|
}
|
|
81
87
|
|
|
@@ -163,8 +169,11 @@ export const getChangedActors = async ({
|
|
|
163
169
|
actorConfigs,
|
|
164
170
|
isLatest = false,
|
|
165
171
|
commits,
|
|
172
|
+
testFilesGlob,
|
|
166
173
|
}: ShouldBuildAndTestOptions): Promise<ActorConfig[]> => {
|
|
167
174
|
const actorsChangedMap = new Map<string, ActorChangeEntry>();
|
|
175
|
+
const testFilesInContext = new Set<string>();
|
|
176
|
+
const isTestFile = testFilesGlob ? picomatch(testFilesGlob, { nocase: true, dot: true }) : () => false;
|
|
168
177
|
|
|
169
178
|
for (const actorConfig of actorConfigs) {
|
|
170
179
|
const dockerIgnoreMatcher = loadDockerIgnore(actorConfig.dockerContextDir);
|
|
@@ -176,7 +185,10 @@ export const getChangedActors = async ({
|
|
|
176
185
|
continue;
|
|
177
186
|
}
|
|
178
187
|
|
|
179
|
-
const change = await classifyFileChange(filePath, actorConfig, commits, dockerIgnoreMatcher);
|
|
188
|
+
const change = await classifyFileChange(filePath, actorConfig, commits, dockerIgnoreMatcher, isTestFile);
|
|
189
|
+
if (change.impact !== 'outside-context' && isTestFile(originalFilePath)) {
|
|
190
|
+
testFilesInContext.add(originalFilePath);
|
|
191
|
+
}
|
|
180
192
|
|
|
181
193
|
if (change.impact === 'ignored' || change.impact === 'outside-context') continue;
|
|
182
194
|
if (change.impact === 'cosmetic' && !isLatest) continue;
|
|
@@ -195,6 +207,15 @@ export const getChangedActors = async ({
|
|
|
195
207
|
const groups = groupFilesByActorSet(fileToActors);
|
|
196
208
|
logChangeGroups(groups);
|
|
197
209
|
|
|
210
|
+
for (const testFile of filepathsChanged.filter(
|
|
211
|
+
(filePath) => isTestFile(filePath) && !testFilesInContext.has(filePath),
|
|
212
|
+
)) {
|
|
213
|
+
logger.warn(
|
|
214
|
+
`[DIFF]: Test file ${testFile} is outside every Actor's context, so changing only it builds and tests nothing. ` +
|
|
215
|
+
`Add its folder to "overrideActorContext" of the Actors it tests.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
198
219
|
if (actorsChanged.length > 0) {
|
|
199
220
|
const actors = actorsChanged.map((config) => config.actorFullName);
|
|
200
221
|
logger.info(`[DIFF]: Actors to be built and tested: ${actors.join(', ')}`);
|
package/bin/main.ts
CHANGED
|
@@ -64,6 +64,19 @@ const actorSelectionOptions = {
|
|
|
64
64
|
ignore: { type: 'string', array: true, default: [] as string[] },
|
|
65
65
|
} as const satisfies Record<string, Options>;
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* For commands that change something (builds, deletes, Slack posts). A dry run still does every read and check
|
|
69
|
+
* the real run does, credentials included, and only skips the writes. So it needs the same tokens, though
|
|
70
|
+
* read-only ones are enough. Read-only commands don't take it since they have nothing to skip.
|
|
71
|
+
*/
|
|
72
|
+
const dryRunOptions = {
|
|
73
|
+
'dry-run': {
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
default: false,
|
|
76
|
+
describe: 'Do every read and check (tokens included) but change nothing; print what would be done',
|
|
77
|
+
},
|
|
78
|
+
} as const satisfies Record<string, Options>;
|
|
79
|
+
|
|
67
80
|
/** Flags for commands that trigger Apify builds. */
|
|
68
81
|
const buildOptions = {
|
|
69
82
|
'use-docker-cache': { type: 'boolean', default: false },
|
|
@@ -77,7 +90,6 @@ const globalOptions = {
|
|
|
77
90
|
default: 'info',
|
|
78
91
|
describe: 'Minimum diagnostic level written to stderr; silent disables logging',
|
|
79
92
|
},
|
|
80
|
-
'dry-run': { type: 'boolean', default: false },
|
|
81
93
|
} as const satisfies Record<string, Options>;
|
|
82
94
|
|
|
83
95
|
const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: boolean }) => {
|
|
@@ -99,6 +111,7 @@ const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: bo
|
|
|
99
111
|
filepathsChanged: branchOnlyFiles,
|
|
100
112
|
actorConfigs,
|
|
101
113
|
commits: allBranchCommits,
|
|
114
|
+
testFilesGlob: config.testFilesGlob,
|
|
102
115
|
});
|
|
103
116
|
if (branchOnlyActorsChanged.length === 0) {
|
|
104
117
|
logger.info('[MERGE-FROM-TARGET-OPTIMIZATION]: Branch itself has no functional changes, skipping tests');
|
|
@@ -112,7 +125,13 @@ const resolveChangedActors = async (config: Config, { isLatest }: { isLatest: bo
|
|
|
112
125
|
// If the optimization doesn't apply, we check all branch commits including merges for full coverage. We don't reuse the merge optimization results because here we can apply baseCommit and check merge commits (they might be functional or just cosmetic)
|
|
113
126
|
const commits = await getCommits(config);
|
|
114
127
|
const changedFiles = await getChangedFiles(commits);
|
|
115
|
-
return getChangedActors({
|
|
128
|
+
return getChangedActors({
|
|
129
|
+
filepathsChanged: changedFiles,
|
|
130
|
+
actorConfigs,
|
|
131
|
+
isLatest,
|
|
132
|
+
commits,
|
|
133
|
+
testFilesGlob: config.testFilesGlob,
|
|
134
|
+
});
|
|
116
135
|
};
|
|
117
136
|
|
|
118
137
|
await yargs()
|
|
@@ -172,6 +191,7 @@ await yargs()
|
|
|
172
191
|
'',
|
|
173
192
|
(y) =>
|
|
174
193
|
y
|
|
194
|
+
.options(dryRunOptions)
|
|
175
195
|
.option('report-file', { type: 'string', demandOption: true })
|
|
176
196
|
.option('report-slack-channel', { type: 'string' })
|
|
177
197
|
.option('job-url', { type: 'string' })
|
|
@@ -183,7 +203,14 @@ await yargs()
|
|
|
183
203
|
.command(
|
|
184
204
|
'build',
|
|
185
205
|
'',
|
|
186
|
-
(y) =>
|
|
206
|
+
(y) =>
|
|
207
|
+
y
|
|
208
|
+
.options(gitRangeOptions)
|
|
209
|
+
.options(actorSelectionOptions)
|
|
210
|
+
.options(buildOptions)
|
|
211
|
+
.options(repoUrlOptions)
|
|
212
|
+
.options(dryRunOptions)
|
|
213
|
+
.option('test-files-glob', { type: 'string' }),
|
|
187
214
|
async (config) => {
|
|
188
215
|
const actorsChanged = await resolveChangedActors(config, { isLatest: false });
|
|
189
216
|
const builds = await runBuilds({
|
|
@@ -204,6 +231,7 @@ await yargs()
|
|
|
204
231
|
.options(actorSelectionOptions)
|
|
205
232
|
.options(buildOptions)
|
|
206
233
|
.options(repoUrlOptions)
|
|
234
|
+
.options(dryRunOptions)
|
|
207
235
|
// The last commit that is already released, everything after it up to HEAD gets released.
|
|
208
236
|
// Required, see the README section "Releasing Actors" for how to set it for each merge strategy.
|
|
209
237
|
.option('base-commit', { type: 'string', demandOption: true })
|
|
@@ -256,7 +284,7 @@ await yargs()
|
|
|
256
284
|
.command(
|
|
257
285
|
'build-from-local',
|
|
258
286
|
'',
|
|
259
|
-
(y) => y.options(actorSelectionOptions),
|
|
287
|
+
(y) => y.options(actorSelectionOptions).options(dryRunOptions),
|
|
260
288
|
async ({ actors, ignore, dryRun }) => {
|
|
261
289
|
const actorConfigs = await readConfigFile({ actors, ignore });
|
|
262
290
|
const builds = await runBuildsFromLocal({ actorConfigs, dryRun });
|
|
@@ -266,10 +294,10 @@ await yargs()
|
|
|
266
294
|
.command(
|
|
267
295
|
'delete-old-builds',
|
|
268
296
|
'',
|
|
269
|
-
(y) => y.options(actorSelectionOptions),
|
|
270
|
-
async ({ actors, ignore }) => {
|
|
297
|
+
(y) => y.options(actorSelectionOptions).options(dryRunOptions),
|
|
298
|
+
async ({ actors, ignore, dryRun }) => {
|
|
271
299
|
const actorConfigs = await readConfigFile({ actors, ignore });
|
|
272
|
-
await deleteOldBuilds(actorConfigs);
|
|
300
|
+
await deleteOldBuilds(actorConfigs, { dryRun });
|
|
273
301
|
},
|
|
274
302
|
)
|
|
275
303
|
.strictCommands()
|
package/bin/slack.ts
CHANGED
|
@@ -15,6 +15,25 @@ type NotifyToSlackOptions = {
|
|
|
15
15
|
reportSlackChannel?: string;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Creates the client the real run posts with. A dry run posts nothing but still requires the token and checks
|
|
20
|
+
* Slack accepts it (`auth.test` is read-only), so it fails wherever the real run would.
|
|
21
|
+
*/
|
|
22
|
+
export const getSlackClient = async (tokenEnvVar: string, dryRun: boolean): Promise<WebClient> => {
|
|
23
|
+
const slack = new WebClient(getEnvVar(tokenEnvVar));
|
|
24
|
+
if (dryRun) {
|
|
25
|
+
try {
|
|
26
|
+
await slack.auth.test();
|
|
27
|
+
} catch (err) {
|
|
28
|
+
throw new Error(`${tokenEnvVar} was rejected by Slack: ${(err as Error).message}`);
|
|
29
|
+
}
|
|
30
|
+
logger.info(`[DRY RUN] ${tokenEnvVar} is valid`);
|
|
31
|
+
}
|
|
32
|
+
return slack;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const sendingLabel = (dryRun: boolean) => (dryRun ? '[DRY RUN] Would send' : 'Sending');
|
|
36
|
+
|
|
18
37
|
// Git authors come as "Name<email>", Slack only needs the name
|
|
19
38
|
const displayName = (author: string) => author.replace(/\s*<[^>]*>$/, '');
|
|
20
39
|
|
|
@@ -28,19 +47,23 @@ export const notifyToSlack = async ({
|
|
|
28
47
|
releaseSlackChannel,
|
|
29
48
|
reportSlackChannel,
|
|
30
49
|
}: NotifyToSlackOptions) => {
|
|
31
|
-
const slack = new WebClient(getEnvVar('SLACK_TOKEN_RELEASES_BOT'));
|
|
32
|
-
|
|
33
50
|
if (!changelog) {
|
|
34
51
|
logger.warn('No new changelog entries found, did you forget to update it?');
|
|
35
52
|
}
|
|
36
53
|
|
|
54
|
+
if (!releaseSlackChannel && !reportSlackChannel) {
|
|
55
|
+
logger.info('Skipping slack notification, no --release-slack-channel or --report-slack-channel passed');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const slack = await getSlackClient('SLACK_TOKEN_RELEASES_BOT', dryRun);
|
|
59
|
+
|
|
37
60
|
let shortMessage = `*${repository}* – New release (by ${displayName(author)}):\n\n`;
|
|
38
61
|
|
|
39
62
|
// This one is just for broader public that only cares about public facing changes
|
|
40
63
|
if (changelog && releaseSlackChannel) {
|
|
41
64
|
shortMessage += `*Additions to the changelog*:\n\n${changelog}\n`;
|
|
42
65
|
logger.info(`=========================================`);
|
|
43
|
-
logger.info(
|
|
66
|
+
logger.info(`**${sendingLabel(dryRun)} slack message to channel**: ${releaseSlackChannel}.\n\n${shortMessage}`);
|
|
44
67
|
logger.info(`=========================================`);
|
|
45
68
|
if (!dryRun) {
|
|
46
69
|
await slack.chat.postMessage({
|
|
@@ -62,7 +85,7 @@ export const notifyToSlack = async ({
|
|
|
62
85
|
// This one is for devs and project managers that need to know more details
|
|
63
86
|
if (reportSlackChannel) {
|
|
64
87
|
logger.info(`=========================================`);
|
|
65
|
-
logger.info(
|
|
88
|
+
logger.info(`${sendingLabel(dryRun)} slack message to channel: ${reportSlackChannel}.\n\n${longMessage}`);
|
|
66
89
|
logger.info(`=========================================`);
|
|
67
90
|
if (!dryRun) {
|
|
68
91
|
await slack.chat.postMessage({
|
|
@@ -73,8 +96,7 @@ export const notifyToSlack = async ({
|
|
|
73
96
|
}
|
|
74
97
|
};
|
|
75
98
|
|
|
76
|
-
export const sendSlackMessage = async (channel: string, text: string, blocks: string[]
|
|
77
|
-
const slack = new WebClient(token);
|
|
99
|
+
export const sendSlackMessage = async (slack: WebClient, channel: string, text: string, blocks: string[]) => {
|
|
78
100
|
const { ts } = await slack.chat.postMessage({ text, channel });
|
|
79
101
|
if (blocks.length > 0 && ts) {
|
|
80
102
|
await slack.chat.postMessage({
|
package/bin/test-report.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
|
|
3
3
|
import { logger } from './logger.js';
|
|
4
|
-
import { sendSlackMessage } from './slack.js';
|
|
5
|
-
import { getEnvVar } from './utils.js';
|
|
4
|
+
import { getSlackClient, sendingLabel, sendSlackMessage } from './slack.js';
|
|
6
5
|
|
|
7
6
|
interface ReportTestResultsOptions {
|
|
8
7
|
reportFile: string;
|
|
@@ -80,6 +79,9 @@ export const reportTestResults = async ({
|
|
|
80
79
|
);
|
|
81
80
|
return;
|
|
82
81
|
}
|
|
82
|
+
// Checked before knowing if there's anything to send, so a broken token shows up on a green run
|
|
83
|
+
// (and in dry runs) rather than on the day tests fail
|
|
84
|
+
const slack = await getSlackClient('SLACK_TOKEN_TESTS_BOT', dryRun);
|
|
83
85
|
|
|
84
86
|
if (failedAssertions.length === 0) {
|
|
85
87
|
return;
|
|
@@ -95,16 +97,11 @@ export const reportTestResults = async ({
|
|
|
95
97
|
.slice(1)
|
|
96
98
|
.map(({ message, runLink, actorId }) => `• ${message} --- <${runLink}|${actorId}>`);
|
|
97
99
|
|
|
98
|
-
logger.info(
|
|
100
|
+
logger.info(`${sendingLabel(dryRun)} slack message to channel ${reportSlackChannel}:`, slackMessage);
|
|
99
101
|
logger.info('\tblocks:', blocks.join('\n\t\t'));
|
|
100
102
|
|
|
101
|
-
if (!reportSlackChannel) {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
103
|
if (!dryRun) {
|
|
106
|
-
|
|
107
|
-
await sendSlackMessage(reportSlackChannel, slackMessage, blocks, slackToken);
|
|
104
|
+
await sendSlackMessage(slack, reportSlackChannel, slackMessage, blocks);
|
|
108
105
|
}
|
|
109
106
|
};
|
|
110
107
|
|