Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: builds namespace #620

Merged
merged 27 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eefca3d
feat: builds namespace
vladfrangu Aug 26, 2024
ad5f932
feat: builds ls
vladfrangu Aug 26, 2024
8f03c93
chore: return full json not just items
vladfrangu Aug 26, 2024
aae3670
chore: only mark these cmds as supporting json for now
vladfrangu Aug 26, 2024
6921eaa
chore: include actor username too
vladfrangu Aug 26, 2024
b7bd906
feat: builds create
vladfrangu Aug 26, 2024
12aed77
chore: more enhancements to build ls
vladfrangu Aug 27, 2024
1767123
Merge branch 'master' into feat/builds-namespace
vladfrangu Aug 27, 2024
893fae2
Merge branch 'master' into feat/builds-namespace
vladfrangu Aug 28, 2024
3656c54
chore: push --log flag
vladfrangu Aug 30, 2024
7391d99
chore: nicer log
vladfrangu Aug 30, 2024
7b4776c
Merge branch 'master' into feat/builds-namespace
vladfrangu Aug 30, 2024
965d3f3
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 2, 2024
dae1f5a
chore: some requested changes
vladfrangu Sep 2, 2024
3b44f38
chore: basic tests
vladfrangu Sep 2, 2024
2df93f8
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 2, 2024
b4adcf1
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 3, 2024
f8425d4
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 4, 2024
d858078
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 5, 2024
7b40a3e
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 6, 2024
e050956
Apply suggestions from code review
vladfrangu Sep 6, 2024
f978bd9
chore: attempt to split up ls more
vladfrangu Sep 9, 2024
fe8b002
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 9, 2024
f8b67c0
chore: disable pr for now (will be handled in future PR)
vladfrangu Sep 9, 2024
0ec21c0
docs: correct Actor spelling
vladfrangu Sep 9, 2024
4134faf
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 9, 2024
8d724be
Merge branch 'master' into feat/builds-namespace
vladfrangu Sep 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/cucumber.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ name: Cucumber E2E tests

on:
workflow_dispatch:
push:
paths:
- "features/**"
# risky... but we trust our developers :finger_crossed:
vladfrangu marked this conversation as resolved.
Show resolved Hide resolved
# pull_request:
# paths:
# - "features/**"

jobs:
make_salad:
Expand Down Expand Up @@ -39,6 +46,5 @@ jobs:
- name: Run Cucumber tests
env:
APIFY_CLI_DISABLE_TELEMETRY: 1
# TODO: once we start writing tests that interact with Apify platform, just uncomment this line :salute:
# TEST_USER_TOKEN: ${{ secrets.APIFY_TEST_USER_API_TOKEN }}
TEST_USER_TOKEN: ${{ secrets.APIFY_TEST_USER_API_TOKEN }}
run: yarn test:cucumber
119 changes: 119 additions & 0 deletions features/builds-namespace.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Feature: Builds namespace

- As an Actor developer or user
- I want to be able to manage the builds of my actors on Apify Console
- In order to trigger new builds from the CLI, list them, and get details about them

## Background:

- Given my `pwd` is a fully initialized Actor project directory
- And the `actor.json` is valid
- And I am a logged in Apify Console User

## Rule: Creating builds works

### Example: calling create with invalid actor ID fails

- When I run:
```
$ apify builds create --actor=invalid-id
```
- Then I can read text on stderr:
```
Actor with name or ID "invalid-id" was not found
```

### Example: calling create from an unpublished actor directory fails

- When I run:
```
$ apify builds create
```
- Then I can read text on stderr:
```
Actor with name "{{ testActorName }}" was not found
```

### Example: calling create from a published actor directory works

- Given the local Actor is pushed to the Apify platform
- When I run:
```
$ apify builds create
```
- Then I can read text on stderr:
```
Build Started
```
- And I can read text on stderr:
```
{{ testActorName}}
```

### Example: calling create from a published actor with `--json` prints valid JSON data

- Given the local Actor is pushed to the Apify platform
- When I run:
```
$ apify builds create --json
```
- Then I can read valid JSON on stdout

## Rule: Printing information about builds works

### Example: calling info with invalid build ID fails

- When I run:
```
$ apify builds info invalid-id
```
- Then I can read text on stderr:
```
Build with ID "invalid-id" was not found
```

### Example: calling info with valid build ID works

