Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
- CloudArtifact: provider-hosted session output such as video, Appium logs, device logs, automation
logs, or provider dashboard links. Cloud artifacts stay under the `cloudArtifacts` response field
so they do not collide with daemon-managed local/downloadable `artifacts`.
- DaemonArtifactType: optional semantic category supplied by the command or adapter that owns a
daemon-managed downloadable artifact, such as `screenshot`, `screen-recording`, or `trace-log`.
Finalization and inventory code must preserve this value when present, not infer it from
filenames, fields, or MIME types. Missing artifact types must not prevent artifact registration.
The type documents known values while allowing provider or command owners to introduce more
specific strings.
- Provider transcript: exact record of provider calls used when a test must verify platform command translation.
- Scenario transcript: command-level integration flow that describes user-visible behavior through daemon commands.
- In-process provider scenario harness: integration runner that invokes the daemon request handler directly without opening an HTTP listener.
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client/client.ts';
import { runCommand } from '../commands/command-surface.ts';
import type { DaemonRequest, DaemonResponse } from '../kernel/contracts.ts';
import type { DaemonRequest, DaemonResponse, DaemonResponseData } from '../kernel/contracts.ts';
import { AppError } from '../kernel/errors.ts';

function createTransport(
Expand Down Expand Up @@ -809,11 +809,11 @@ test('sessions.stateDir resolves locally without contacting the daemon', async (
});

test('capture.screenshot passes a digest (non-default level) payload through unnormalized', async () => {
const digest = {
const digest: DaemonResponseData = {
path: '/tmp/shot.png',
overlayCount: 2,
overlayRefs: [{ ref: 'e1', label: 'Login' }],
artifacts: [{ field: 'path', artifactId: 'a1' }],
artifacts: [{ field: 'path', artifactType: 'screenshot', artifactId: 'a1' }],
};
const setup = createTransport(async (req) => {
assert.equal(req.command, 'screenshot');
Expand Down
6 changes: 5 additions & 1 deletion src/__tests__/daemon-entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ test('daemon runtime starts HTTP transport in-process and shuts down cleanly', a
const paths = resolveDaemonPaths(stateDir);
const artifactPath = path.join(stateDir, 'runtime-artifact.txt');
fs.writeFileSync(artifactPath, 'runtime-artifact');
const artifactId = trackDownloadableArtifact({ artifactPath, fileName: 'runtime-artifact.txt' });
const artifactId = trackDownloadableArtifact({
artifactPath,
artifactType: 'runtime-artifact',
fileName: 'runtime-artifact.txt',
});
const stdout: string[] = [];
const stderr: string[] = [];
let exitCode: number | undefined;
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/runtime-public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ test('local artifact adapter marks command outputs and temp files by visibility'
const output = await adapter.reserveOutput(undefined, {
field: 'path',
ext: '.png',
artifactType: 'screenshot',
visibility: 'client-visible',
});
const temp = await adapter.createTempFile({
Expand Down Expand Up @@ -153,7 +154,7 @@ test('local artifact adapter can constrain explicit local paths to a root', asyn
() =>
adapter.reserveOutput(
{ kind: 'path', path: path.join(path.dirname(root), 'outside.png') },
{ field: 'path', ext: '.png' },
{ field: 'path', ext: '.png', artifactType: 'screenshot' },
),
/outside the artifact adapter root/,
);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/__tests__/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test('screenshot --level digest --json preserves the digest payload through the
path: '/tmp/shot.png',
overlayCount: 2,
overlayRefs: [{ ref: 'e1', label: 'Login' }],
artifacts: [{ field: 'path', artifactId: 'a1' }],
artifacts: [{ field: 'path', artifactType: 'screenshot', artifactId: 'a1' }],
};
const client = clientReturning(digest, 'digest');
const flags = { json: true, responseLevel: 'digest' } as CliFlags;
Expand Down
4 changes: 4 additions & 0 deletions src/cloud-artifacts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { DaemonArtifactType } from './kernel/contracts.ts';

const CLOUD_ARTIFACT_KINDS = [
'video',
'appium-log',
Expand Down Expand Up @@ -36,6 +38,8 @@ export type CloudArtifactsResult = {

export type DaemonArtifactInventoryEntry = {
id: string;
// Optional on the wire (see DaemonArtifact.artifactType).
artifactType?: DaemonArtifactType;
filename: string;
mimeType: string;
sizeBytes: number;
Expand Down
2 changes: 2 additions & 0 deletions src/commands/capture/runtime/diff-screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const diffScreenshotCommand: RuntimeCommand<
? await reserveCommandOutput(runtime, options.out, {
field: 'diffPath',
ext: '.png',
artifactType: 'screenshot-diff',
})
: undefined;

Expand Down Expand Up @@ -161,6 +162,7 @@ async function maybeAttachCurrentOverlay(
const overlayOutput = await reserveCommandOutput(runtime, overlayOutputRef, {
field: 'currentOverlayPath',
ext: '.png',
artifactType: 'screenshot',
});

try {
Expand Down
1 change: 1 addition & 0 deletions src/commands/capture/runtime/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const screenshotCommand: RuntimeCommand<
const reserved = await reserveCommandOutput(runtime, options.out, {
field: 'path',
ext: '.png',
artifactType: 'screenshot',
});

let artifact: ArtifactDescriptor | undefined;
Expand Down
7 changes: 5 additions & 2 deletions src/commands/management/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe('artifactsCliOutput', () => {
artifacts: [
{
id: 'artifact-1',
artifactType: 'screenshot',
filename: 'screenshot.png',
mimeType: 'application/octet-stream',
sizeBytes: 123,
Expand All @@ -100,10 +101,12 @@ describe('artifactsCliOutput', () => {
},
});

expect(output.text).toBe('screenshot.png: application/octet-stream 123 bytes id=artifact-1');
expect(output.text).toBe(
'screenshot.png (screenshot): application/octet-stream 123 bytes id=artifact-1',
);
expect(output.data).toMatchObject({
source: 'daemon',
artifacts: [{ id: 'artifact-1', filename: 'screenshot.png' }],
artifacts: [{ id: 'artifact-1', artifactType: 'screenshot', filename: 'screenshot.png' }],
});
});
});
Expand Down
3 changes: 2 additions & 1 deletion src/commands/management/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,8 @@ function formatCloudArtifactLine(artifact: CloudArtifactsResult['cloudArtifacts'
}

function formatDaemonArtifactLine(artifact: DaemonArtifactsResult['artifacts'][number]): string {
return `${artifact.filename}: ${artifact.mimeType} ${artifact.sizeBytes} bytes id=${artifact.id}`;
const type = artifact.artifactType ? ` (${artifact.artifactType})` : '';
return `${artifact.filename}${type}: ${artifact.mimeType} ${artifact.sizeBytes} bytes id=${artifact.id}`;
}

function formatCloudArtifactsRetryCommand(result: CloudArtifactsResult): string | undefined {
Expand Down
1 change: 1 addition & 0 deletions src/commands/recording/runtime/recording.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ test('record keeps successful reserved outputs available after publish', async (
publish: async () => ({
kind: 'artifact',
field: options.field,
artifactType: options.artifactType,
artifactId: 'recording-1',
fileName: 'recording.mp4',
}),
Expand Down
2 changes: 2 additions & 0 deletions src/commands/recording/runtime/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const recordCommand: RuntimeCommand<
? await reserveCommandOutput(runtime, options.out, {
field: 'path',
ext: '.mp4',
artifactType: 'screen-recording',
})
: undefined;
try {
Expand Down Expand Up @@ -100,6 +101,7 @@ export const traceCommand: RuntimeCommand<
? await reserveCommandOutput(runtime, options.out, {
field: 'outPath',
ext: '.trace',
artifactType: 'trace-log',
})
: undefined;
try {
Expand Down
42 changes: 31 additions & 11 deletions src/daemon/__tests__/http-server-artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type ArtifactInventoryResponse = {
artifacts: Array<{
id: string;
artifactType?: string;
filename: string;
mimeType: string;
sizeBytes: number;
Expand All @@ -37,15 +38,21 @@ test('downloadable artifact inventory is filtered by tenant', async () => {
fs.writeFileSync(tenantAPath, 'tenant-a');
fs.writeFileSync(tenantBPath, 'tenant-b');
const artifactIds = [
trackDownloadableArtifact({ artifactPath: publicPath, fileName: 'public.txt' }),
trackDownloadableArtifact({
artifactPath: publicPath,
artifactType: 'test-public-file',
fileName: 'public.txt',
}),
trackDownloadableArtifact({
artifactPath: tenantAPath,
tenantId: 'tenant-a',
artifactType: 'test-tenant-file',
fileName: 'tenant-a.txt',
}),
trackDownloadableArtifact({
artifactPath: tenantBPath,
tenantId: 'tenant-b',
artifactType: 'test-tenant-file',
fileName: 'tenant-b.txt',
}),
];
Expand Down Expand Up @@ -79,8 +86,16 @@ test('downloadable artifact inventory skips directory artifacts that fail to arc
fs.mkdirSync(tracePath, { recursive: true });
fs.writeFileSync(path.join(tracePath, 'metadata.json'), '{}\n');
const artifactIds = [
trackDownloadableArtifact({ artifactPath: filePath, fileName: 'report.json' }),
trackDownloadableArtifact({ artifactPath: tracePath, fileName: 'profile.trace' }),
trackDownloadableArtifact({
artifactPath: filePath,
artifactType: 'test-report',
fileName: 'report.json',
}),
trackDownloadableArtifact({
artifactPath: tracePath,
artifactType: 'trace-log',
fileName: 'profile.trace',
}),
];

try {
Expand Down Expand Up @@ -112,6 +127,7 @@ test('daemon artifact inventory exposes directory artifacts as tar.gz downloads'
fs.writeFileSync(path.join(tracePath, 'metadata.json'), '{"ok":true}\n');
const artifactId = trackDownloadableArtifact({
artifactPath: tracePath,
artifactType: 'trace-log',
fileName: 'profile.trace',
});
const server = await createDaemonHttpServer({
Expand Down Expand Up @@ -174,6 +190,7 @@ test('daemon artifact inventory lists artifacts and downloads consume them', asy
fs.writeFileSync(artifactPath, 'png-body');
const artifactId = trackDownloadableArtifact({
artifactPath,
artifactType: 'screenshot',
fileName: 'shot.png',
});
const server = await createDaemonHttpServer({
Expand All @@ -191,6 +208,7 @@ test('daemon artifact inventory lists artifacts and downloads consume them', asy
const body = (await inventory.json()) as ArtifactInventoryResponse;
const artifact = body.artifacts.find((entry) => entry.id === artifactId);
assert.ok(artifact, `expected ${artifactId} in artifact inventory`);
assert.equal(artifact.artifactType, 'screenshot');
assert.equal(artifact.filename, 'shot.png');
assert.equal(artifact.mimeType, 'application/octet-stream');
assert.equal(artifact.sizeBytes, 'png-body'.length);
Expand Down Expand Up @@ -228,6 +246,7 @@ test('daemon artifact downloads can keep the source file while consuming the inv
fs.writeFileSync(artifactPath, 'runner-output');
const artifactId = trackDownloadableArtifact({
artifactPath,
artifactType: 'runner-output',
fileName: 'runner-output.txt',
deleteAfterDownload: false,
});
Expand Down Expand Up @@ -256,12 +275,11 @@ test('daemon artifact downloads can keep the source file while consuming the inv
assert.equal(await consumingDownload.text(), 'runner-output');
assert.equal(fs.existsSync(artifactPath), true);

const inventoryAfterConsume = await fetch(`${baseUrl}/artifacts`, { headers: auth });
const consumedBody = (await inventoryAfterConsume.json()) as ArtifactInventoryResponse;
assert.equal(
consumedBody.artifacts.some((entry) => entry.id === artifactId),
false,
);
await waitFor(async () => {
const inventoryAfterConsume = await fetch(`${baseUrl}/artifacts`, { headers: auth });
const consumedBody = (await inventoryAfterConsume.json()) as ArtifactInventoryResponse;
return !consumedBody.artifacts.some((entry) => entry.id === artifactId);
});
} finally {
cleanupDownloadableArtifact(artifactId);
await closeLoopbackServer(server);
Expand All @@ -277,6 +295,7 @@ test('daemon artifact downloads can be forced retained by server option', async
fs.writeFileSync(artifactPath, 'log-body');
const artifactId = trackDownloadableArtifact({
artifactPath,
artifactType: 'session-log',
fileName: 'session-log.txt',
});
const server = await createDaemonHttpServer({
Expand Down Expand Up @@ -314,9 +333,10 @@ test('daemon artifact downloads can be forced retained by server option', async
}
});

async function waitFor(condition: () => boolean): Promise<void> {
async function waitFor(condition: () => boolean | Promise<boolean>): Promise<void> {
for (let attempt = 0; attempt < 20; attempt++) {
if (condition()) return;
if (await condition()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
Comment thread
sjchmiela marked this conversation as resolved.
Comment on lines +336 to 340
throw new Error('Timed out waiting for condition');
}
Comment thread
sjchmiela marked this conversation as resolved.
Loading
Loading