From 84b6690e81ebde8693415dd12a88dc73a5a2f5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Thu, 16 Apr 2026 23:43:32 +0200 Subject: [PATCH 1/2] batch2 --- src/commands/actors/rm.ts | 7 + src/commands/builds/remove-tag.ts | 8 +- src/commands/builds/rm.ts | 26 ++- src/commands/datasets/rm.ts | 7 + src/commands/init.ts | 9 +- src/commands/key-value-stores/delete-value.ts | 7 + src/commands/key-value-stores/rm.ts | 7 + src/commands/runs/rm.ts | 7 + src/lib/command-framework/flags.ts | 9 ++ .../commands/builds/create-info-ls.test.ts | 2 +- test/e2e/commands/datasets/lifecycle.test.ts | 138 ++++++++++++++++ .../key-value-stores/lifecycle.test.ts | 149 ++++++++++++++++++ 12 files changed, 357 insertions(+), 19 deletions(-) create mode 100644 test/e2e/commands/datasets/lifecycle.test.ts create mode 100644 test/e2e/commands/key-value-stores/lifecycle.test.ts diff --git a/src/commands/actors/rm.ts b/src/commands/actors/rm.ts index 168b863ca..795d89668 100644 --- a/src/commands/actors/rm.ts +++ b/src/commands/actors/rm.ts @@ -2,6 +2,7 @@ import type { ApifyApiError } from 'apify-client'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; +import { YesFlag } from '../../lib/command-framework/flags.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow } from '../../lib/utils.js'; @@ -18,8 +19,13 @@ export class ActorsRmCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { actorId } = this.args; + const { yes } = this.flags; const apifyClient = await getLoggedClientOrThrow(); @@ -32,6 +38,7 @@ export class ActorsRmCommand extends ApifyCommand { const confirmedDelete = await useYesNoConfirm({ message: `Are you sure you want to delete this Actor?`, + providedConfirmFromStdin: yes || undefined, }); if (!confirmedDelete) { diff --git a/src/commands/builds/remove-tag.ts b/src/commands/builds/remove-tag.ts index 6ca252e8e..27b8648fc 100644 --- a/src/commands/builds/remove-tag.ts +++ b/src/commands/builds/remove-tag.ts @@ -2,7 +2,7 @@ import type { ActorTaggedBuild, ApifyApiError } from 'apify-client'; import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; -import { Flags } from '../../lib/command-framework/flags.js'; +import { Flags, YesFlag } from '../../lib/command-framework/flags.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info, success } from '../../lib/outputs.js'; import { getLoggedClientOrThrow } from '../../lib/utils.js'; @@ -23,11 +23,7 @@ export class BuildsRemoveTagCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { buildId } = this.args; + const { yes } = this.flags; const apifyClient = await getLoggedClientOrThrow(); @@ -46,11 +52,21 @@ export class BuildsRmCommand extends ApifyCommand { } // If the build is tagged, console asks you to confirm by typing in the tag. Otherwise, it asks you to confirm with a yes/no question. - const confirmed = await (confirmationPrompt ? useInputConfirmation : useYesNoConfirm)({ - message: `Are you sure you want to delete this Actor Build?${confirmationPrompt ? ` If so, please type in "${confirmationPrompt}":` : ''}`, - expectedValue: confirmationPrompt ?? '', - failureMessage: 'Your provided value does not match the build tag.', - }); + let confirmed: string | boolean; + + if (confirmationPrompt) { + confirmed = await useInputConfirmation({ + message: `Are you sure you want to delete this Actor Build? If so, please type in "${confirmationPrompt}":`, + expectedValue: confirmationPrompt, + failureMessage: 'Your provided value does not match the build tag.', + providedConfirmFromStdin: yes ? confirmationPrompt : undefined, + }); + } else { + confirmed = await useYesNoConfirm({ + message: `Are you sure you want to delete this Actor Build?`, + providedConfirmFromStdin: yes || undefined, + }); + } if (!confirmed) { info({ diff --git a/src/commands/datasets/rm.ts b/src/commands/datasets/rm.ts index 4b2810162..0aedc8042 100644 --- a/src/commands/datasets/rm.ts +++ b/src/commands/datasets/rm.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; +import { YesFlag } from '../../lib/command-framework/flags.js'; import { tryToGetDataset } from '../../lib/commands/storages.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info, success } from '../../lib/outputs.js'; @@ -20,8 +21,13 @@ export class DatasetsRmCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { datasetNameOrId } = this.args; + const { yes } = this.flags; const client = await getLoggedClientOrThrow(); @@ -37,6 +43,7 @@ export class DatasetsRmCommand extends ApifyCommand { const confirmed = await useYesNoConfirm({ message: `Are you sure you want to delete this Dataset?`, + providedConfirmFromStdin: yes || undefined, }); if (!confirmed) { diff --git a/src/commands/init.ts b/src/commands/init.ts index 347d97f50..8b187ed72 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -3,7 +3,7 @@ import process from 'node:process'; import { ApifyCommand } from '../lib/command-framework/apify-command.js'; import { Args } from '../lib/command-framework/args.js'; -import { Flags } from '../lib/command-framework/flags.js'; +import { Flags, YesFlag } from '../lib/command-framework/flags.js'; import { CommandExitCodes, DEFAULT_LOCAL_STORAGE_DIR, EMPTY_LOCAL_CONFIG, LOCAL_CONFIG_PATH } from '../lib/consts.js'; import { useActorConfig } from '../lib/hooks/useActorConfig.js'; import { ProjectLanguage, useCwdProject } from '../lib/hooks/useCwdProject.js'; @@ -31,12 +31,7 @@ export class InitCommand extends ApifyCommand { }; static override flags = { - yes: Flags.boolean({ - char: 'y', - description: - 'Automatic yes to prompts; assume "yes" as answer to all prompts. Note that in some cases, the command may still ask for confirmation.', - required: false, - }), + ...YesFlag, dockerfile: Flags.string({ description: 'Path to a Dockerfile to use for the Actor (e.g., "./Dockerfile" or "./docker/Dockerfile").', required: false, diff --git a/src/commands/key-value-stores/delete-value.ts b/src/commands/key-value-stores/delete-value.ts index 7418cec10..66140c317 100644 --- a/src/commands/key-value-stores/delete-value.ts +++ b/src/commands/key-value-stores/delete-value.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; +import { YesFlag } from '../../lib/command-framework/flags.js'; import { tryToGetKeyValueStore } from '../../lib/commands/storages.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { error, info } from '../../lib/outputs.js'; @@ -24,8 +25,13 @@ export class KeyValueStoresDeleteValueCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { runId } = this.args; + const { yes } = this.flags; const apifyClient = await getLoggedClientOrThrow(); @@ -49,6 +55,7 @@ export class RunsRmCommand extends ApifyCommand { const confirmedDelete = await useYesNoConfirm({ message: `Are you sure you want to delete this Actor Run?`, + providedConfirmFromStdin: yes || undefined, }); if (!confirmedDelete) { diff --git a/src/lib/command-framework/flags.ts b/src/lib/command-framework/flags.ts index 910c0d7a6..97cecd644 100644 --- a/src/lib/command-framework/flags.ts +++ b/src/lib/command-framework/flags.ts @@ -64,6 +64,15 @@ export const Flags = { integer: integerFlag, }; +/** Reusable `--yes` / `-y` flag for commands with confirmation prompts. */ +export const YesFlag = { + yes: Flags.boolean({ + char: 'y', + description: 'Automatic yes to prompts; assume "yes" as answer to all prompts.', + default: false, + }), +}; + function stringFlag>( options: T & { choices?: Choices }, ): TaggedFlagBuilder<'string', Choices, T['default'] extends string ? true : T['required'], T['default']> { diff --git a/test/e2e/commands/builds/create-info-ls.test.ts b/test/e2e/commands/builds/create-info-ls.test.ts index 47cd739b2..b2e0e2cb4 100644 --- a/test/e2e/commands/builds/create-info-ls.test.ts +++ b/test/e2e/commands/builds/create-info-ls.test.ts @@ -45,7 +45,7 @@ describe('[e2e][api] builds namespace', () => { const me = await client.user('me').get(); await client.actor(`${me.username}/${actor.name}`).delete(); } catch { - // Best-effort cleanup + // Do nothing } } diff --git a/test/e2e/commands/datasets/lifecycle.test.ts b/test/e2e/commands/datasets/lifecycle.test.ts new file mode 100644 index 000000000..c4319e711 --- /dev/null +++ b/test/e2e/commands/datasets/lifecycle.test.ts @@ -0,0 +1,138 @@ +import { randomBytes } from 'node:crypto'; + +import { ApifyClient } from 'apify-client'; + +import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { runCli } from '../../__helpers__/run-cli.js'; + +describe('[e2e][api] datasets namespace', () => { + let authEnv: Record; + let client: ApifyClient; + let datasetId: string; + const datasetName = `e2e-ds-${randomBytes(6).toString('hex')}`; + const renamedDatasetName = `e2e-ds-renamed-${randomBytes(6).toString('hex')}`; + + beforeAll(async () => { + const token = process.env.TEST_USER_TOKEN; + if (!token) throw new Error('TEST_USER_TOKEN env var is required for datasets tests'); + + const authPath = `e2e-datasets-${randomBytes(6).toString('hex')}`; + authEnv = { __APIFY_INTERNAL_TEST_AUTH_PATH__: authPath }; + + const loginResult = await runCli('apify', ['login', '--token', token], { env: authEnv }); + if (loginResult.exitCode !== 0) { + throw new Error(`Failed to login:\n${loginResult.stderr}`); + } + + client = new ApifyClient(getApifyClientOptions(token)); + }); + + afterAll(async () => { + if (datasetId && client) { + try { + await client.dataset(datasetId).delete(); + } catch { + // Do nothing + } + } + }); + + it('creates a named dataset', async () => { + const result = await runCli('apify', ['datasets', 'create', datasetName, '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const ds = JSON.parse(result.stdout); + datasetId = ds.id; + expect(datasetId).toBeTruthy(); + }); + + it('creates a named dataset (non-json output)', async () => { + // Create another dataset to test non-json output, then delete it + const tmpName = `e2e-ds-tmp-${randomBytes(6).toString('hex')}`; + const result = await runCli('apify', ['datasets', 'create', tmpName], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('was created'); + + // Clean up the temp dataset via API + const listResult = await runCli('apify', ['datasets', 'ls', '--json'], { env: authEnv }); + const list = JSON.parse(listResult.stdout); + const tmpDs = list.items.find((d: { name: string }) => d.name === tmpName); + if (tmpDs) { + await client.dataset(tmpDs.id).delete(); + } + }); + + it('pushes items to the dataset via argument', async () => { + const result = await runCli('apify', ['datasets', 'push-items', datasetId, '{"foo":"bar"}'], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('pushed to'); + }); + + it('pushes items to the dataset via stdin', async () => { + const result = await runCli('apify', ['datasets', 'push-items', datasetId], { + stdin: '[{"a":1},{"b":2}]', + env: authEnv, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain('pushed to'); + }); + + it('gets items from the dataset (JSON)', async () => { + const result = await runCli('apify', ['datasets', 'get-items', datasetId], { env: authEnv }); + expect(result.exitCode).toBe(0); + const items = JSON.parse(result.stdout); + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThanOrEqual(3); + expect(items[0]).toHaveProperty('foo', 'bar'); + }); + + it('gets items with --limit', async () => { + const result = await runCli('apify', ['datasets', 'get-items', datasetId, '--limit', '1'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const items = JSON.parse(result.stdout); + expect(items).toHaveLength(1); + }); + + it('gets items in CSV format', async () => { + const result = await runCli('apify', ['datasets', 'get-items', datasetId, '--format', 'csv'], { env: authEnv }); + expect(result.exitCode).toBe(0); + // CSV output should have comma-separated values + expect(result.stdout).toContain(','); + }); + + it('shows dataset info (--json)', async () => { + const result = await runCli('apify', ['datasets', 'info', datasetId, '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const info = JSON.parse(result.stdout); + expect(info.id).toBe(datasetId); + }); + + it('shows dataset info (non-json)', async () => { + const result = await runCli('apify', ['datasets', 'info', datasetId], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(datasetId); + }); + + it('lists datasets (--json)', async () => { + const result = await runCli('apify', ['datasets', 'ls', '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const list = JSON.parse(result.stdout); + expect(list).toHaveProperty('items'); + const found = list.items.some((d: { id: string }) => d.id === datasetId); + expect(found).toBe(true); + }); + + it('renames the dataset', async () => { + const result = await runCli('apify', ['datasets', 'rename', datasetId, renamedDatasetName], { env: authEnv }); + expect(result.exitCode).toBe(0); + // The output uses "was changed from" when the dataset already has a name + expect(result.stdout).toContain('was changed from'); + }); + + it('deletes the dataset', async () => { + const result = await runCli('apify', ['datasets', 'rm', datasetId, '--yes'], { env: authEnv }); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + expect(result.stdout).toContain('has been deleted'); + // Clear datasetId so afterAll doesn't try to delete again + datasetId = ''; + }); +}); diff --git a/test/e2e/commands/key-value-stores/lifecycle.test.ts b/test/e2e/commands/key-value-stores/lifecycle.test.ts new file mode 100644 index 000000000..2f461d247 --- /dev/null +++ b/test/e2e/commands/key-value-stores/lifecycle.test.ts @@ -0,0 +1,149 @@ +import { randomBytes } from 'node:crypto'; + +import { ApifyClient } from 'apify-client'; + +import { getApifyClientOptions } from '../../../../src/lib/utils.js'; +import { runCli } from '../../__helpers__/run-cli.js'; + +describe('[e2e][api] key-value-stores namespace', () => { + let authEnv: Record; + let client: ApifyClient; + let storeId: string; + const storeName = `e2e-kvs-${randomBytes(6).toString('hex')}`; + const renamedStoreName = `e2e-kvs-renamed-${randomBytes(6).toString('hex')}`; + + beforeAll(async () => { + const token = process.env.TEST_USER_TOKEN; + if (!token) throw new Error('TEST_USER_TOKEN env var is required for key-value-stores tests'); + + const authPath = `e2e-kvs-${randomBytes(6).toString('hex')}`; + authEnv = { __APIFY_INTERNAL_TEST_AUTH_PATH__: authPath }; + + const loginResult = await runCli('apify', ['login', '--token', token], { env: authEnv }); + if (loginResult.exitCode !== 0) { + throw new Error(`Failed to login:\n${loginResult.stderr}`); + } + + client = new ApifyClient(getApifyClientOptions(token)); + }); + + afterAll(async () => { + if (storeId && client) { + try { + await client.keyValueStore(storeId).delete(); + } catch { + // Do nothing + } + } + }); + + it('creates a named key-value store', async () => { + const result = await runCli('apify', ['kvs', 'create', storeName, '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const store = JSON.parse(result.stdout); + storeId = store.id; + expect(storeId).toBeTruthy(); + }); + + it('creates a named key-value store (non-json output)', async () => { + const tmpName = `e2e-kvs-tmp-${randomBytes(6).toString('hex')}`; + const result = await runCli('apify', ['kvs', 'create', tmpName], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('was created'); + + // Clean up the temp store via API + const listResult = await runCli('apify', ['kvs', 'ls', '--json'], { env: authEnv }); + const list = JSON.parse(listResult.stdout); + const tmpStore = list.items.find((s: { name: string }) => s.name === tmpName); + if (tmpStore) { + await client.keyValueStore(tmpStore.id).delete(); + } + }); + + it('sets a JSON value', async () => { + const result = await runCli('apify', ['kvs', 'set-value', storeId, 'test-key', '{"hello":"world"}'], { + env: authEnv, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('set in the key-value store'); + }); + + it('sets a plain text value', async () => { + const result = await runCli( + 'apify', + ['kvs', 'set-value', storeId, 'text-key', 'hello plain text', '--content-type', 'text/plain'], + { env: authEnv }, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('set in the key-value store'); + }); + + it('gets the JSON value back', async () => { + const result = await runCli('apify', ['kvs', 'get-value', storeId, 'test-key'], { env: authEnv }); + expect(result.exitCode).toBe(0); + // The value should be pretty-printed JSON on stdout + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ hello: 'world' }); + // Content-type is printed to stderr + expect(result.stderr).toContain('application/json'); + }); + + it('gets the plain text value back', async () => { + const result = await runCli('apify', ['kvs', 'get-value', storeId, 'text-key'], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('hello plain text'); + expect(result.stderr).toContain('text/plain'); + }); + + it('lists keys (--json)', async () => { + const result = await runCli('apify', ['kvs', 'keys', storeId, '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const keys = JSON.parse(result.stdout); + expect(keys).toHaveProperty('items'); + const keyNames = keys.items.map((k: { key: string }) => k.key); + expect(keyNames).toContain('test-key'); + expect(keyNames).toContain('text-key'); + }); + + it('shows store info (--json)', async () => { + const result = await runCli('apify', ['kvs', 'info', storeId, '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const info = JSON.parse(result.stdout); + expect(info.id).toBe(storeId); + }); + + it('shows store info (non-json)', async () => { + const result = await runCli('apify', ['kvs', 'info', storeId], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(storeId); + }); + + it('lists all stores (--json)', async () => { + const result = await runCli('apify', ['kvs', 'ls', '--json'], { env: authEnv }); + expect(result.exitCode).toBe(0); + const list = JSON.parse(result.stdout); + expect(list).toHaveProperty('items'); + const found = list.items.some((s: { id: string }) => s.id === storeId); + expect(found).toBe(true); + }); + + it('renames the store', async () => { + const result = await runCli('apify', ['kvs', 'rename', storeId, renamedStoreName], { env: authEnv }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('was changed from'); + }); + + it('deletes a value', async () => { + const result = await runCli('apify', ['kvs', 'delete-value', storeId, 'test-key', '--yes'], { env: authEnv }); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + expect(result.stdout).toContain('deleted from the key-value store'); + }); + + it('deletes the store', async () => { + const result = await runCli('apify', ['kvs', 'rm', storeId, '--yes'], { env: authEnv }); + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + expect(result.stdout).toContain('has been deleted'); + // Clear storeId so afterAll doesn't try to delete again + storeId = ''; + }); +}); From 48d11b02215b0b8b86f297024cb0a01c7d270fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Fri, 17 Apr 2026 10:59:10 +0200 Subject: [PATCH 2/2] Fix tests --- test/e2e/commands/datasets/lifecycle.test.ts | 5 +++-- test/e2e/commands/key-value-stores/lifecycle.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/e2e/commands/datasets/lifecycle.test.ts b/test/e2e/commands/datasets/lifecycle.test.ts index c4319e711..838a67bc0 100644 --- a/test/e2e/commands/datasets/lifecycle.test.ts +++ b/test/e2e/commands/datasets/lifecycle.test.ts @@ -113,12 +113,13 @@ describe('[e2e][api] datasets namespace', () => { }); it('lists datasets (--json)', async () => { - const result = await runCli('apify', ['datasets', 'ls', '--json'], { env: authEnv }); + const result = await runCli('apify', ['datasets', 'ls', '--json', '--desc'], { env: authEnv }); expect(result.exitCode).toBe(0); const list = JSON.parse(result.stdout); expect(list).toHaveProperty('items'); const found = list.items.some((d: { id: string }) => d.id === datasetId); - expect(found).toBe(true); + const returnedIds = list.items.map((d: { id: string }) => d.id); + expect(found, `Dataset ${datasetId} not found in listed IDs: ${returnedIds.join(', ')}`).toBe(true); }); it('renames the dataset', async () => { diff --git a/test/e2e/commands/key-value-stores/lifecycle.test.ts b/test/e2e/commands/key-value-stores/lifecycle.test.ts index 2f461d247..191e76062 100644 --- a/test/e2e/commands/key-value-stores/lifecycle.test.ts +++ b/test/e2e/commands/key-value-stores/lifecycle.test.ts @@ -119,12 +119,13 @@ describe('[e2e][api] key-value-stores namespace', () => { }); it('lists all stores (--json)', async () => { - const result = await runCli('apify', ['kvs', 'ls', '--json'], { env: authEnv }); + const result = await runCli('apify', ['kvs', 'ls', '--json', '--desc'], { env: authEnv }); expect(result.exitCode).toBe(0); const list = JSON.parse(result.stdout); expect(list).toHaveProperty('items'); const found = list.items.some((s: { id: string }) => s.id === storeId); - expect(found).toBe(true); + const returnedIds = list.items.map((s: { id: string }) => s.id); + expect(found, `Store ${storeId} not found in listed IDs: ${returnedIds.join(', ')}`).toBe(true); }); it('renames the store', async () => {