- Given the local Actor is pushed to the Apify platform
- When I run:
```
$ apify builds create
```
- And I capture the build ID
- And I run with captured data:
```
$ apify builds info {{ buildId }}
```
- Then I can read text on stderr:
```
{{ testActorName }}
```

### Example: calling info with valid build ID and `--json` prints valid JSON data

- Given the local Actor is pushed to the Apify platform
- When I run:
```
$ apify builds create
```
- And I capture the build ID
- And I run with captured data:
```
$ apify builds info {{ buildId }} --json
```
- Then I can read valid JSON on stdout

## Rule: Listing builds works

<!-- TODO table testing? -->

### Example: calling list with --json prints valid JSON data

- Given the local Actor is pushed to the Apify platform
- When I run:
```
$ apify builds ls --json
```
- Then I can read valid JSON on stdout

<!-- TODO: We should test builds log, but that's gonna be annoying, so for now leave it as is -->
12 changes: 12 additions & 0 deletions features/test-implementations/0.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface StringMatcherTemplate {
testActorName?: string;
buildId?: string;
}

export function replaceMatchersInString(str: string, matchers: StringMatcherTemplate): string {
for (const [key, replaceValue] of Object.entries(matchers) as [keyof StringMatcherTemplate, string][]) {
str = str.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), replaceValue);
}

return str;
}
30 changes: 27 additions & 3 deletions features/test-implementations/0.world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';

import type { IWorld } from '@cucumber/cucumber';
import { Result } from '@sapphire/result';
import type { ApifyClient } from 'apify-client';
import { type Options, type ExecaError, type Result as ExecaResult, execaNode } from 'execa';

type DynamicOptions = {
Expand Down Expand Up @@ -33,7 +34,14 @@ export interface TestWorld<Parameters = unknown[]> extends IWorld<Parameters> {
* Input that should be provided to the command via stdin
*/
stdinInput?: string;
/**
* The name of the actor, used for matchers
*/
name?: string;
};
apifyClient?: ApifyClient;
authStatePath?: string;
capturedData?: Record<string, string>;
}

