diff --git a/src/commands/actors/rm.ts b/src/commands/actors/rm.ts index 265aca5ca..438852195 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'; @@ -32,8 +33,13 @@ export class ActorsRmCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { actorId } = this.args; + const { yes } = this.flags; const apifyClient = await getLoggedClientOrThrow(); @@ -46,6 +52,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 774d37509..42e63466a 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'; @@ -36,11 +36,7 @@ export class BuildsRemoveTagCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { buildId } = this.args; + const { yes } = this.flags; const apifyClient = await getLoggedClientOrThrow(); @@ -60,11 +66,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 a829ebbe0..e58b3d615 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'; @@ -34,8 +35,13 @@ export class DatasetsRmCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { datasetNameOrId } = this.args; + const { yes } = this.flags; const client = await getLoggedClientOrThrow(); @@ -51,6 +57,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 03248e1f6..35271c87a 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'; @@ -55,12 +55,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 a0ad09a16..7dea81455 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'; @@ -38,8 +39,13 @@ export class KeyValueStoresDeleteValueCommand extends ApifyCommand { }), }; + static override flags = { + ...YesFlag, + }; + async run() { const { runId } = this.args; + const { yes } = this.flags; const apifyClient = await getLoggedClientOrThrow(); @@ -63,6 +69,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..838a67bc0 --- /dev/null +++ b/test/e2e/commands/datasets/lifecycle.test.ts @@ -0,0 +1,139 @@ +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', '--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); + 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 () => { + 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..191e76062 --- /dev/null +++ b/test/e2e/commands/key-value-stores/lifecycle.test.ts @@ -0,0 +1,150 @@ +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', '--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); + 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 () => { + 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 = ''; + }); +});