apify-test-tools 0.10.1-beta.4 → 0.10.1-beta.6
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/.claude-plugin/marketplace.json +12 -0
- package/.github/workflows/public_platform-tests-claude-investigate-and-fix.yaml +15 -8
- package/CHANGELOG.md +2 -0
- package/README.md +6 -7
- package/dist/test/unit/bin/build-from-local.test.js +6 -15
- package/dist/test/unit/bin/build-from-local.test.js.map +1 -1
- package/dist/test/unit/bin/git-command.test.js +12 -42
- package/dist/test/unit/bin/git-command.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/platform-tests-investigate-and-fix/.claude-plugin/plugin.json +4 -0
- package/platform-tests-investigate-and-fix/skills/measure-flakiness/SKILL.md +28 -0
- package/test/unit/bin/build-from-local.test.ts +9 -18
- package/test/unit/bin/git-command.test.ts +14 -55
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: measure-flakiness
|
|
3
|
+
description: Re-runs the failing Actor run many times in parallel to measure how often the failure happens. Use only when you are about to create a new issue for a failing platform test and its failure rate is not already obvious.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Measure flakiness
|
|
7
|
+
|
|
8
|
+
Use this only when both are true:
|
|
9
|
+
|
|
10
|
+
- You are about to create a new issue — no existing issue already covers this failure.
|
|
11
|
+
- How often the failure happens isn't obvious. Blocking, too few scraped items or a missing field can happen in 1% of runs or in all of them, and one failed run doesn't tell which.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
The Apify API token is in the `TESTER_APIFY_TOKEN` environment variable. It is always set, so there's no need to check for it.
|
|
16
|
+
|
|
17
|
+
1. From the failing run (the run the failed test started; its link is in the test log), get the Actor ID, build number, memory, timeout and input. For example, you can get the first four from `GET https://api.apify.com/v2/actor-runs/{runId}`.
|
|
18
|
+
2. Decide how to tell whether a finished run hit the same failure. Check what the failed test assertion checked — e.g. the run's status, its dataset item count against the expected minimum, a field missing from items, a log line.
|
|
19
|
+
3. Start runs of the same Actor at once, with the same build, memory, timeout and input. For example, you can start each with `POST https://api.apify.com/v2/acts/{actorId}/runs`. If a run can't start because the account's memory limit is reached, start it once earlier runs finish.
|
|
20
|
+
|
|
21
|
+
Decide on your own how many runs you need, up to !`echo "$MAX_FLAKINESS_RUNS"` in total. The point isn't a statistically significant rate, but a rough idea of whether the failure is frequent, infrequent or a once-in-a-lifetime event. For example:
|
|
22
|
+
- Blocking that looks persistent: if around 20 runs all get blocked the same way, that's enough.
|
|
23
|
+
- A rare flake that's hard to reproduce, e.g. one item out of hundreds missing a field: start a lot of runs — it may take a hundred runs, and after that another hundred, to see it even a few times.
|
|
24
|
+
|
|
25
|
+
4. Wait until every run has finished, then apply the check from step 2 to each. If there were no or almost no reproductions and you can't tell yet how often the failure happens, start more runs and check them the same way, but up to !`echo "$MAX_FLAKINESS_RUNS"` in total.
|
|
26
|
+
5. Add a short `## Flakiness` section to the issue, right after the Summary:
|
|
27
|
+
- The rate, e.g. "Fails in 28 of 47 runs (60%)". Leave out runs that failed for another reason, e.g. never started — only mention how many there were.
|
|
28
|
+
- Links to up to 2 runs that hit the failure.
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type * as ChildProcessModule from 'node:child_process';
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
1
|
import fs from 'node:fs/promises';
|
|
4
2
|
import os from 'node:os';
|
|
5
3
|
import path from 'node:path';
|
|
@@ -17,20 +15,8 @@ import {
|
|
|
17
15
|
} from '../../../bin/build-from-local.js';
|
|
18
16
|
import * as Utils from '../../../bin/utils.js';
|
|
19
17
|
|
|
20
|
-
// Defaults to the real spawnSync so `git init`/`git ls-files` calls made by the code under test
|
|
21
|
-
// (and by test setup below) actually run — individual tests override this via mockReturnValue
|
|
22
|
-
// where they need to fake git's output, and vi.restoreAllMocks() reverts back to this passthrough.
|
|
23
|
-
vi.mock('node:child_process', async (importOriginal) => {
|
|
24
|
-
const actual = await importOriginal<typeof ChildProcessModule>();
|
|
25
|
-
return { ...actual, spawnSync: vi.fn(actual.spawnSync) };
|
|
26
|
-
});
|
|
27
|
-
|
|
28
18
|
const mkTempDir = async (prefix: string) => fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
29
19
|
|
|
30
|
-
const initGitRepo = (dir: string) => {
|
|
31
|
-
spawnSync('git', ['init', '-q'], { cwd: dir });
|
|
32
|
-
};
|
|
33
|
-
|
|
34
20
|
describe('build-from-local helpers', () => {
|
|
35
21
|
const tempDirs: string[] = [];
|
|
36
22
|
|
|
@@ -43,15 +29,13 @@ describe('build-from-local helpers', () => {
|
|
|
43
29
|
it('drops secret-pattern files and gitignored files, keeps everything else', async () => {
|
|
44
30
|
const rootDir = await mkTempDir('apify-test-tools-collect-');
|
|
45
31
|
tempDirs.push(rootDir);
|
|
46
|
-
initGitRepo(rootDir);
|
|
47
32
|
|
|
48
33
|
await fs.writeFile(path.join(rootDir, 'main.js'), 'console.log(1)');
|
|
49
34
|
await fs.writeFile(path.join(rootDir, '.env'), 'SECRET=1');
|
|
50
35
|
await fs.mkdir(path.join(rootDir, 'sub'));
|
|
51
36
|
await fs.writeFile(path.join(rootDir, 'sub', 'ignored.log'), 'log');
|
|
52
37
|
|
|
53
|
-
|
|
54
|
-
// proves the secret-pattern backstop applies independently of .gitignore.
|
|
38
|
+
vi.spyOn(Utils, 'listRepoFilePaths').mockResolvedValue(['main.js', '.env', 'sub/ignored.log']);
|
|
55
39
|
vi.spyOn(Utils, 'getGitignoredPaths').mockImplementation(
|
|
56
40
|
async (relativePaths) => new Set(relativePaths.filter((p) => p.endsWith('.log'))),
|
|
57
41
|
);
|
|
@@ -146,7 +130,6 @@ describe('build-from-local helpers', () => {
|
|
|
146
130
|
it("always collects the actor's own .actor/actor.json for a monorepo actor, since flattenMonorepoContext depends on it", async () => {
|
|
147
131
|
const repoRoot = await mkTempDir('apify-test-tools-collect-source-');
|
|
148
132
|
tempDirs.push(repoRoot);
|
|
149
|
-
initGitRepo(repoRoot);
|
|
150
133
|
|
|
151
134
|
const originalCwd = process.cwd();
|
|
152
135
|
// We simulate the working directory being the repo root, since collectSourceFiles uses relative paths to the repo root.
|
|
@@ -161,6 +144,14 @@ describe('build-from-local helpers', () => {
|
|
|
161
144
|
);
|
|
162
145
|
await fs.writeFile(path.join(cwd, 'package.json'), '{}');
|
|
163
146
|
|
|
147
|
+
vi.spyOn(Utils, 'listRepoFilePaths').mockResolvedValue([
|
|
148
|
+
'actors/owner_actor/.actor/actor.json',
|
|
149
|
+
'package.json',
|
|
150
|
+
]);
|
|
151
|
+
vi.spyOn(Utils, 'getGitignoredPaths').mockResolvedValue(
|
|
152
|
+
new Set(['actors/owner_actor/.actor/actor.json']),
|
|
153
|
+
);
|
|
154
|
+
|
|
164
155
|
const sourceFiles = await collectSourceFiles('owner/actor', actorDir);
|
|
165
156
|
expect(sourceFiles.map((file) => file.name)).toContain('.actor/actor.json');
|
|
166
157
|
} finally {
|
|
@@ -1,71 +1,30 @@
|
|
|
1
|
-
import os from 'node:os';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
1
|
import { describe, expect, it } from 'vitest';
|
|
5
2
|
|
|
6
|
-
import {
|
|
3
|
+
import { GitCommandError, runGitCommand } from '../../../bin/utils.js';
|
|
7
4
|
|
|
8
5
|
describe('runGitCommand', () => {
|
|
9
|
-
it('returns trimmed Git output', async () => {
|
|
10
|
-
await expect(
|
|
11
|
-
runGitCommand(['rev-parse', '--is-inside-work-tree'], {
|
|
12
|
-
cwd: process.cwd(),
|
|
13
|
-
operation: 'check whether the current directory is in a Git repository',
|
|
14
|
-
}),
|
|
15
|
-
).resolves.toBe('true');
|
|
6
|
+
it('returns trimmed Git output without requiring a repository', async () => {
|
|
7
|
+
await expect(runGitCommand(['--version'])).resolves.toMatch(/^git version /);
|
|
16
8
|
});
|
|
17
9
|
|
|
18
|
-
it('
|
|
19
|
-
const error: unknown = await runGitCommand(['
|
|
20
|
-
|
|
21
|
-
operation: 'verify a commit',
|
|
10
|
+
it('wraps a Git failure with the command and operation', async () => {
|
|
11
|
+
const error: unknown = await runGitCommand(['--definitely-invalid-option'], {
|
|
12
|
+
operation: 'check error handling',
|
|
22
13
|
}).catch((failure: unknown) => failure);
|
|
14
|
+
|
|
23
15
|
expect(error).toBeInstanceOf(GitCommandError);
|
|
24
16
|
if (error instanceof GitCommandError) {
|
|
25
|
-
expect(error.exitCode).toBe(
|
|
26
|
-
expect(error.
|
|
27
|
-
expect(error.message).toContain('
|
|
28
|
-
expect(error.
|
|
17
|
+
expect(error.exitCode).not.toBe(0);
|
|
18
|
+
expect(error.message).toContain('Failed to check error handling.\n');
|
|
19
|
+
expect(error.message).toContain('Command: git "--definitely-invalid-option"\n');
|
|
20
|
+
expect(error.gitError).toContain('--definitely-invalid-option');
|
|
29
21
|
expect(error.cause).toBeInstanceOf(Error);
|
|
30
22
|
}
|
|
31
23
|
});
|
|
32
24
|
|
|
33
|
-
it('passes arguments literally
|
|
34
|
-
await expect(
|
|
35
|
-
|
|
36
|
-
operation: 'verify a commit reference',
|
|
37
|
-
}),
|
|
38
|
-
).rejects.toThrow('Command: git "rev-parse" "--verify" "HEAD; echo injected"\nGit error:');
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('listRepoFilePaths', () => {
|
|
43
|
-
it('lists files under a subdirectory relative to the repository root', async () => {
|
|
44
|
-
const repoRoot = process.cwd();
|
|
45
|
-
await expect(listRepoFilePaths(repoRoot, path.join(repoRoot, 'bin'))).resolves.toContain('bin/utils.ts');
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('getGitignoredPaths', () => {
|
|
50
|
-
it('returns an empty set for no paths', async () => {
|
|
51
|
-
await expect(getGitignoredPaths([])).resolves.toStrictEqual(new Set());
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('returns only paths ignored by the repository rules', async () => {
|
|
55
|
-
await expect(
|
|
56
|
-
getGitignoredPaths(['node_modules/example.js', 'bin/utils.ts', '.DS_Store'], process.cwd()),
|
|
57
|
-
).resolves.toStrictEqual(new Set(['node_modules/example.js', '.DS_Store']));
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('returns an empty set when Git exits with code 1', async () => {
|
|
61
|
-
await expect(getGitignoredPaths(['bin/utils.ts'], process.cwd())).resolves.toStrictEqual(new Set());
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('rejects unexpected Git errors', async () => {
|
|
65
|
-
await expect(getGitignoredPaths(['bin/utils.ts'], os.tmpdir())).rejects.toThrow(
|
|
66
|
-
'Failed to check which repository paths are ignored.\n' +
|
|
67
|
-
'Command: git "check-ignore" "--" "bin/utils.ts"\n' +
|
|
68
|
-
'Git error: fatal: not a git repository',
|
|
25
|
+
it('passes arguments literally instead of running them through a shell', async () => {
|
|
26
|
+
await expect(runGitCommand(['--version; echo injected'])).rejects.toThrow(
|
|
27
|
+
'Command: git "--version; echo injected"\nGit error:',
|
|
69
28
|
);
|
|
70
29
|
});
|
|
71
30
|
});
|