/**
Expand All @@ -59,7 +67,8 @@ export async function executeCommand({
rawCommand,
stdin,
cwd = TestTmpRoot,
}: { rawCommand: string; stdin?: string; cwd?: string | URL }) {
env,
}: { rawCommand: string; stdin?: string; cwd?: string | URL; env?: Record<string, string> }) {
// Step 0: ensure the command is executable -> strip out $, trim spaces
const commandToRun = rawCommand.split('\n').map((str) => str.replace(/^\$/, '').trim());

Expand All @@ -86,6 +95,7 @@ export async function executeCommand({

const options: DynamicOptions = {
cwd,
env,
};

if (process.env.CUCUMBER_PRINT_EXEC) {
Expand Down Expand Up @@ -143,7 +153,7 @@ export async function executeCommand({

export function assertWorldIsValid(
world: TestWorld,
): asserts world is TestWorld & { testActor: { pwd: URL; initialized: true } } {
): asserts world is TestWorld & { testActor: { pwd: URL; initialized: true; name: string } } {
if (!world.testActor || !world.testActor.initialized) {
throw new RangeError(
'Test actor must be initialized before running any subsequent background requirements. You may have the order of your steps wrong. The "Given my `pwd` is a fully initialized Actor project directory" step needs to run before this step',
Expand Down Expand Up @@ -188,6 +198,20 @@ export function assertWorldHasRunResult(world: TestWorld): asserts world is Test
}
}

export function assertWorldIsLoggedIn(world: TestWorld): asserts world is TestWorld & { apifyClient: ApifyClient } {
if (!world.apifyClient) {
throw new RangeError('You must run the "Given a logged in Apify Console user" step before running this step');
}
}

export function assertWorldHasCapturedData(world: TestWorld): asserts world is TestWorld & {
capturedData: Record<string, string>;
} {
if (!world.capturedData) {
throw new RangeError(`You must run the "I capture the <type> ID" step before running this step`);
}
}

export async function getActorRunResults(world: TestWorld & { testActor: { pwd: URL; initialized: true } }) {
const startedPath = new URL('./storage/key_value_stores/default/STARTED.json', world.testActor.pwd);
const inputPath = new URL('./storage/key_value_stores/default/RECEIVED_INPUT.json', world.testActor.pwd);
Expand All @@ -206,7 +230,7 @@ export async function getActorRunResults(world: TestWorld & { testActor: { pwd:
}

return {
started: parsed,
started: parsed as 'works',
receivedInput: receivedInput ? JSON.parse(receivedInput) : null,
};
}
101 changes: 98 additions & 3 deletions features/test-implementations/1.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,54 @@ import { randomBytes } from 'node:crypto';
import { readFile, rm, writeFile } from 'node:fs/promises';

import { AfterAll, Given, setDefaultTimeout } from '@cucumber/cucumber';

import { assertWorldIsValid, executeCommand, getActorRunResults, TestTmpRoot, type TestWorld } from './0.world';
import { ApifyClient } from 'apify-client';

import {
assertWorldIsLoggedIn,
assertWorldIsValid,
executeCommand,
getActorRunResults,
TestTmpRoot,
type TestWorld,
} from './0.world';
import { getApifyClientOptions } from '../../src/lib/utils';

setDefaultTimeout(20_000);

const createdActors: URL[] = [];
const pushedActorIds: string[] = [];
let globalClient: ApifyClient;

if (!process.env.DO_NOT_DELETE_CUCUMBER_TEST_ACTORS) {
AfterAll(async () => {
console.log(`\n Cleaning up actors for worker ${process.env.CUCUMBER_WORKER_ID}...`);
if (!createdActors.length) {
return;
}

console.log(`\n Cleaning up Actors for worker ${process.env.CUCUMBER_WORKER_ID}...`);

for (const path of createdActors) {
await rm(path, { recursive: true, force: true });
}
});

AfterAll(async () => {
if (!pushedActorIds.length) {
return;
}

console.log(`\n Cleaning up pushed Actors for worker ${process.env.CUCUMBER_WORKER_ID}...`);

const me = await globalClient.user('me').get();

for (const id of pushedActorIds) {
try {
await globalClient.actor(`${me.username}/${id}`).delete();
} catch (err) {
console.error(`Failed to delete Actor ${id}: ${(err as Error).message}`);
}
}
});
}

const actorJs = await readFile(new URL('./0.basic-actor.js', import.meta.url), 'utf8');
Expand All @@ -40,6 +73,7 @@ Given<TestWorld>(/my `?pwd`? is a fully initialized actor project directory/i, {
if (result.isOk()) {
this.testActor.pwd = new URL(`./${actorName}/`, TestTmpRoot);
this.testActor.initialized = true;
this.testActor.name = actorName;

createdActors.push(this.testActor.pwd);

Expand Down Expand Up @@ -152,3 +186,64 @@ Given<TestWorld>(
await writeFile(file, jsonValue);
},
);

Given<TestWorld>(/logged in apify console user/i, async function () {
if (!process.env.TEST_USER_TOKEN) {
throw new Error('No test user token provided');
}

// Try to make the client with the token
const client = new ApifyClient(getApifyClientOptions(process.env.TEST_USER_TOKEN));

try {
await client.user('me').get();
} catch (err) {
throw new Error(`Failed to get user information: ${(err as Error).message}`);
}

// Login with the CLI too

const authStatePath = `cucumber-${randomBytes(12).toString('hex')}`;

const result = await executeCommand({
rawCommand: `apify login --token ${process.env.TEST_USER_TOKEN}`,
env: {
// Keep in sync with GLOBAL_CONFIGS_FOLDER in consts.ts
__APIFY_INTERNAL_TEST_AUTH_PATH__: authStatePath,
},
});

// This will throw if there was an error
result.unwrap();

this.apifyClient = client;
this.authStatePath = authStatePath;

// We need it for later cleanup
globalClient = client;
});

Given<TestWorld>(/the local actor is pushed to the Apify platform/i, { timeout: 240_000 }, async function () {
assertWorldIsValid(this);
assertWorldIsLoggedIn(this);

const extraEnv: Record<string, string> = {};

if (this.authStatePath) {
// eslint-disable-next-line no-underscore-dangle
extraEnv.__APIFY_INTERNAL_TEST_AUTH_PATH__ = this.authStatePath;
}

const result = await executeCommand({
rawCommand: 'apify push --no-prompt',
cwd: this.testActor.pwd,
env: extraEnv,
});

if (result.isOk()) {
pushedActorIds.push(this.testActor.name);
} else {
// This throws on errors
result.unwrap();
}
});
Loading
Loading