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

115 lines
4.2 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { ArtifactStore } from "@argent/registry";
import {
nativeProfilerSessionBlueprint,
type NativeProfilerSessionApi,
} from "../../src/blueprints/native-profiler-session";
// Mock the platform impl modules so we never shell out to xctrace / adb. We
// only care that the dispatch picks the correct branch given an api.platform.
vi.mock("../../src/utils/check-deps", () => ({
ensureDeps: vi.fn(async () => {}),
ensureDep: vi.fn(async () => {}),
}));
vi.mock("../../src/tools/profiler/native-profiler/platforms/ios", () => ({
startNativeProfilerIos: vi.fn(async () => ({
status: "recording",
pid: 1,
traceFile: "/ios.trace",
})),
stopNativeProfilerIos: vi.fn(async () => ({
traceFile: "/ios.trace",
exportedFiles: {},
exportDiagnostics: { tocSchemas: [], cpuSchemaUsed: null, errors: {} },
})),
analyzeNativeProfilerIos: vi.fn(async () => ({
report: "ios",
reportFile: null,
bottlenecksTotal: 0,
status: "ok",
exportErrors: {},
})),
handleXctraceExit: vi.fn(),
}));
vi.mock("../../src/tools/profiler/native-profiler/platforms/android", () => ({
startNativeProfilerAndroid: vi.fn(async () => ({
status: "recording",
pid: 2,
traceFile: "/android.pftrace",
})),
stopNativeProfilerAndroid: vi.fn(async () => ({
traceFile: "/android.pftrace",
exportedFiles: {},
})),
analyzeNativeProfilerAndroid: vi.fn(async () => ({
report: "android",
reportFile: null,
bottlenecksTotal: 0,
status: "ok",
exportErrors: {},
})),
}));
import { nativeProfilerStartTool } from "../../src/tools/profiler/native-profiler/native-profiler-start";
import { nativeProfilerStopTool } from "../../src/tools/profiler/native-profiler/native-profiler-stop";
import { nativeProfilerAnalyzeTool } from "../../src/tools/profiler/native-profiler/native-profiler-analyze";
import { startNativeProfilerIos } from "../../src/tools/profiler/native-profiler/platforms/ios";
import { startNativeProfilerAndroid } from "../../src/tools/profiler/native-profiler/platforms/android";
async function buildSession(platform: "ios" | "android"): Promise<NativeProfilerSessionApi> {
const device =
platform === "ios"
? {
id: "11111111-2222-3333-4444-555555555555",
platform: "ios" as const,
kind: "simulator" as const,
}
: { id: "emulator-5554", platform: "android" as const, kind: "emulator" as const };
const instance = await nativeProfilerSessionBlueprint.factory({}, device, { device });
return instance.api;
}
describe("native-profiler-* dispatch by session platform", () => {
it("routes iOS sessions through platforms/ios", async () => {
const api = await buildSession("ios");
const result = await nativeProfilerStartTool.execute({ session: api } as never, {
device_id: "11111111-2222-3333-4444-555555555555",
});
expect(startNativeProfilerIos).toHaveBeenCalled();
expect(startNativeProfilerAndroid).not.toHaveBeenCalled();
expect(result.pid).toBe(1);
});
it("routes Android sessions through platforms/android", async () => {
const api = await buildSession("android");
const result = await nativeProfilerStartTool.execute({ session: api } as never, {
device_id: "emulator-5554",
});
expect(startNativeProfilerAndroid).toHaveBeenCalled();
expect(result.pid).toBe(2);
});
it("stop+analyze also route by session platform", async () => {
const androidApi = await buildSession("android");
const stop = await nativeProfilerStopTool.execute(
{ session: androidApi } as never,
{
device_id: "emulator-5554",
},
{ artifacts: new ArtifactStore() }
);
// The stop tool wraps the platform's raw path into a downloadable artifact;
// the filename still proves it routed through the Android (.pftrace) path.
expect(stop.traceFile).toMatchObject({
__argentArtifact: true,
kind: "native-profile-trace",
archive: "tar.gz",
filename: "android.pftrace",
});
const analyze = await nativeProfilerAnalyzeTool.execute({ session: androidApi } as never, {
device_id: "emulator-5554",
});
expect(analyze.report).toBe("android");
});
});