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
@@ -1,8 +1,25 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
1
5
  import type * as ApifyClientTypes from 'apify-client';
2
- import { ActorSourceType } from 'apify-client';
3
- import { describe, expect, it } from 'vitest';
6
+ import { ActorSourceType, ApifyClient } from 'apify-client';
7
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ import {
10
+ assertRepoUrlMatchesDefaultVersion,
11
+ deleteOldBuilds,
12
+ resolveDefaultVersion,
13
+ runBuilds,
14
+ } from '../../../bin/build.js';
15
+ import type { ActorConfig } from '../../../bin/types.js';
16
+ import { ExistingDir, ExistingFile, RelativeDir } from '../../../bin/utils/path/repo-relative.js';
4
17
 
5
- import { assertRepoUrlMatchesDefaultVersion, resolveDefaultVersion } from '../../../bin/build.js';
18
+ // Only the client is faked, so the tests exercise the real builder code against a stubbed API
19
+ vi.mock('apify-client', async (importOriginal) => {
20
+ const actual = await importOriginal<typeof ApifyClientTypes>();
21
+ return { ...actual, ApifyClient: vi.fn() };
22
+ });
6
23
 
7
24
  const ACTOR = 'owner/actor';
8
25
  const REPO_URL = 'git@github.com:owner/repo';
@@ -66,3 +83,118 @@ describe('assertRepoUrlMatchesDefaultVersion', () => {
66
83
  expect(() => assertRepoUrlMatchesDefaultVersion(ACTOR, undefined, REPO_URL)).toThrow('no longer exists');
67
84
  });
68
85
  });
86
+
87
+ describe('dry run', () => {
88
+ const TOKEN_ENV_VAR = 'APIFY_TOKEN_OWNER';
89
+ const originalCwd = process.cwd();
90
+ let fixtureRoot: string;
91
+ let actorConfig: ActorConfig;
92
+
93
+ // ExistingDir checks the folder exists, so the config is built inside a throwaway repo root
94
+ beforeAll(() => {
95
+ fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'apify-build-dry-run-'));
96
+ process.chdir(fixtureRoot);
97
+ fs.mkdirSync('actors/a/.actor', { recursive: true });
98
+ fs.writeFileSync('actors/a/.actor/actor.json', '{}');
99
+ actorConfig = {
100
+ actorFullName: ACTOR,
101
+ folder: new ExistingDir('actors/a'),
102
+ tokenEnvVar: TOKEN_ENV_VAR,
103
+ actorJson: {
104
+ file: new ExistingFile('actors/a/.actor/actor.json'),
105
+ dockerContextDir: new RelativeDir('actors/a'),
106
+ },
107
+ dockerContextDir: new RelativeDir('actors/a'),
108
+ contextPaths: [new RelativeDir('actors/a')],
109
+ };
110
+ });
111
+
112
+ afterAll(() => {
113
+ process.chdir(originalCwd);
114
+ fs.rmSync(fixtureRoot, { recursive: true, force: true });
115
+ });
116
+ const deleteBuild = vi.fn();
117
+ const startBuild = vi.fn();
118
+ const createVersion = vi.fn();
119
+ const buildClient = vi.fn(() => ({ delete: deleteBuild }));
120
+
121
+ beforeEach(() => {
122
+ vi.stubEnv(TOKEN_ENV_VAR, 'token');
123
+ const fakeClient = {
124
+ actor: () => ({
125
+ get: async () => makeActorInfo(),
126
+ // Old enough to be deleted, and not the default build 0.1.5
127
+ builds: () => ({
128
+ list: async () => ({ items: [{ id: 'old-build', buildNumber: '0.0.1', startedAt: new Date(0) }] }),
129
+ }),
130
+ versions: () => ({ create: createVersion }),
131
+ version: () => ({ update: createVersion }),
132
+ build: startBuild,
133
+ }),
134
+ build: buildClient,
135
+ };
136
+ vi.mocked(ApifyClient).mockImplementation(function () {
137
+ return fakeClient;
138
+ } as never);
139
+ });
140
+
141
+ afterEach(() => {
142
+ vi.unstubAllEnvs();
143
+ vi.clearAllMocks();
144
+ });
145
+
146
+ const runDryBuilds = async () =>
147
+ runBuilds({
148
+ actorConfigs: [actorConfig],
149
+ repoUrl: REPO_URL,
150
+ shouldVerifyRepoUrl: true,
151
+ branch: 'feature',
152
+ dryRun: true,
153
+ useDockerCache: false,
154
+ });
155
+
156
+ it('should return a build plan without creating versions or builds', async () => {
157
+ expect(await runDryBuilds()).toEqual({
158
+ dryRun: true,
159
+ builds: [
160
+ {
161
+ actorFullName: ACTOR,
162
+ versionNumber: '0.99',
163
+ gitRepoUrl: `${REPO_URL}#feature:actors/a`,
164
+ useDockerCache: false,
165
+ },
166
+ ],
167
+ });
168
+ expect(createVersion).not.toHaveBeenCalled();
169
+ expect(startBuild).not.toHaveBeenCalled();
170
+ });
171
+
172
+ it('should still require the Apify token', async () => {
173
+ vi.stubEnv(TOKEN_ENV_VAR, '');
174
+ await expect(runDryBuilds()).rejects.toThrow(`Env var ${TOKEN_ENV_VAR} is not set`);
175
+ });
176
+
177
+ it('should still check the repository URL', async () => {
178
+ await expect(
179
+ runBuilds({
180
+ actorConfigs: [actorConfig],
181
+ repoUrl: 'git@github.com:fork/repo',
182
+ shouldVerifyRepoUrl: true,
183
+ branch: 'feature',
184
+ dryRun: true,
185
+ useDockerCache: false,
186
+ }),
187
+ ).rejects.toThrow('Repository mismatch');
188
+ });
189
+
190
+ it('should not delete old builds', async () => {
191
+ await deleteOldBuilds([actorConfig], { dryRun: true });
192
+ expect(deleteBuild).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('should delete old builds outside of a dry run', async () => {
196
+ await deleteOldBuilds([actorConfig], { dryRun: false });
197
+ expect(buildClient).toHaveBeenCalledWith('old-build');
198
+ expect(deleteBuild).toHaveBeenCalledTimes(1);
199
+ });
200
+ });
@@ -63,6 +63,7 @@ const commits = [{ sha: 'Commit1', author: '', date: '', message: '' }];
63
63
 
