Skip to content

Commit

Permalink
feat: builds namespace (#620)
Browse files Browse the repository at this point in the history
Co-authored-by: Jindřich Bär <[email protected]>
  • Loading branch information
vladfrangu and barjin authored Sep 9, 2024
1 parent 0eed20b commit 6345162
Show file tree
Hide file tree
Showing 20 changed files with 1,143 additions and 11 deletions.
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:
# 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

0 comments on commit 6345162

Please sign in to comment.