diff --git a/src/commands/device.ts b/src/commands/device.ts index 9dd70a0..282e6f1 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -19,6 +19,12 @@ import { import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js'; import { refreshAuth, resolveAuth } from '../utils/auth.js'; import { openBrowser } from '../utils/browser.js'; +import { + type DeviceArtifactFile, + downloadArtifactFiles, + fetchAllArtifacts, + planArtifactFiles, +} from '../utils/device-artifacts.js'; import { CliError, logger, validateEnum } from '../utils/cli.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { @@ -900,6 +906,86 @@ const openSub = defineCommand({ }, }); +interface ArtifactsData { + /** Where they were saved; null with --links. */ + dir: null | string; + files: DeviceArtifactFile[]; + failed: { file: string; reason: string }[]; +} + +const artifactsSub = defineCommand({ + meta: { + name: 'artifacts', + description: 'Download what a session left behind: its video, device log, and the screen after each action', + }, + args: { + ...commonArgs, + id: idArg, + output: { + type: 'string', + alias: ['o'], + description: 'Directory to save them in (default: ./-artifacts)', + }, + links: { + type: 'boolean', + default: false, + description: 'List the download links instead of downloading (they expire after a while)', + }, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const ctx = await context(args); + const id = await resolveSessionId(ctx.apiUrl, ctx.auth, args.id as string | undefined); + const { pages, session } = await fetchAllArtifacts(ctx.apiUrl, ctx.auth, id); + const files = planArtifactFiles(pages); + + if (args.links) { + return { + session, + data: { dir: null, files, failed: [] }, + print() { + if (files.length === 0) { + logger.log(ui.info('This session has no artifacts yet.')); + return; + } + + logger.log(ui.section(`Artifacts (${files.length})`)); + logger.log(ui.branch(ui.fields(files.map((f) => [f.file, f.url ? formatUrl(f.url) : colors.dim('no link')])))); + }, + }; + } + + const dir = resolvePath((args.output as string | undefined) || `${session.id}-artifacts`); + if (files.length > 0) ctx.progress(ui.running(`Downloading ${files.length} files to ${dir}…`)); + const failed = await downloadArtifactFiles(dir, files); + const saved = files.length - failed.length; + return { + session, + data: { dir, files, failed }, + ...(failed.length > 0 && { + failure: { + code: null, + message: `${failed.length} of ${files.length} files could not be downloaded: ${failed.map((f) => `${f.file} (${f.reason})`).join(', ')}`, + }, + }), + print() { + if (files.length === 0) { + logger.log(ui.info('This session has no artifacts yet.')); + return; + } + + logger.log(ui.success(`Saved ${saved} of ${files.length} files to ${dir}`)); + for (const f of failed) logger.log(ui.warn(`${f.file}: ${f.reason}`)); + if (!ENDED_STATES.includes(session.state)) { + logger.log(ui.note('The session is still open: its video and log are saved when it ends.')); + } + }, + }; + }); + }, +}); + export const deviceCommand = defineCommand({ meta: { name: 'device', @@ -921,6 +1007,7 @@ export const deviceCommand = defineCommand({ run: runSub, logs: logsSub, open: openSub, + artifacts: artifactsSub, }, }); diff --git a/src/gateways/device-gateway.ts b/src/gateways/device-gateway.ts index d21000d..1b24cea 100644 --- a/src/gateways/device-gateway.ts +++ b/src/gateways/device-gateway.ts @@ -1,5 +1,6 @@ import type { AuthContext } from '../types/domain/auth.types.js'; import type { + DeviceArtifactsPage, DeviceCatalogue, DeviceCommandResult, DeviceInstalledApp, @@ -109,6 +110,16 @@ const sessionPath = (id: string) => `/device/sessions/${encodeURIComponent(id)}` /** The Agent Devices session API (dcd docs/device-v1-plan.md, section 4.1). */ export const DeviceGateway = { + artifacts( + baseUrl: string, + auth: AuthContext, + id: string, + before?: string, + ): Promise { + const query = new URLSearchParams({ limit: '200', ...(before && { before }) }); + return request(baseUrl, auth, 'GET', `${sessionPath(id)}/artifacts?${query}`); + }, + command( baseUrl: string, auth: AuthContext, diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts index c35091d..2ac4d7f 100644 --- a/src/types/domain/device-session.types.ts +++ b/src/types/domain/device-session.types.ts @@ -128,3 +128,27 @@ export interface DeviceLogs { /** logcat, ios-unified-log, or none before an app is installed. */ source: string; } + +/** A short-lived download link for one stored artifact. */ +export interface DeviceArtifactLink { + bytes: null | number; + content_type: string; + created_at: string; + /** Null when the file could not be signed (e.g. already cleaned up). */ + url: null | string; +} + +/** GET .../artifacts: one page, newest actions first. */ +export interface DeviceArtifactsPage { + actions: { + command_id: null | string; + created_at: string; + hierarchy: DeviceArtifactLink | null; + screenshot: DeviceArtifactLink | null; + }[]; + logs: DeviceArtifactLink | null; + /** Pass as `before` for the next (older) page; null on the last page. */ + next_before: null | string; + recording: DeviceArtifactLink | null; + session: DeviceSession; +} diff --git a/src/utils/device-artifacts.ts b/src/utils/device-artifacts.ts new file mode 100644 index 0000000..12ca7c2 --- /dev/null +++ b/src/utils/device-artifacts.ts @@ -0,0 +1,152 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; + +import { DeviceGateway } from '../gateways/device-gateway.js'; +import type { AuthContext } from '../types/domain/auth.types.js'; +import type { + DeviceArtifactLink, + DeviceArtifactsPage, + DeviceSession, +} from '../types/domain/device-session.types.js'; + +/** One file a session left behind, and where it goes on disk. */ +export interface DeviceArtifactFile { + /** recording, logs, screenshot or hierarchy. */ + kind: 'hierarchy' | 'logs' | 'recording' | 'screenshot'; + /** Relative to the output directory. */ + file: string; + url: null | string; + bytes: null | number; + /** The action a screenshot or screen dump was taken after. */ + command_id?: null | string; + created_at: string; +} + +const EXTENSIONS: Record = { + 'application/json': 'json', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'text/plain': 'log', + 'video/mp4': 'mp4', +}; + +function extensionFor(link: DeviceArtifactLink, fallback: string): string { + return EXTENSIONS[link.content_type.split(';')[0].trim()] ?? fallback; +} + +/** Every page of a session's artifacts. */ +export async function fetchAllArtifacts( + apiUrl: string, + auth: AuthContext, + id: string, +): Promise<{ pages: DeviceArtifactsPage[]; session: DeviceSession }> { + const pages: DeviceArtifactsPage[] = []; + let before: string | undefined; + do { + const page = await DeviceGateway.artifacts(apiUrl, auth, id, before); + pages.push(page); + before = page.next_before ?? undefined; + } while (before); + return { pages, session: pages.at(-1)!.session }; +} + +/** + * Lay the artifacts out as files: `recording.mp4`, `device.log`, and per + * action `actions/001-screenshot.jpg` and `actions/001-screen.json`, + * numbered oldest first so the folder reads in order. + */ +export function planArtifactFiles(pages: DeviceArtifactsPage[]): DeviceArtifactFile[] { + const files: DeviceArtifactFile[] = []; + const first = pages[0]; + if (first?.recording) { + files.push({ + kind: 'recording', + file: `recording.${extensionFor(first.recording, 'mp4')}`, + url: first.recording.url, + bytes: first.recording.bytes, + created_at: first.recording.created_at, + }); + } + + if (first?.logs) { + files.push({ + kind: 'logs', + file: `device.${extensionFor(first.logs, 'log')}`, + url: first.logs.url, + bytes: first.logs.bytes, + created_at: first.logs.created_at, + }); + } + + const actions = pages.flatMap((p) => p.actions).reverse(); + const width = Math.max(3, String(actions.length).length); + for (const [i, action] of actions.entries()) { + const n = String(i + 1).padStart(width, '0'); + if (action.screenshot) { + files.push({ + kind: 'screenshot', + file: path.join('actions', `${n}-screenshot.${extensionFor(action.screenshot, 'jpg')}`), + url: action.screenshot.url, + bytes: action.screenshot.bytes, + command_id: action.command_id, + created_at: action.created_at, + }); + } + + if (action.hierarchy) { + files.push({ + kind: 'hierarchy', + file: path.join('actions', `${n}-screen.${extensionFor(action.hierarchy, 'json')}`), + url: action.hierarchy.url, + bytes: action.hierarchy.bytes, + command_id: action.command_id, + created_at: action.created_at, + }); + } + } + + return files; +} + +const CONCURRENCY = 4; + +/** + * Download every file with a link into `dir`, four at a time. Returns the + * files that could not be fetched, with why, rather than failing the rest. + */ +export async function downloadArtifactFiles( + dir: string, + files: DeviceArtifactFile[], + onFile: (file: DeviceArtifactFile) => void = () => {}, +): Promise<{ file: string; reason: string }[]> { + const failed: { file: string; reason: string }[] = []; + const queue = [...files]; + async function worker(): Promise { + for (let next = queue.shift(); next; next = queue.shift()) { + if (!next.url) { + failed.push({ file: next.file, reason: 'no download link (it may have been cleaned up)' }); + continue; + } + + const target = path.join(dir, next.file); + try { + await mkdir(path.dirname(target), { recursive: true }); + const res = await fetch(next.url); + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); + await pipeline( + Readable.fromWeb(res.body as Parameters[0]), + createWriteStream(target), + ); + onFile(next); + } catch (error) { + failed.push({ file: next.file, reason: error instanceof Error ? error.message : String(error) }); + } + } + } + + await Promise.all(Array.from({ length: CONCURRENCY }, () => worker())); + return failed; +} diff --git a/test/unit/device-artifacts.test.ts b/test/unit/device-artifacts.test.ts new file mode 100644 index 0000000..4e4e984 --- /dev/null +++ b/test/unit/device-artifacts.test.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { DeviceArtifactsPage, DeviceSession } from '../../src/types/domain/device-session.types.js'; +import { + downloadArtifactFiles, + fetchAllArtifacts, + planArtifactFiles, +} from '../../src/utils/device-artifacts.js'; + +const API = 'https://api.devicecloud.dev'; +const auth = { headers: {}, mode: 'apiKey' as const }; +const session = { id: 'a-b-c', state: 'released' } as DeviceSession; + +const link = (url: null | string, contentType: string) => ({ + url, + content_type: contentType, + bytes: 3, + created_at: '2026-10-19T10:00:00Z', +}); + +function page(extra: Partial): DeviceArtifactsPage { + return { actions: [], logs: null, next_before: null, recording: null, session, ...extra }; +} + +describe('dcd device artifacts', () => { + const realFetch = globalThis.fetch; + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-artifacts-test-')); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + fs.rmSync(tempDir, { force: true, recursive: true }); + }); + + it('follows next_before through every page', async () => { + const urls: string[] = []; + globalThis.fetch = (async (input: unknown) => { + urls.push(String(input)); + const body = String(input).includes('before=') + ? page({ actions: [{ command_id: 'c1', created_at: 't1', screenshot: null, hierarchy: null }] }) + : page({ + next_before: '2026-10-19T10:00:00Z', + actions: [{ command_id: 'c2', created_at: 't2', screenshot: null, hierarchy: null }], + }); + return new Response(JSON.stringify(body)); + }) as typeof fetch; + + const { pages } = await fetchAllArtifacts(API, auth, 'a-b-c'); + expect(pages).to.have.length(2); + expect(urls).to.deep.equal([ + `${API}/device/sessions/a-b-c/artifacts?limit=200`, + `${API}/device/sessions/a-b-c/artifacts?limit=200&before=2026-10-19T10%3A00%3A00Z`, + ]); + }); + + it('names the files, numbering actions oldest first', () => { + const files = planArtifactFiles([ + page({ + recording: link('https://r', 'video/mp4'), + logs: link('https://l', 'text/plain; charset=utf-8'), + actions: [ + { command_id: 'c2', created_at: 't2', screenshot: link('https://s2', 'image/jpeg'), hierarchy: null }, + ], + }), + page({ + actions: [ + { + command_id: 'c1', + created_at: 't1', + screenshot: link('https://s1', 'image/jpeg'), + hierarchy: link('https://h1', 'application/json'), + }, + ], + }), + ]); + expect(files.map((f) => [f.file, f.command_id ?? null])).to.deep.equal([ + ['recording.mp4', null], + ['device.log', null], + [path.join('actions', '001-screenshot.jpg'), 'c1'], + [path.join('actions', '001-screen.json'), 'c1'], + [path.join('actions', '002-screenshot.jpg'), 'c2'], + ]); + }); + + it('downloads what it can and reports the rest', async () => { + globalThis.fetch = (async (input: unknown) => + String(input) === 'https://ok' + ? new Response('abc') + : new Response('gone', { status: 404 })) as typeof fetch; + + const failed = await downloadArtifactFiles(tempDir, [ + { kind: 'recording', file: 'recording.mp4', url: 'https://ok', bytes: 3, created_at: 't' }, + { kind: 'logs', file: 'device.log', url: 'https://missing', bytes: 3, created_at: 't' }, + { kind: 'screenshot', file: path.join('actions', '001-screenshot.jpg'), url: null, bytes: null, created_at: 't' }, + ]); + expect(fs.readFileSync(path.join(tempDir, 'recording.mp4'), 'utf8')).to.equal('abc'); + expect(failed.map((f) => f.file).sort()).to.deep.equal( + [path.join('actions', '001-screenshot.jpg'), 'device.log'].sort(), + ); + }); +});