64
64
  describe('getChangedActors', () => {
65
65
  beforeEach(() => {
66
+ vi.restoreAllMocks();
66
67
  vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockResolvedValue(false);
67
68
  });
68
69
 
@@ -393,6 +394,72 @@ describe('getChangedActors', () => {
393
394
  expect(result).toEqual([miniActor]);
394
395
  });
395
396
 
397
+ it('test file listed in .dockerignore triggers a build when it matches the test files glob', async () => {
398
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue((filePath) => filePath.startsWith('test/'));
399
+ const result = await getChangedActors({
400
+ filepathsChanged: ['test/platform/daily/foo.test.ts'],
401
+ actorConfigs: [miniActor],
402
+ commits,
403
+ testFilesGlob: 'test/platform/**',
404
+ });
405
+ expect(result).toEqual([miniActor]);
406
+ });
407
+
408
+ it('test file listed in .dockerignore stays ignored without a test files glob', async () => {
409
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue((filePath) => filePath.startsWith('test/'));
410
+ const result = await getChangedActors({
411
+ filepathsChanged: ['test/platform/daily/foo.test.ts'],
412
+ actorConfigs: [miniActor],
413
+ commits,
414
+ });
415
+ expect(result).toEqual([]);
416
+ });
417
+
418
+ it('test files glob matches case-insensitively and does not bypass .dockerignore for other files', async () => {
419
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue(() => true);
420
+ const result = await getChangedActors({
421
+ filepathsChanged: ['Test/Platform/Foo.test.ts'],
422
+ actorConfigs: [miniActor],
423
+ commits,
424
+ testFilesGlob: 'test/platform/**',
425
+ });
426
+ expect(result).toEqual([miniActor]);
427
+ expect(
428
+ await getChangedActors({
429
+ filepathsChanged: ['actors/foo_bar/node_modules/foo.js'],
430
+ actorConfigs: [miniActor],
431
+ commits,
432
+ testFilesGlob: 'test/platform/**',
433
+ }),
434
+ ).toEqual([]);
435
+ });
436
+
437
+ it('test file outside an actor context still does not trigger it', async () => {
438
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue(() => true);
439
+ const result = await getChangedActors({
440
+ filepathsChanged: ['test/platform/daily/foo.test.ts'],
441
+ actorConfigs: [standaloneActor],
442
+ commits,
443
+ testFilesGlob: 'test/platform/**',
444
+ });
445
+ expect(result).toEqual([]);
446
+ });
447
+
448
+ it('test file listed in .dockerignore triggers an actor whose overrideActorContext includes it', async () => {
449
+ vi.spyOn(Dockerignore, 'loadDockerIgnore').mockReturnValue(() => true);
450
+ const actorWithTests = {
451
+ ...standaloneActor,
452
+ contextPaths: [...standaloneActor.contextPaths, new RelativeDir('test')],
453
+ };
454
+ const result = await getChangedActors({
455
+ filepathsChanged: ['test/platform/standalone.test.ts'],
456
+ actorConfigs: [actorWithTests],
457
+ commits,
458
+ testFilesGlob: 'test/platform/**',
459
+ });
460
+ expect(result).toEqual([actorWithTests]);
461
+ });
462
+
396
463
  it('JSON file in context but outside actor folder is functional (not checked for cosmetic)', async () => {
397
464
  const result = await getChangedActors({
398
465
  filepathsChanged: ['lib/config.json'],
@@ -473,6 +540,7 @@ describe('getChangedActors logging', () => {
473
540
  beforeEach(() => {
474
541
  vi.spyOn(DiffJsonSchema, 'isCosmeticOnlyJsonSchemaChange').mockResolvedValue(false);
475
542
  vi.spyOn(logger, 'info').mockImplementation(() => undefined);
543
+ vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
476
544
  });
477
545
 
478
546
  it('logs a single "specific" group for a single actor with one functional file', async () => {
@@ -525,6 +593,44 @@ describe('getChangedActors logging', () => {
525
593
  ]);
526
594
  });
527
595
 
596
+ const outsideContextWarning = (testFile: string) =>
597
+ `[DIFF]: Test file ${testFile} is outside every Actor's context, so changing only it builds and tests nothing. ` +
598
+ 'Add its folder to "overrideActorContext" of the Actors it tests.';
599
+
600
+ it('warns about a changed test file outside every Actor context', async () => {
601
+ await getChangedActors({
602
+ filepathsChanged: ['test/platform/standalone.test.ts'],
603
+ actorConfigs: [standaloneActor],
604
+ commits,
605
+ testFilesGlob: 'test/platform/**',
606
+ });
607
+
608
+ expect(logger.warn).toHaveBeenCalledWith(outsideContextWarning('test/platform/standalone.test.ts'));
609
+ });
610
+
611
+ it('still warns when the same push also rebuilds an Actor', async () => {
612
+ const result = await getChangedActors({
613
+ filepathsChanged: ['test/platform/standalone.test.ts', 'standalone-actors/standalone/src/main.ts'],
614
+ actorConfigs: [standaloneActor],
615
+ commits,
616
+ testFilesGlob: 'test/platform/**',
617
+ });
618
+
619
+ expect(result).toEqual([standaloneActor]);
620
+ expect(logger.warn).toHaveBeenCalledWith(outsideContextWarning('test/platform/standalone.test.ts'));
621
+ });
622
+
623
+ it('does not warn about test files inside an Actor context, even cosmetic ones', async () => {
624
+ await getChangedActors({
625
+ filepathsChanged: ['test/platform/foo.test.ts', 'test/platform/README.md', 'test/platform/CHANGELOG.md'],
626
+ actorConfigs: [miniActor],
627
+ commits,
628
+ testFilesGlob: 'test/platform/**',
629
+ });
630
+
631
+ expect(logger.warn).not.toHaveBeenCalled();
632
+ });
633
+
528
634
  it('logs no group lines when zero actors changed', async () => {
529
635
  await getChangedActors({
530
636
  filepathsChanged: ['.gitignore', '.prettierrc'],
@@ -0,0 +1,65 @@
1
+ import { WebClient } from '@slack/web-api';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { notifyToSlack } from '../../../bin/slack.js';
5
+
6
+ vi.mock('@slack/web-api', () => ({ WebClient: vi.fn() }));
7
+
8
+ const TOKEN_ENV_VAR = 'SLACK_TOKEN_RELEASES_BOT';
9
+ const postMessage = vi.fn();
10
+ const authTest = vi.fn();
11
+
12
+ const notify = async (overrides: Partial<Parameters<typeof notifyToSlack>[0]> = {}) =>
13
+ notifyToSlack({
14
+ repository: 'owner/repo',
15
+ changedFiles: ['src/main.ts'],
16
+ changelog: '- Fixed a bug',
17
+ commits: [{ sha: '1', author: 'Test<test@example.com>', date: '', message: 'fix: bug' }],
18
+ author: 'Test<test@example.com>',
19
+ dryRun: true,
20
+ releaseSlackChannel: '#releases',
21
+ reportSlackChannel: '#notif',
22
+ ...overrides,
23
+ });
24
+
25
+ describe('notifyToSlack', () => {
26
+ beforeEach(() => {
27
+ vi.stubEnv(TOKEN_ENV_VAR, 'token');
28
+ vi.mocked(WebClient).mockImplementation(function () {
29
+ return { chat: { postMessage }, auth: { test: authTest } };
30
+ } as never);
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.unstubAllEnvs();
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ it('should verify the token but post nothing in a dry run', async () => {
39
+ await notify();
40
+ expect(authTest).toHaveBeenCalledTimes(1);
41
+ expect(postMessage).not.toHaveBeenCalled();
42
+ });
43
+
44
+ it('should still require the token in a dry run', async () => {
45
+ vi.stubEnv(TOKEN_ENV_VAR, '');
46
+ await expect(notify()).rejects.toThrow(`${TOKEN_ENV_VAR} not defined`);
47
+ });
48
+
49
+ it('should fail a dry run when Slack rejects the token', async () => {
50
+ authTest.mockRejectedValueOnce(new Error('An API error occurred: invalid_auth'));
51
+ await expect(notify()).rejects.toThrow(`${TOKEN_ENV_VAR} was rejected by Slack`);
52
+ });
53
+
54
+ it('should not need a token when no channel is passed', async () => {
55
+ vi.stubEnv(TOKEN_ENV_VAR, '');
56
+ await notify({ dryRun: false, releaseSlackChannel: undefined, reportSlackChannel: undefined });
57
+ expect(WebClient).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it('should post to both channels outside of a dry run', async () => {
61
+ await notify({ dryRun: false });
62
+ expect(authTest).not.toHaveBeenCalled();
63
+ expect(postMessage).toHaveBeenCalledTimes(2);
64
+ });
65
+ });
@@ -1,74 +0,0 @@
1
- #!/usr/bin/env node
2
- // Reads changed file paths on stdin (one per line) and fails if a PR changes both a consumer-facing
3
- // workflow and the package code it calls, without repinning in workflows-package-version.
4
- //
5
- // The case this catches: a workflow starts using a CLI feature from the same PR. On merge the tag
6
- // moves, the workflow goes live, and it calls something that is not on npm yet — breaking every
7
- // consumer's CI at once. Pinning the unreleased version instead holds the tag until it publishes.
8
- //
9
- // It deliberately only looks at one PR. A workflow could also start depending on package code that
10
- // landed in an *earlier*, still-unreleased PR, which this will not see — that needs someone to ship
11
- // a breaking CLI change and then sit on it unreleased, and catching it means flagging every workflow
12
- // edit made while any package change is unreleased, which is far more noise than the case is worth.
13
-
14
- const PUBLIC_WORKFLOW = /^\.github\/workflows\/public_.*\.ya?ml$/;
15
- // The composite action is consumer-facing too, and it is what installs the package.
16
- const PUBLIC_ACTION = /^\.github\/actions\//;
17
- // What `npx apify-test-tools ...` runs, and the library the platform tests import.
18
- const PACKAGE_CODE = /^(bin\/|lib\/|index\.ts$)/;
19
- const PIN_FILE = '.github/workflows-package-version';
20
- const OVERRIDE_LABEL = 'no-version-bump-needed';
21
-
22
- const changed = await new Promise((resolve) => {
23
- let buffer = '';
24
- process.stdin.setEncoding('utf8');
25
- process.stdin.on('data', (chunk) => (buffer += chunk));
26
- process.stdin.on('end', () =>
27
- resolve(
28
- buffer
29
- .split('\n')
30
- .map((l) => l.trim())
31
- .filter(Boolean),
32
- ),
33
- );
34
- });
35
-
36
- const publicSurface = changed.filter((f) => PUBLIC_WORKFLOW.test(f) || PUBLIC_ACTION.test(f));
37
- const packageCode = changed.filter((f) => PACKAGE_CODE.test(f));
38
- const versionRepinned = changed.includes(PIN_FILE);
39
-
40
- if (publicSurface.length === 0 || packageCode.length === 0) {
41
- console.log('No consumer-facing workflow and package change in the same PR; nothing to check.');
42
- process.exit(0);
43
- }
44
-
45
- if (versionRepinned) {
46
- console.log(`Both sides changed and ${PIN_FILE} was repinned. The tag will hold until that version ships.`);
47
- process.exit(0);
48
- }
49
-
50
- if (
51
- (process.env.PR_LABELS ?? '')
52
- .split(',')
53
- .map((l) => l.trim())
54
- .includes(OVERRIDE_LABEL)
55
- ) {
56
- console.log(`Both sides changed without a version bump, allowed by the "${OVERRIDE_LABEL}" label.`);
57
- process.exit(0);
58
- }
59
-
60
- console.error(`This PR changes consumer-facing workflows and the package code they call:
61
-
62
- workflows: ${publicSurface.join(', ')}
63
- package: ${packageCode.join(', ')}
64
-
65
- On merge the tag moves and those workflows go live immediately, against whatever is on npm today.
66
- If they rely on anything from the package change in this PR, that is not published yet and every
67
- consumer's CI breaks.
68
-
69
- If they do rely on it, set ${PIN_FILE} to the version that will contain it. The tag is then held
70
- until you cut that release, and moves on its own once it is published.
71
-
72
- If they do not rely on it — the two changes just happen to be in one PR — add the
73
- "${OVERRIDE_LABEL}" label.`);
74
- process.exit(1);
@@ -1 +0,0 @@
1
- 0.10.1