Files
filip131311 5d76ed4d66 feat: tag artifacts with a semantic kind at registration (#564)
Fresh, from-current-main take on the artifact-kind idea from #398 /
#466. Knowing what an artifact *represents* (not just its MIME type) is
what a UI needs to group and name artifacts (e.g. in EAS); this PR
delivers exactly that — the tagged handle — without the
declared-output-map machinery.

## Summary

- Adds a closed `ArtifactKind` union to the artifact wire contract;
`kind` is carried on `ArtifactHandle`, `ArtifactEntry`, and the
`/artifacts` inventory items.
- `ArtifactStore.register` now takes a single `{ hostPath, kind, ... }`
options object, so the compiler forces every producer — current and
future — to declare what it is emitting.
- Tags **all** producers on main today, including the ones #398/#466
predate: screenshot (all four capture paths), screenshot-diff, **flow
snapshot baselines/diffs** (`screenshot` / `screenshot-diff-context`),
**screen recordings** (new `screen-recording` kind), and the
native/React profiler exports and reports.
- Types the native-profiler export keys at their source (`IosExportKey`
from the `EXPORTS` table, `AndroidExportKey`) and maps them through a
kind table that is **total over both unions** — a newly added export key
fails to compile until classified, replacing the previous stringly-keyed
loop (and the `?? "native-profile-trace"` silent-default this design
would otherwise need).
- Client-side (`@argent/tools-client`), the mirrored handle type keeps
`kind` **optional and widened** (`ArtifactKind | (string & {})`): an
older, already-running tool-server sends no kind, and a newer server may
send kinds this client build predates. Consumers treat it as advisory
metadata; unknown kinds are opaque, never an error.

## Differences from #398 / #466 (deliberate)

- No `@argent/artifacts` package, no third `ToolDefinition` generic, no
per-tool output-name registrar, no runtime throw for “undeclared output”
— the kind is plain data at the registration call site, checked at
compile time where it can be, and a wrong label can never fail a tool at
runtime. If a pre-invocation `artifacts` declaration in tool-listing
metadata is ever needed (an actual EAS consumer), it can be layered on
later without changing this wire contract.
- The two `ArtifactKind` definitions (registry / tools-client) stay
deliberately separate — same as the existing `ARTIFACT_MARKER`
must-match convention — because the client-side type is intentionally
*looser* (optional + open) than the server-side one for version skew.
- Extending the union stays backward-compatible on the wire.

## 2026-08-24 update

Merged current main (0.22.0) into the branch. The flow-visual rewrite
that landed since (cropOn crops, per-app snapshot-key dedupe, diff
scratch-dir hygiene) is preserved verbatim; its five registration sites
now carry kinds (`screenshot` for baseline/current,
`screenshot-diff-context` for the annotated diff — the cropped `current`
included). No producer added since the PR was opened needed a new kind:
`paste`, `shake`, and the other new tools emit no file artifacts, and
every register call site in the tree now goes through the typed options
object.

No docs update is needed: the docs site (added since this PR) does not
document the artifact handle wire contract; `kind` is advisory wire
metadata with no user-facing surface.

## Validation

At the merge with main (0.22.0), 2026-08-24:

- `npm run build` (full `tsc --build`) — clean
- `npm run typecheck:tests` in registry, tools-client, tool-server, mcp,
cli — clean
- Full suites green: registry 90, tools-client 191, tool-server 4495 (+1
skipped), mcp 80, cli 472
- `prettier --check .`, `eslint . --max-warnings 0`, and the knip gate —
all pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-24 12:09:51 +02:00

285 lines
9.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import supertest from "supertest";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { createHttpApp, type HttpAppHandle } from "../src/http";
import { ArtifactStore } from "@argent/registry";
import type { Registry } from "@argent/registry";
const isFlagEnabledMock = vi.hoisted(() => vi.fn(() => false));
// supertest/superagent doesn't buffer binary bodies (gzip) by default.
function binaryParser(res: NodeJS.ReadableStream, cb: (err: Error | null, body: Buffer) => void) {
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(Buffer.from(c)));
res.on("end", () => cb(null, Buffer.concat(chunks)));
res.on("error", (e: Error) => cb(e, Buffer.alloc(0)));
}
vi.mock("../src/utils/update-checker", () => ({
getUpdateState: vi.fn(() => ({
updateAvailable: false,
latestVersion: null,
currentVersion: "1.0.0",
})),
isUpdateNoteSuppressed: vi.fn(() => true),
suppressUpdateNote: vi.fn(),
}));
vi.mock("@argent/configuration-core", () => ({
isFlagEnabled: isFlagEnabledMock,
}));
// A minimal Registry carrying a real ArtifactStore — the `/artifacts/:id` route
// resolves files from `registry.artifacts`, so the test registers into the same
// store instance it hands to createHttpApp.
function stubRegistry(): Registry {
return {
artifacts: new ArtifactStore(),
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: [] })),
getTool: vi.fn(() => undefined),
invokeTool: vi.fn(),
} as unknown as Registry;
}
describe("ArtifactStore", () => {
it("registers a file and infers filename, mime type, and size", async () => {
const dir = await mkdtemp(join(tmpdir(), "artifact-reg-"));
try {
const filePath = join(dir, "shot.png");
await writeFile(filePath, Buffer.from([1, 2, 3, 4]));
const handle = await new ArtifactStore().register({ hostPath: filePath, kind: "screenshot" });
expect(handle.__argentArtifact).toBe(true);
expect(handle.kind).toBe("screenshot");
expect(handle.filename).toBe("shot.png");
expect(handle.mimeType).toBe("image/png");
expect(handle.size).toBe(4);
expect(typeof handle.id).toBe("string");
// Emitted so a co-located client can read the file directly (gate).
expect(handle.hostPath).toBe(filePath);
expect(typeof handle.mtimeMs).toBe("number");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("honours filename and mimeType overrides", async () => {
const dir = await mkdtemp(join(tmpdir(), "artifact-reg-"));
try {
const filePath = join(dir, "raw.bin");
await writeFile(filePath, "hi");
const handle = await new ArtifactStore().register({
hostPath: filePath,
kind: "screenshot",
filename: "pretty.png",
mimeType: "image/png",
});
expect(handle.filename).toBe("pretty.png");
expect(handle.mimeType).toBe("image/png");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("marks a directory (e.g. a .trace bundle) for tar.gz delivery", async () => {
const root = await mkdtemp(join(tmpdir(), "artifact-dir-"));
try {
const bundle = join(root, "session.trace");
await mkdir(bundle, { recursive: true });
await writeFile(join(bundle, "data.bin"), "trace");
const handle = await new ArtifactStore().register({
hostPath: bundle,
kind: "native-profile-trace",
});
expect(handle.kind).toBe("native-profile-trace");
expect(handle.archive).toBe("tar.gz");
expect(handle.filename).toBe("session.trace");
expect(handle.hostPath).toBe(bundle);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("honours an explicit archive option when the path can't be stat'd", async () => {
const handle = await new ArtifactStore().register({
hostPath: "/tmp/does-not-exist-yet.trace",
kind: "native-profile-trace",
archive: "tar.gz",
});
expect(handle.archive).toBe("tar.gz");
});
it("lists safe metadata for registered artifacts", async () => {
const dir = await mkdtemp(join(tmpdir(), "artifact-list-store-"));
try {
const filePath = join(dir, "shot.png");
await writeFile(filePath, "png");
const store = new ArtifactStore();
const handle = await store.register({
hostPath: filePath,
kind: "screenshot",
mimeType: "image/png",
});
expect(store.list()).toEqual([
{
id: handle.id,
kind: "screenshot",
filename: "shot.png",
mimeType: "image/png",
size: 3,
isDirectory: false,
},
]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe("GET /artifacts", () => {
let handle: HttpAppHandle | null = null;
beforeEach(() => {
isFlagEnabledMock.mockReturnValue(false);
});
afterEach(() => {
handle?.dispose();
handle = null;
isFlagEnabledMock.mockReset();
});
it("is hidden unless the artifact list endpoint flag is enabled", async () => {
handle = createHttpApp(stubRegistry());
const res = await supertest(handle.app).get("/artifacts");
expect(res.status).toBe(404);
});
it("returns registered artifact metadata without host paths", async () => {
const dir = await mkdtemp(join(tmpdir(), "artifact-list-route-"));
try {
const filePath = join(dir, "img.png");
await writeFile(filePath, "image");
const registry = stubRegistry();
const artifact = await registry.artifacts.register({
hostPath: filePath,
kind: "screenshot",
mimeType: "image/png",
});
isFlagEnabledMock.mockReturnValue(true);
handle = createHttpApp(registry);
const res = await supertest(handle.app).get("/artifacts");
expect(res.status).toBe(200);
expect(res.body).toEqual({
artifacts: [
{
id: artifact.id,
kind: "screenshot",
filename: "img.png",
mimeType: "image/png",
size: 5,
isDirectory: false,
},
],
});
expect(JSON.stringify(res.body)).not.toContain(filePath);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe("GET /artifacts/:id", () => {
let handle: HttpAppHandle | null = null;
afterEach(() => {
handle?.dispose();
handle = null;
});
it("streams a registered file with its content type", async () => {
const dir = await mkdtemp(join(tmpdir(), "artifact-route-"));
try {
const filePath = join(dir, "img.png");
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xaa, 0xbb]);
await writeFile(filePath, bytes);
const registry = stubRegistry();
const artifact = await registry.artifacts.register({
hostPath: filePath,
kind: "screenshot",
mimeType: "image/png",
});
handle = createHttpApp(registry);
const res = await supertest(handle.app).get(`/artifacts/${artifact.id}`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("image/png");
expect(Buffer.from(res.body)).toEqual(bytes);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("streams a directory bundle as a gzipped tar that unpacks to the original files", async () => {
const root = await mkdtemp(join(tmpdir(), "artifact-bundle-"));
try {
const bundle = join(root, "session.trace");
await mkdir(join(bundle, "sub"), { recursive: true });
await writeFile(join(bundle, "top.txt"), "top");
await writeFile(join(bundle, "sub", "nested.txt"), "nested");
const registry = stubRegistry();
const artifact = await registry.artifacts.register({
hostPath: bundle,
kind: "native-profile-trace",
});
handle = createHttpApp(registry);
const res = await supertest(handle.app)
.get(`/artifacts/${artifact.id}`)
.buffer(true)
.parse(binaryParser as never);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("application/gzip");
expect(res.headers["content-disposition"]).toContain("session.trace.tar.gz");
// Round-trip: the gzipped tar lists the bundle's own files under its name.
const tarball = join(root, "out.tar.gz");
await writeFile(tarball, res.body);
const listing = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" });
expect(listing).toContain("session.trace/top.txt");
expect(listing).toContain("session.trace/sub/nested.txt");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("404s an unknown artifact id", async () => {
handle = createHttpApp(stubRegistry());
const res = await supertest(handle.app).get("/artifacts/does-not-exist");
expect(res.status).toBe(404);
});
it("410s when the registered file has been removed", async () => {
const dir = await mkdtemp(join(tmpdir(), "artifact-gone-"));
const filePath = join(dir, "gone.png");
await writeFile(filePath, "x");
const registry = stubRegistry();
const artifact = await registry.artifacts.register({ hostPath: filePath, kind: "screenshot" });
await rm(dir, { recursive: true, force: true });
handle = createHttpApp(registry);
const res = await supertest(handle.app).get(`/artifacts/${artifact.id}`);
expect(res.status).toBe(410);
});
});