diff --git a/packages/cli/e2e/__tests__/help.spec.ts b/packages/cli/e2e/__tests__/help.spec.ts index 124c5398a..db8a79c53 100644 --- a/packages/cli/e2e/__tests__/help.spec.ts +++ b/packages/cli/e2e/__tests__/help.spec.ts @@ -56,6 +56,7 @@ describe('help', () => { account View and manage your Checkly account. alert-channels List and inspect alert channels in your Checkly account. api Make an authenticated HTTP request to the Checkly API. + assets List and download result assets. checks List and inspect checks in your Checkly account. destroy Destroy your project with all its related resources. env Manage Checkly environment variables. diff --git a/packages/cli/package.json b/packages/cli/package.json index f8ddbd477..0983fb82b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -74,6 +74,9 @@ "alert-channels": { "description": "List and inspect alert channels in your Checkly account." }, + "assets": { + "description": "List and download result assets." + }, "checks": { "description": "List and inspect checks in your Checkly account." }, diff --git a/packages/cli/src/commands/__tests__/assets.spec.ts b/packages/cli/src/commands/__tests__/assets.spec.ts new file mode 100644 index 000000000..97736f2b0 --- /dev/null +++ b/packages/cli/src/commands/__tests__/assets.spec.ts @@ -0,0 +1,662 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Readable } from 'node:stream' +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { AssetManifest } from '../../rest/asset-manifests.js' + +vi.mock('../../rest/api.js', () => ({ + assetManifests: { + getForCheckResult: vi.fn(), + getForTestSessionResult: vi.fn(), + }, + api: { + get: vi.fn(), + }, +})) + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + }, +})) + +import axios from 'axios' +import * as api from '../../rest/api.js' +import AssetsList from '../assets/list.js' +import AssetsDownload from '../assets/download.js' + +const manifest: AssetManifest = { + assets: [ + { + type: 'log', + name: 'logs.txt', + url: '/download/logs', + contentType: 'text/plain', + source: 'runner', + }, + { + type: 'trace', + name: 'trace.zip', + url: '/download/trace', + contentType: 'application/zip', + source: 'runner', + archive: { entryName: 'traces/checkout trace.zip' }, + }, + { + type: 'screenshot', + name: 'page.png', + url: '/download/page', + contentType: 'image/png', + source: 'runner', + }, + { + type: 'file', + name: 'duplicate.txt', + url: '/download/duplicate-a', + source: 'runner', + archive: { entryName: 'a/duplicate.txt' }, + }, + { + type: 'file', + name: 'duplicate.txt', + url: '/download/duplicate-b', + source: 'runner', + archive: { entryName: 'b/duplicate.txt' }, + }, + ], +} + +const truncatedManifest: AssetManifest = { + ...manifest, + truncated: true, + entriesReturned: 5, + entriesTotal: 12, +} + +function createCommandContext (parsed: unknown) { + const logged: string[] = [] + return { + parse: vi.fn().mockResolvedValue(parsed), + log: vi.fn((msg?: string) => { + if (msg) logged.push(msg) + }), + style: { + outputFormat: undefined, + actionStart: vi.fn(), + actionStatus: vi.fn(), + actionSuccess: vi.fn(), + actionFailure: vi.fn(), + longError: vi.fn(), + }, + fancy: false, + logged, + } +} + +describe('assets commands', () => { + let originalCwd: string + let tempDir: string + + beforeEach(async () => { + vi.clearAllMocks() + process.exitCode = undefined + originalCwd = process.cwd() + tempDir = await realpath(await mkdtemp(path.join(tmpdir(), 'checkly-assets-test-'))) + process.chdir(tempDir) + vi.mocked(api.assetManifests.getForCheckResult).mockResolvedValue(manifest) + vi.mocked(api.assetManifests.getForTestSessionResult).mockResolvedValue(manifest) + vi.mocked(api.api.get).mockResolvedValue({ data: Readable.from(['downloaded']) } as any) + vi.mocked(axios.get).mockResolvedValue({ data: Readable.from(['downloaded']) } as any) + }) + + afterEach(async () => { + process.chdir(originalCwd) + await rm(tempDir, { recursive: true, force: true }) + }) + + it('validates source flags', async () => { + const missing = createCommandContext({ + flags: { 'result-id': 'result-id', 'output': 'table' }, + }) + await expect(AssetsList.prototype.run.call(missing as any)) + .rejects + .toThrow('Use exactly one of --check-id or --test-session-id.') + + const both = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'test-session-id': 'session-id', + 'result-id': 'result-id', + 'output': 'table', + }, + }) + await expect(AssetsList.prototype.run.call(both as any)) + .rejects + .toThrow('Use exactly one of --check-id or --test-session-id, not both.') + }) + + it('lists check result assets as a table with copyable Asset values and truncation warning', async () => { + vi.mocked(api.assetManifests.getForCheckResult).mockResolvedValue(truncatedManifest) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'type': 'all', + 'view': 'table', + 'output': 'table', + }, + }) + + await AssetsList.prototype.run.call(ctx as any) + + expect(api.assetManifests.getForCheckResult).toHaveBeenCalledWith('check-id', 'result-id') + expect(ctx.logged[0]).toContain('Assets for check result') + expect(ctx.logged[0]).toContain('Check ID:') + expect(ctx.logged[0]).toContain('check-id') + expect(ctx.logged[0]).toContain('Result ID:') + expect(ctx.logged[0]).toContain('result-id') + expect(ctx.logged[0]).toContain('Showing:') + expect(ctx.logged[0]).toContain('trace 1') + expect(ctx.logged[0]).toContain('logs.txt') + expect(ctx.logged[0]).toContain('traces/checkout trace.zip') + expect(ctx.logged[0]).toContain('Warning: asset manifest is truncated (5 of 12 entries returned).') + }) + + it('lists test-session result assets as filtered JSON without human warnings', async () => { + vi.mocked(api.assetManifests.getForTestSessionResult).mockResolvedValue(truncatedManifest) + const ctx = createCommandContext({ + flags: { + 'test-session-id': 'session-id', + 'result-id': 'result-id', + 'type': 'trace', + 'view': 'tree', + 'output': 'json', + }, + }) + + await AssetsList.prototype.run.call(ctx as any) + + expect(api.assetManifests.getForTestSessionResult).toHaveBeenCalledWith('session-id', 'result-id') + expect(JSON.parse(ctx.logged[0])).toEqual({ + data: [manifest.assets[1]], + source: { + kind: 'test-session-result', + testSessionId: 'session-id', + resultId: 'result-id', + }, + metadata: { + filter: { + type: 'trace', + }, + manifest: { + truncated: true, + entriesReturned: 5, + entriesTotal: 12, + }, + }, + }) + expect(ctx.logged[0]).not.toContain('Assets for test-session result') + expect(ctx.logged[0]).not.toContain('checkout trace.zip (trace') + }) + + it('renders list markdown output', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'type': 'log', + 'view': 'table', + 'output': 'md', + }, + }) + + await AssetsList.prototype.run.call(ctx as any) + + expect(ctx.logged[0]).toContain('| Type | Name | Asset | Content Type |') + expect(ctx.logged[0]).toContain('| log | logs.txt | logs.txt | text/plain |') + }) + + it('renders list tree output for nested asset paths', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'type': 'all', + 'view': 'tree', + 'output': 'table', + }, + }) + + await AssetsList.prototype.run.call(ctx as any) + + expect(ctx.logged[0]).toContain('traces/') + expect(ctx.logged[0]).toContain('checkout trace.zip') + expect(ctx.logged[0]).toContain('Tip: use --view table to copy exact Asset values for download.') + }) + + it('filters list output by exact asset name without treating duplicates as ambiguous', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'type': 'all', + 'asset': 'duplicate.txt', + 'view': 'table', + 'output': 'table', + }, + }) + + await AssetsList.prototype.run.call(ctx as any) + + expect(ctx.logged[0]).toContain('Filter:') + expect(ctx.logged[0]).toContain('asset=duplicate.txt') + expect(ctx.logged[0]).toContain('a/duplicate.txt') + expect(ctx.logged[0]).toContain('b/duplicate.txt') + expect(ctx.logged[0]).not.toContain('logs.txt') + }) + + it('filters list JSON output by asset glob', async () => { + const ctx = createCommandContext({ + flags: { + 'test-session-id': 'session-id', + 'result-id': 'result-id', + 'type': 'trace', + 'asset': 'traces/*.zip', + 'view': 'tree', + 'output': 'json', + }, + }) + + await AssetsList.prototype.run.call(ctx as any) + + const parsed = JSON.parse(ctx.logged[0]) + expect(parsed.data).toEqual([manifest.assets[1]]) + expect(parsed.metadata.filter).toEqual({ + type: 'trace', + asset: 'traces/*.zip', + }) + }) + + it('requires a download selector', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'output': 'table', + }, + }) + + await expect(AssetsDownload.prototype.run.call(ctx as any)) + .rejects + .toThrow('Pass --type or --asset to select assets. Use --type all to download all assets.') + }) + + it('downloads by exact asset selector to the default directory', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'json', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + const summary = JSON.parse(ctx.logged[0]) + const expectedPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'logs.txt') + expect(api.api.get).toHaveBeenCalledWith('/download/logs', { responseType: 'stream' }) + expect(axios.get).not.toHaveBeenCalled() + expect(summary.directory).toBe(path.dirname(expectedPath)) + expect(summary.files[0].path).toBe(expectedPath) + expect(summary.files[0].status).toBe('written') + await expect(readFile(expectedPath, 'utf8')).resolves.toBe('downloaded') + }) + + it('downloads absolute external asset URLs without the authenticated API client', async () => { + vi.mocked(api.assetManifests.getForCheckResult).mockResolvedValue({ + assets: [{ + type: 'log', + name: 'external-log.txt', + url: 'https://assets.example.com/external-log.txt', + contentType: 'text/plain', + source: 'runner', + }], + }) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'external-log.txt', + 'output': 'json', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(api.api.get).not.toHaveBeenCalled() + expect(axios.get).toHaveBeenCalledWith( + 'https://assets.example.com/external-log.txt', + expect.objectContaining({ responseType: 'stream' }), + ) + expect(vi.mocked(axios.get).mock.calls[0][1]?.headers).toBeUndefined() + expect(JSON.parse(ctx.logged[0]).files[0].status).toBe('written') + }) + + it('renders single download human output as a full-path detail', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'page.png', + 'output': 'table', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + const expectedPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'page.png') + expect(ctx.logged[0]).toContain('Downloaded screenshot asset') + expect(ctx.logged[0]).toContain(`Path: ${expectedPath}`) + expect(ctx.logged[0]).not.toContain('ASSET') + }) + + it('renders multiple download human output with full paths and no repeated asset column', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'type': 'file', + 'output': 'table', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + const firstPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'a', 'duplicate.txt') + const secondPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'b', 'duplicate.txt') + expect(ctx.logged[0]).toContain('2 assets processed (2 downloaded)') + expect(ctx.logged[0]).toContain(firstPath) + expect(ctx.logged[0]).toContain(secondPath) + expect(ctx.logged[0]).toContain('TYPE') + expect(ctx.logged[0]).toContain('PATH') + expect(ctx.logged[0]).not.toContain('ASSET') + expect(ctx.logged[0]).not.toContain('STATUS') + }) + + it('shows live progress in interactive human output', async () => { + const originalIsTTY = process.stdout.isTTY + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }) + vi.mocked(api.api.get).mockResolvedValue({ + data: Readable.from(['downloaded']), + headers: { 'content-length': '10' }, + } as any) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'page.png', + 'output': 'table', + 'force': false, + 'skip-existing': false, + }, + }) + ctx.fancy = true + + try { + await AssetsDownload.prototype.run.call(ctx as any) + } finally { + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: originalIsTTY }) + } + + expect(ctx.style.actionStart).toHaveBeenCalledWith('Fetching asset manifest') + expect(ctx.style.actionStatus).toHaveBeenCalledWith(expect.stringContaining('Downloading 1/1 screenshot page.png')) + expect(ctx.style.actionStatus).toHaveBeenCalledWith(expect.stringContaining('10 B / 10 B')) + expect(ctx.style.actionSuccess).toHaveBeenCalled() + }) + + it('downloads by glob selector and preserves safe archive entry paths', async () => { + const ctx = createCommandContext({ + flags: { + 'test-session-id': 'session-id', + 'result-id': 'result-id', + 'asset': 'traces/*.zip', + 'dir': 'custom-assets', + 'output': 'json', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + const summary = JSON.parse(ctx.logged[0]) + expect(summary.files).toHaveLength(1) + expect(summary.files[0].path).toBe(path.join(tempDir, 'custom-assets', 'traces', 'checkout trace.zip')) + }) + + it('refuses category downloads from truncated manifests', async () => { + vi.mocked(api.assetManifests.getForCheckResult).mockResolvedValue(truncatedManifest) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'type': 'file', + 'output': 'table', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(ctx.style.longError).toHaveBeenCalledWith( + 'Failed to download assets.', + expect.objectContaining({ + message: expect.stringContaining('Asset manifest is truncated (5 of 12 entries returned). Refusing to download'), + }), + ) + expect(api.api.get).not.toHaveBeenCalled() + expect(axios.get).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + + it('allows exact copied Asset downloads from truncated manifests', async () => { + vi.mocked(api.assetManifests.getForCheckResult).mockResolvedValue(truncatedManifest) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'traces/checkout trace.zip', + 'output': 'json', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(api.api.get).toHaveBeenCalledWith('/download/trace', { responseType: 'stream' }) + expect(JSON.parse(ctx.logged[0]).files[0].status).toBe('written') + }) + + it('allows exact plain Asset downloads from truncated manifests', async () => { + vi.mocked(api.assetManifests.getForCheckResult).mockResolvedValue(truncatedManifest) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'json', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(api.api.get).toHaveBeenCalledWith('/download/logs', { responseType: 'stream' }) + expect(JSON.parse(ctx.logged[0]).files[0].status).toBe('written') + }) + + it('fails ambiguous exact asset selection with matching Asset values', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'duplicate.txt', + 'output': 'table', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(ctx.style.longError).toHaveBeenCalledWith( + 'Failed to download assets.', + expect.objectContaining({ + message: expect.stringContaining('a/duplicate.txt'), + }), + ) + expect(ctx.style.longError).toHaveBeenCalledWith( + 'Failed to download assets.', + expect.objectContaining({ + message: expect.stringContaining('b/duplicate.txt'), + }), + ) + expect(process.exitCode).toBe(1) + expect(api.api.get).not.toHaveBeenCalled() + }) + + it('fails existing files by default', async () => { + const existingPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'logs.txt') + await mkdir(path.dirname(existingPath), { recursive: true }) + await writeFile(existingPath, 'existing') + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'table', + 'force': false, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(ctx.style.longError).toHaveBeenCalledWith( + 'Failed to download assets.', + expect.objectContaining({ + message: `Refusing to overwrite existing file. Use --force to overwrite or --skip-existing to keep it.\n${existingPath}`, + }), + ) + expect(process.exitCode).toBe(1) + await expect(readFile(existingPath, 'utf8')).resolves.toBe('existing') + }) + + it('overwrites existing files with --force', async () => { + const existingPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'logs.txt') + await mkdir(path.dirname(existingPath), { recursive: true }) + await writeFile(existingPath, 'existing') + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'json', + 'force': true, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(JSON.parse(ctx.logged[0]).files[0].status).toBe('written') + await expect(readFile(existingPath, 'utf8')).resolves.toBe('downloaded') + }) + + it('preserves existing files when a forced download fails', async () => { + const existingPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'logs.txt') + await mkdir(path.dirname(existingPath), { recursive: true }) + await writeFile(existingPath, 'existing') + async function* failingDownload () { + yield 'partial' + throw new Error('stream failed') + } + vi.mocked(api.api.get).mockResolvedValue({ data: Readable.from(failingDownload()) } as any) + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'table', + 'force': true, + 'skip-existing': false, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(ctx.style.longError).toHaveBeenCalledWith( + 'Failed to download assets.', + expect.objectContaining({ message: 'stream failed' }), + ) + expect(process.exitCode).toBe(1) + await expect(readFile(existingPath, 'utf8')).resolves.toBe('existing') + await expect(readdir(path.dirname(existingPath))).resolves.toEqual(['logs.txt']) + }) + + it('skips existing files with --skip-existing', async () => { + const existingPath = path.join(tempDir, 'checkly-assets', 'check-result-result-id', 'logs.txt') + await mkdir(path.dirname(existingPath), { recursive: true }) + await writeFile(existingPath, 'existing') + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'json', + 'force': false, + 'skip-existing': true, + }, + }) + + await AssetsDownload.prototype.run.call(ctx as any) + + expect(JSON.parse(ctx.logged[0]).files[0].status).toBe('skipped') + expect(api.api.get).not.toHaveBeenCalled() + await expect(readFile(existingPath, 'utf8')).resolves.toBe('existing') + }) + + it('rejects mutually exclusive overwrite flags', async () => { + const ctx = createCommandContext({ + flags: { + 'check-id': 'check-id', + 'result-id': 'result-id', + 'asset': 'logs.txt', + 'output': 'table', + 'force': true, + 'skip-existing': true, + }, + }) + + await expect(AssetsDownload.prototype.run.call(ctx as any)) + .rejects + .toThrow('--force and --skip-existing are mutually exclusive.') + }) + + it('does not expose archive extraction before it is implemented', () => { + expect(AssetsDownload.flags).not.toHaveProperty('extract') + }) +}) diff --git a/packages/cli/src/commands/__tests__/command-metadata.spec.ts b/packages/cli/src/commands/__tests__/command-metadata.spec.ts index c481b44c1..76830669b 100644 --- a/packages/cli/src/commands/__tests__/command-metadata.spec.ts +++ b/packages/cli/src/commands/__tests__/command-metadata.spec.ts @@ -10,6 +10,8 @@ import StatusPagesGet from '../status-pages/get.js' import AlertChannelsList from '../alert-channels/list.js' import AlertChannelsGet from '../alert-channels/get.js' import AlertChannelsLogs from '../alert-channels/logs.js' +import AssetsList from '../assets/list.js' +import AssetsDownload from '../assets/download.js' import IncidentsList from '../incidents/list.js' import IncidentsCreate from '../incidents/create.js' import IncidentsUpdate from '../incidents/update.js' @@ -54,6 +56,8 @@ const commands: Array<[string, typeof BaseCommand]> = [ ['alert-channels list', AlertChannelsList], ['alert-channels get', AlertChannelsGet], ['alert-channels logs', AlertChannelsLogs], + ['assets list', AssetsList], + ['assets download', AssetsDownload], ['checks list', ChecksList], ['checks get', ChecksGet], ['checks stats', ChecksStats], diff --git a/packages/cli/src/commands/assets/download.ts b/packages/cli/src/commands/assets/download.ts new file mode 100644 index 000000000..5aca2a89a --- /dev/null +++ b/packages/cli/src/commands/assets/download.ts @@ -0,0 +1,168 @@ +import { Flags } from '@oclif/core' +import path from 'node:path' +import { AuthCommand } from '../authCommand.js' +import { outputFlag } from '../../helpers/flags.js' +import { assetSelectorValue, formatDownloadedAssets, type DownloadedAssetRow } from '../../formatters/assets.js' +import { + assertManifestSupportsDownload, + assetTypes, + defaultDownloadDirectory, + destinationPathForAsset, + downloadAssetToFile, + fetchAssetManifest, + resolveAssetSource, + selectAssets, +} from '../../helpers/result-assets.js' + +export default class AssetsDownload extends AuthCommand { + static hidden = false + static readOnly = true + static idempotent = true + static description = 'Download result assets.' + + static flags = { + 'check-id': Flags.string({ + description: 'Check ID for a scheduled check result.', + }), + 'test-session-id': Flags.string({ + description: 'Test session ID for a test-session result.', + }), + 'result-id': Flags.string({ + description: 'Check result ID or test-session result ID.', + required: true, + }), + 'type': Flags.string({ + description: 'Select assets by type.', + options: assetTypes, + }), + 'asset': Flags.string({ + description: 'Select an asset by exact Asset/Name value or glob.', + }), + 'dir': Flags.string({ + description: 'Directory to write assets into.', + }), + 'force': Flags.boolean({ + description: 'Overwrite existing files.', + default: false, + }), + 'skip-existing': Flags.boolean({ + description: 'Skip files that already exist.', + default: false, + }), + 'output': outputFlag({ default: 'table', options: ['table', 'json'] }), + } + + async run (): Promise { + const { flags } = await this.parse(AssetsDownload) + this.style.outputFormat = flags.output + const source = resolveAssetSource(flags) + const showProgress = flags.output !== 'json' && this.fancy && process.stdout.isTTY + let progressStarted = false + + const startProgress = (message: string) => { + if (!showProgress) return + this.style.actionStart(message) + progressStarted = true + } + + const setProgress = (message: string) => { + if (!showProgress || !progressStarted) return + this.style.actionStatus(message) + } + + const stopProgress = (success: boolean) => { + if (!showProgress || !progressStarted) return + if (success) { + this.style.actionSuccess() + } else { + this.style.actionFailure() + } + progressStarted = false + } + + if (flags.force && flags['skip-existing']) { + throw new Error('--force and --skip-existing are mutually exclusive.') + } + + if (!flags.type && !flags.asset) { + throw new Error('Pass --type or --asset to select assets. Use --type all to download all assets.') + } + + try { + startProgress('Fetching asset manifest') + const manifest = await fetchAssetManifest(source) + assertManifestSupportsDownload(manifest, { asset: flags.asset }) + const assets = selectAssets(manifest.assets, { + type: flags.type as any, + asset: flags.asset, + }) + + if (assets.length === 0) { + stopProgress(true) + if (flags.output === 'json') { + this.log(JSON.stringify({ source, directory: null, files: [] }, null, 2)) + return + } + this.log('No matching assets found.') + return + } + + const directory = path.resolve(flags.dir ?? defaultDownloadDirectory(source)) + const rows: DownloadedAssetRow[] = [] + + for (const [index, asset] of assets.entries()) { + const filePath = destinationPathForAsset(directory, asset) + const label = `${index + 1}/${assets.length} ${asset.type} ${assetSelectorValue(asset)}` + let lastProgressUpdate = 0 + setProgress(`Downloading ${label}`) + const status = await downloadAssetToFile(asset, filePath, { + force: flags.force, + skipExisting: flags['skip-existing'], + onProgress: showProgress + ? ({ downloadedBytes, totalBytes }) => { + const now = Date.now() + if (now - lastProgressUpdate < 250) return + lastProgressUpdate = now + setProgress(`Downloading ${label} ${formatDownloadBytes(downloadedBytes, totalBytes)}`) + } + : undefined, + }) + setProgress(status === 'skipped' ? `Skipped ${label}` : `Downloaded ${label}`) + rows.push({ status, path: filePath, asset }) + } + stopProgress(true) + + if (flags.output === 'json') { + this.log(JSON.stringify({ source, directory, files: rows }, null, 2)) + return + } + + this.log(formatDownloadedAssets(rows)) + } catch (err: any) { + stopProgress(false) + this.style.longError('Failed to download assets.', err) + process.exitCode = 1 + } + } +} + +function formatDownloadBytes (downloadedBytes: number, totalBytes?: number): string { + if (totalBytes === undefined) return formatBytes(downloadedBytes) + return `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}` +} + +function formatBytes (bytes: number): string { + if (bytes < 1024) return `${bytes} B` + + const units = ['KB', 'MB', 'GB', 'TB'] + let value = bytes / 1024 + let unitIndex = 0 + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + + const rounded = value >= 10 ? value.toFixed(1) : value.toFixed(2) + return `${rounded} ${units[unitIndex]}` +} diff --git a/packages/cli/src/commands/assets/list.ts b/packages/cli/src/commands/assets/list.ts new file mode 100644 index 000000000..e7b8fb9d9 --- /dev/null +++ b/packages/cli/src/commands/assets/list.ts @@ -0,0 +1,119 @@ +import { Flags } from '@oclif/core' +import chalk from 'chalk' +import { AuthCommand } from '../authCommand.js' +import { outputFlag } from '../../helpers/flags.js' +import type { OutputFormat } from '../../formatters/render.js' +import { + formatAssetListHeader, + formatAssetManifestEntries, + formatAssetManifestTree, +} from '../../formatters/assets.js' +import { + assetTypes, + fetchAssetManifest, + filterAssetsBySelector, + filterAssetsByType, + resolveAssetSource, +} from '../../helpers/result-assets.js' + +export default class AssetsList extends AuthCommand { + static hidden = false + static readOnly = true + static idempotent = true + static description = 'List result assets.' + + static flags = { + 'check-id': Flags.string({ + description: 'Check ID for a scheduled check result.', + }), + 'test-session-id': Flags.string({ + description: 'Test session ID for a test-session result.', + }), + 'result-id': Flags.string({ + description: 'Check result ID or test-session result ID.', + required: true, + }), + 'type': Flags.string({ + description: 'Filter assets by type.', + options: assetTypes, + default: 'all', + }), + 'asset': Flags.string({ + description: 'Filter assets by exact Asset/Name value or glob.', + }), + 'view': Flags.string({ + description: 'Human output view. Ignored with --output json.', + options: ['table', 'tree'], + default: 'table', + }), + 'output': outputFlag({ default: 'table' }), + } + + async run (): Promise { + const { flags } = await this.parse(AssetsList) + this.style.outputFormat = flags.output + const source = resolveAssetSource(flags) + + try { + const manifest = await fetchAssetManifest(source) + const assets = filterAssetsBySelector( + filterAssetsByType(manifest.assets, flags.type as any), + flags.asset, + ) + + if (flags.output === 'json') { + this.log(JSON.stringify({ + data: assets, + source, + metadata: { + filter: { + type: flags.type, + asset: flags.asset, + }, + manifest: { + truncated: Boolean(manifest.truncated), + entriesReturned: manifest.entriesReturned, + entriesTotal: manifest.entriesTotal, + }, + }, + }, null, 2)) + return + } + + const fmt: OutputFormat = flags.output === 'md' ? 'md' : 'terminal' + const output: string[] = [] + + output.push(formatAssetListHeader({ + sourceType: source.kind, + checkId: source.kind === 'check-result' ? source.checkId : undefined, + testSessionId: source.kind === 'test-session-result' ? source.testSessionId : undefined, + resultId: source.resultId, + type: flags.type, + asset: flags.asset, + }, assets, fmt)) + output.push('') + + if (assets.length === 0) { + output.push('No assets found.') + } else if (flags.view === 'tree') { + output.push(formatAssetManifestTree(assets, fmt)) + output.push('') + output.push(chalk.dim('Tip: use --view table to copy exact Asset values for download.')) + } else { + output.push(formatAssetManifestEntries(assets, fmt)) + } + + if (manifest.truncated) { + const returned = manifest.entriesReturned ?? assets.length + const total = manifest.entriesTotal == null ? 'unknown' : String(manifest.entriesTotal) + output.push('') + output.push(chalk.yellow(`Warning: asset manifest is truncated (${returned} of ${total} entries returned).`)) + } + + this.log(output.join('\n')) + } catch (err: any) { + this.style.longError('Failed to list assets.', err) + process.exitCode = 1 + } + } +} diff --git a/packages/cli/src/formatters/assets.ts b/packages/cli/src/formatters/assets.ts new file mode 100644 index 000000000..84e92a649 --- /dev/null +++ b/packages/cli/src/formatters/assets.ts @@ -0,0 +1,252 @@ +import chalk from 'chalk' +import type { AssetManifestEntry } from '../rest/asset-manifests.js' +import { + type ColumnDef, + type OutputFormat, + escapeMdCell, + renderAdaptiveTable, +} from './render.js' + +export function assetSelectorValue (asset: AssetManifestEntry): string { + return asset.archive?.entryName ?? asset.name +} + +function buildAssetColumns (format: OutputFormat): ColumnDef[] { + if (format === 'md') { + return [ + { header: 'Type', value: asset => asset.type }, + { header: 'Name', value: asset => asset.name }, + { header: 'Asset', value: asset => assetSelectorValue(asset) }, + { header: 'Content Type', value: asset => asset.contentType ?? '-' }, + ] + } + + return [ + { + header: 'Type', + width: 12, + value: asset => asset.type, + }, + { + header: 'Name', + minWidth: 16, + maxWidth: 34, + value: asset => asset.name, + }, + { + header: 'Asset', + minWidth: 28, + value: asset => assetSelectorValue(asset), + }, + { + header: 'Content Type', + minWidth: 14, + maxWidth: 28, + value: asset => { + const contentType = asset.contentType ?? '-' + return asset.contentType ? contentType : chalk.dim(contentType) + }, + }, + ] +} + +export function formatAssetManifestEntries (assets: AssetManifestEntry[], format: OutputFormat): string { + return renderAdaptiveTable(buildAssetColumns(format), assets, format) +} + +export interface AssetListContext { + sourceType: 'check-result' | 'test-session-result' + checkId?: string + testSessionId?: string + resultId: string + type?: string + asset?: string +} + +function formatTypeCounts (assets: AssetManifestEntry[]): string { + const counts = new Map() + for (const asset of assets) { + counts.set(asset.type, (counts.get(asset.type) ?? 0) + 1) + } + return [...counts.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([type, count]) => `${type} ${count}`) + .join(', ') +} + +export function formatAssetListHeader ( + context: AssetListContext, + assets: AssetManifestEntry[], + format: OutputFormat, +): string { + const lines: string[] = [] + const title = context.sourceType === 'check-result' + ? 'Assets for check result' + : 'Assets for test-session result' + + lines.push(format === 'md' ? `## ${title}` : chalk.bold(title)) + if (context.checkId) { + lines.push(format === 'md' ? `- Check ID: ${context.checkId}` : `${chalk.dim('Check ID:')} ${context.checkId}`) + } + if (context.testSessionId) { + lines.push(format === 'md' + ? `- Test session ID: ${context.testSessionId}` + : `${chalk.dim('Test session ID:')} ${context.testSessionId}`) + } + lines.push(format === 'md' ? `- Result ID: ${context.resultId}` : `${chalk.dim('Result ID:')} ${context.resultId}`) + const filters = [`type=${context.type ?? 'all'}`] + if (context.asset) { + filters.push(`asset=${context.asset}`) + } + lines.push(format === 'md' ? `- Filter: ${filters.join(', ')}` : `${chalk.dim('Filter:')} ${filters.join(', ')}`) + + const typeCounts = formatTypeCounts(assets) + const total = `${assets.length} asset${assets.length !== 1 ? 's' : ''}` + lines.push(format === 'md' + ? `- Showing: ${typeCounts ? `${total} (${typeCounts})` : total}` + : `${chalk.dim('Showing:')} ${typeCounts ? `${total} (${typeCounts})` : total}`) + + return lines.join('\n') +} + +interface AssetTreeNode { + children: Map + assets: AssetManifestEntry[] +} + +function createTreeNode (): AssetTreeNode { + return { children: new Map(), assets: [] } +} + +function assetPathSegments (asset: AssetManifestEntry): string[] { + const selector = assetSelectorValue(asset) + const segments = selector.split(/[\\/]+/).filter(Boolean) + return segments.length > 0 ? segments : [asset.name] +} + +function insertAsset (root: AssetTreeNode, asset: AssetManifestEntry): void { + let node = root + const segments = assetPathSegments(asset) + for (const segment of segments) { + let child = node.children.get(segment) + if (!child) { + child = createTreeNode() + node.children.set(segment, child) + } + node = child + } + node.assets.push(asset) +} + +function leafLabel (segment: string, assets: AssetManifestEntry[], format: OutputFormat): string { + if (assets.length === 0) return `${segment}/` + + const labels = assets + .map(asset => { + const contentType = asset.contentType ? ` ${asset.contentType}` : '' + return `${asset.type}${contentType}` + }) + .join('; ') + + return format === 'md' + ? `${segment} (${labels})` + : `${segment} ${chalk.dim(`(${labels})`)}` +} + +function renderTreeNode (node: AssetTreeNode, format: OutputFormat, depth = 0): string[] { + const lines: string[] = [] + const entries = [...node.children.entries()].sort(([a], [b]) => a.localeCompare(b)) + + for (const [segment, child] of entries) { + const indent = ' '.repeat(depth) + lines.push(`${indent}${leafLabel(segment, child.assets, format)}`) + lines.push(...renderTreeNode(child, format, depth + 1)) + } + + return lines +} + +export function formatAssetManifestTree (assets: AssetManifestEntry[], format: OutputFormat): string { + const root = createTreeNode() + for (const asset of assets) { + insertAsset(root, asset) + } + + const tree = renderTreeNode(root, format) + if (format === 'md') { + return ['```text', ...tree.map(line => escapeMdCell(line)), '```'].join('\n') + } + return tree.join('\n') +} + +export interface DownloadedAssetRow { + status: 'written' | 'skipped' + path: string + asset: AssetManifestEntry +} + +function buildDownloadedAssetColumns (): ColumnDef[] { + return [ + { + header: 'Type', + width: 12, + value: row => row.asset.type, + }, + { + header: 'Path', + minWidth: 24, + truncate: false, + value: row => row.path, + }, + ] +} + +function buildDownloadedAssetStatusColumns (): ColumnDef[] { + return [ + { + header: 'Status', + width: 10, + value: row => row.status === 'written' ? chalk.green('written') : chalk.yellow('skipped'), + }, + { + header: 'Type', + width: 12, + value: row => row.asset.type, + }, + { + header: 'Path', + minWidth: 24, + truncate: false, + value: row => row.path, + }, + ] +} + +export function formatDownloadedAssets (rows: DownloadedAssetRow[]): string { + if (rows.length === 0) return '' + + if (rows.length === 1) { + const [row] = rows + const action = row.status === 'written' ? 'Downloaded' : 'Skipped existing' + return [ + chalk.bold(`${action} ${row.asset.type} asset`), + `${chalk.dim('Path:')} ${row.path}`, + ].join('\n') + } + + const written = rows.filter(row => row.status === 'written').length + const skipped = rows.length - written + const summary = [ + written > 0 ? `${written} downloaded` : undefined, + skipped > 0 ? `${skipped} skipped` : undefined, + ].filter(Boolean).join(', ') + + const columns = skipped > 0 + ? buildDownloadedAssetStatusColumns() + : buildDownloadedAssetColumns() + + return [ + chalk.bold(`${rows.length} assets processed (${summary})`), + renderAdaptiveTable(columns, rows, 'terminal'), + ].join('\n') +} diff --git a/packages/cli/src/helpers/command-style.ts b/packages/cli/src/helpers/command-style.ts index 615e491c6..3e25c6b1c 100644 --- a/packages/cli/src/helpers/command-style.ts +++ b/packages/cli/src/helpers/command-style.ts @@ -29,6 +29,12 @@ export class CommandStyle { } } + actionStatus (message: string) { + if (this.c.fancy) { + ux.action.status = message + } + } + actionSuccess () { if (this.c.fancy) { ux.action.stop(`✅`) diff --git a/packages/cli/src/helpers/result-assets.ts b/packages/cli/src/helpers/result-assets.ts new file mode 100644 index 000000000..c04d60577 --- /dev/null +++ b/packages/cli/src/helpers/result-assets.ts @@ -0,0 +1,292 @@ +import path from 'node:path' +import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios' +import { randomUUID } from 'node:crypto' +import { constants, createWriteStream } from 'node:fs' +import { access, mkdir, rename, rm } from 'node:fs/promises' +import { Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { minimatch } from 'minimatch' +import * as api from '../rest/api.js' +import { assignProxy } from '../services/proxy.js' +import type { + AssetManifest, + AssetManifestEntry, + AssetManifestEntryType, +} from '../rest/asset-manifests.js' +import { assetSelectorValue } from '../formatters/assets.js' + +export const assetTypes: Array = [ + 'log', + 'trace', + 'video', + 'screenshot', + 'pcap', + 'report', + 'file', + 'all', +] + +export interface AssetSourceFlags { + 'check-id'?: string + 'test-session-id'?: string + 'result-id'?: string +} + +export type AssetSource = + | { kind: 'check-result', checkId: string, resultId: string } + | { kind: 'test-session-result', testSessionId: string, resultId: string } + +export function resolveAssetSource (flags: AssetSourceFlags): AssetSource { + if (!flags['result-id']) { + throw new Error('--result-id is required.') + } + + const hasCheckId = Boolean(flags['check-id']) + const hasTestSessionId = Boolean(flags['test-session-id']) + + if (hasCheckId && hasTestSessionId) { + throw new Error('Use exactly one of --check-id or --test-session-id, not both.') + } + + if (!hasCheckId && !hasTestSessionId) { + throw new Error('Use exactly one of --check-id or --test-session-id.') + } + + if (flags['check-id']) { + return { kind: 'check-result', checkId: flags['check-id'], resultId: flags['result-id'] } + } + + return { kind: 'test-session-result', testSessionId: flags['test-session-id']!, resultId: flags['result-id'] } +} + +export function fetchAssetManifest (source: AssetSource): Promise { + if (source.kind === 'check-result') { + return api.assetManifests.getForCheckResult(source.checkId, source.resultId) + } + + return api.assetManifests.getForTestSessionResult(source.testSessionId, source.resultId) +} + +export function filterAssetsByType ( + assets: AssetManifestEntry[], + type?: AssetManifestEntryType | 'all', +): AssetManifestEntry[] { + if (!type || type === 'all') return assets + return assets.filter(asset => asset.type === type) +} + +export function hasGlobCharacters (selector: string): boolean { + return /[*?[\]{}]/.test(selector) +} + +export function filterAssetsBySelector ( + assets: AssetManifestEntry[], + selector?: string, +): AssetManifestEntry[] { + if (!selector) return assets + + if (hasGlobCharacters(selector)) { + return assets.filter(asset => + minimatch(asset.archive?.entryName ?? '', selector, { dot: true }) + || minimatch(asset.name, selector, { dot: true }), + ) + } + + return assets.filter(asset => + asset.archive?.entryName === selector + || asset.name === selector, + ) +} + +export function selectAssets ( + assets: AssetManifestEntry[], + options: { type?: AssetManifestEntryType | 'all', asset?: string }, +): AssetManifestEntry[] { + const byType = filterAssetsByType(assets, options.type) + const selector = options.asset + if (!selector) return byType + + if (hasGlobCharacters(selector)) { + return filterAssetsBySelector(byType, selector) + } + + const exactMatches = filterAssetsBySelector(byType, selector) + + if (exactMatches.length > 1) { + const values = [...new Set(exactMatches.map(assetSelectorValue))] + throw new Error( + `--asset "${selector}" matches multiple assets:\n` + + values.map(value => ` ${value}`).join('\n') + + '\nCopy one Asset value from `checkly assets list` and pass it to --asset.', + ) + } + + return exactMatches +} + +export function defaultDownloadDirectory (source: AssetSource): string { + const prefix = source.kind === 'check-result' ? 'check-result' : 'test-session-result' + return path.join('.', 'checkly-assets', `${prefix}-${source.resultId}`) +} + +function sanitizePathSegment (segment: string): string { + return segment + // eslint-disable-next-line no-control-regex + .replace(/[<>:"|?*\u0000-\u001F]/g, '_') + .replace(/^\.+$/, '_') + .trim() +} + +export function destinationPathForAsset (directory: string, asset: AssetManifestEntry): string { + const selector = assetSelectorValue(asset) + const rawSegments = selector.split(/[\\/]+/) + const safeSegments = rawSegments + .map(sanitizePathSegment) + .filter(segment => segment && segment !== '.' && segment !== '..') + + const relativePath = safeSegments.length > 0 ? path.join(...safeSegments) : 'asset' + return path.join(directory, relativePath) +} + +async function pathExists (filePath: string): Promise { + try { + await access(filePath, constants.F_OK) + return true + } catch { + return false + } +} + +export function formatTruncatedManifestMessage (manifest: AssetManifest): string { + const returned = manifest.entriesReturned ?? manifest.assets.length + const total = manifest.entriesTotal == null ? 'unknown' : String(manifest.entriesTotal) + return `Asset manifest is truncated (${returned} of ${total} entries returned).` +} + +export function assertManifestSupportsDownload ( + manifest: AssetManifest, + options: { asset?: string }, +): void { + if (!manifest.truncated) return + + const selector = options.asset + const isExactCopiedAsset = selector && !hasGlobCharacters(selector) + && manifest.assets.some(asset => assetSelectorValue(asset) === selector) + + if (isExactCopiedAsset) return + + throw new Error( + `${formatTruncatedManifestMessage(manifest)} Refusing to download from an incomplete manifest because the selector may miss assets.\n` + + 'Use `checkly assets list --view table` and pass an exact Asset value from the list.', + ) +} + +export interface AssetDownloadProgress { + downloadedBytes: number + totalBytes?: number +} + +export interface AssetDownloadOptions { + force?: boolean + skipExisting?: boolean + onProgress?: (progress: AssetDownloadProgress) => void +} + +function headerValue (headers: unknown, name: string): unknown { + if (!headers || typeof headers !== 'object') return + + if ('get' in headers && typeof headers.get === 'function') { + return headers.get(name) + } + + const record = headers as Record + return record[name] ?? record[name.toLowerCase()] +} + +function parseContentLength (headers: unknown): number | undefined { + const value = headerValue(headers, 'content-length') + if (Array.isArray(value)) return parseContentLength({ 'content-length': value[0] }) + if (typeof value !== 'string' && typeof value !== 'number') return + + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined +} + +function progressTransform ( + totalBytes: number | undefined, + onProgress: AssetDownloadOptions['onProgress'], +): Transform | undefined { + if (!onProgress) return + + let downloadedBytes = 0 + return new Transform({ + transform (chunk, _encoding, callback) { + downloadedBytes += Buffer.isBuffer(chunk) + ? chunk.length + : Buffer.byteLength(chunk) + onProgress({ downloadedBytes, totalBytes }) + callback(null, chunk) + }, + }) +} + +function shouldUseAuthenticatedApiClient (url: string): boolean { + try { + const apiBaseUrl = api.api.defaults.baseURL + if (!apiBaseUrl) return !URL.canParse(url) + const resolvedUrl = new URL(url, apiBaseUrl) + const resolvedApiUrl = new URL(apiBaseUrl) + return resolvedUrl.origin === resolvedApiUrl.origin + } catch { + return !URL.canParse(url) + } +} + +function fetchAssetStream (assetUrl: string): Promise> { + if (shouldUseAuthenticatedApiClient(assetUrl)) { + return api.api.get(assetUrl, { responseType: 'stream' }) + } + + const config = assignProxy(assetUrl, { responseType: 'stream' }) as AxiosRequestConfig + return axios.get( + assetUrl, + config, + ) +} + +function temporaryDownloadPath (filePath: string): string { + return path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`) +} + +export async function downloadAssetToFile ( + asset: AssetManifestEntry, + filePath: string, + options: AssetDownloadOptions, +): Promise<'written' | 'skipped'> { + const exists = await pathExists(filePath) + if (exists) { + if (options.skipExisting) return 'skipped' + if (!options.force) { + throw new Error(`Refusing to overwrite existing file. Use --force to overwrite or --skip-existing to keep it.\n${filePath}`) + } + } + + await mkdir(path.dirname(filePath), { recursive: true }) + const tempPath = temporaryDownloadPath(filePath) + + try { + const response = await fetchAssetStream(asset.url) + const transform = progressTransform(parseContentLength(response.headers), options.onProgress) + if (transform) { + await pipeline(response.data, transform, createWriteStream(tempPath)) + } else { + await pipeline(response.data, createWriteStream(tempPath)) + } + await rename(tempPath, filePath) + } catch (err) { + await rm(tempPath, { force: true }) + throw err + } + + return 'written' +} diff --git a/packages/cli/src/rest/__tests__/asset-manifests.spec.ts b/packages/cli/src/rest/__tests__/asset-manifests.spec.ts new file mode 100644 index 000000000..af5d7cc4b --- /dev/null +++ b/packages/cli/src/rest/__tests__/asset-manifests.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import AssetManifests from '../asset-manifests.js' + +describe('AssetManifests REST client', () => { + it('gets a check result asset manifest', async () => { + const api = { + get: vi.fn().mockResolvedValue({ data: { assets: [] } }), + } + const assetManifests = new AssetManifests(api as any) + + const result = await assetManifests.getForCheckResult('check-id', 'result-id') + + expect(result).toEqual({ assets: [] }) + expect(api.get).toHaveBeenCalledWith('/v1/check-results/check-id/result-id/assets') + }) + + it('gets a test-session result asset manifest', async () => { + const api = { + get: vi.fn().mockResolvedValue({ data: { assets: [] } }), + } + const assetManifests = new AssetManifests(api as any) + + const result = await assetManifests.getForTestSessionResult('session-id', 'result-id') + + expect(result).toEqual({ assets: [] }) + expect(api.get).toHaveBeenCalledWith('/v1/test-sessions/session-id/results/result-id/assets') + }) +}) diff --git a/packages/cli/src/rest/api.ts b/packages/cli/src/rest/api.ts index ae7d04294..a452053d4 100644 --- a/packages/cli/src/rest/api.ts +++ b/packages/cli/src/rest/api.ts @@ -6,6 +6,7 @@ import Accounts, { Account } from './accounts.js' import Users from './users.js' import Projects from './projects.js' import Assets from './assets.js' +import AssetManifests from './asset-manifests.js' import Runtimes from './runtimes.js' import PrivateLocations from './private-locations.js' import Locations from './locations.js' @@ -114,6 +115,7 @@ export const accounts = new Accounts(api) export const user = new Users(api) export const projects = new Projects(api) export const assets = new Assets(api) +export const assetManifests = new AssetManifests(api) export const runtimes = new Runtimes(api) export const locations = new Locations(api) export const privateLocations = new PrivateLocations(api) diff --git a/packages/cli/src/rest/asset-manifests.ts b/packages/cli/src/rest/asset-manifests.ts new file mode 100644 index 000000000..82f66833f --- /dev/null +++ b/packages/cli/src/rest/asset-manifests.ts @@ -0,0 +1,44 @@ +import type { AxiosInstance } from 'axios' + +export type AssetManifestEntryType = 'log' | 'trace' | 'video' | 'screenshot' | 'pcap' | 'report' | 'file' + +export interface AssetManifestArchiveEntry { + entryName: string +} + +export interface AssetManifestEntry { + type: AssetManifestEntryType + name: string + url: string + contentType?: string + source: string + archive?: AssetManifestArchiveEntry +} + +export interface AssetManifest { + assets: AssetManifestEntry[] + truncated?: boolean + entriesReturned?: number + entriesTotal?: number +} + +class AssetManifests { + api: AxiosInstance + constructor (api: AxiosInstance) { + this.api = api + } + + async getForCheckResult (checkId: string, checkResultId: string): Promise { + const response = await this.api.get(`/v1/check-results/${checkId}/${checkResultId}/assets`) + return response.data + } + + async getForTestSessionResult (testSessionId: string, testSessionResultId: string): Promise { + const response = await this.api.get( + `/v1/test-sessions/${testSessionId}/results/${testSessionResultId}/assets`, + ) + return response.data + } +} + +export default AssetManifests