Files
software-mansion__argent/packages/tool-server/test/failure-classification.test.ts
Hubert Gancarczyk ef955e604d fix(telemetry): add missing failure codes (#420)
## Summary

Adds the missing **failure codes** across the tool-server and converts
raw
`throw new Error(...)` sites into classified `FailureError`s, so
operational
failures land in named telemetry buckets instead of falling through to
the
generic `REGISTRY_TOOL_FAILURE_UNCLASSIFIED` bucket.

- **39 new `FAILURE_CODES`** plus a new `"electron"` entry in
`FAILURE_COMMANDS`.
- Throw sites migrated to `FailureError`, each carrying
`error_code` + `failure_stage` + `failure_area` + `error_kind`, and
where
  relevant `failure_command` / `network_failure` / subprocess metadata
  (`failure_exit_code` / `failure_signal` / `failure_spawn_code`).

## Addressing review feedback

The review found that several proposed codes were thrown only on paths
that are
caught / reformatted before the telemetry boundary (a code only buckets
if its
`FailureError` propagates out of a registered tool's `execute`), so they
could
never bucket a real failure. Those are removed rather than left as dead
codes:

- **Dropped
`VEGA_PAGE_SOURCE_{FETCH_FAILED,HTTP_ERROR,RPC_ERROR,TIMEOUT,PARSE_FAILED}`**
- `describeVega` deliberately swallows every toolkit failure (empty tree
+
  relaunch hint) except `MultipleVegaDevicesError`, so these never reach
`toolFailed`. The throw sites revert to plain `Error` (behaviour
unchanged).
- **Dropped `CHROMIUM_CLIPBOARD_FAILED`** (the chromium-server Express
route
reformats the error to a 500 body; the image-copy path has no production
  caller) and **`CHROMIUM_BUTTON_UNSUPPORTED`** (the WS `button` handler
  reformats it; the registered `button` tool is apple+android only).
- **Fixed the `CHROMIUM_CDP_INVALID_RESPONSE` gap** - a reachable
HTTP-200 with a
non-JSON body made `res.json()` reject with a raw `SyntaxError` that
escaped
`fetchJson` unclassified; it is now classified
`CHROMIUM_CDP_INVALID_RESPONSE`
(matching the non-2xx and missing-`webSocketDebuggerUrl` paths).
`AbortError`
  still surfaces untouched.
- **Unified two codes that carried two `error_kind`s** - both
`REACT_PROFILER_NO_ACTIVE_SESSION` sites and both
`PROFILER_NATIVE_TRACE_MISSING`
sites now use `not_found` (a missing session / a trace file missing on
disk),
  keeping telemetry keyed on `error_code` from splitting by platform.
- **Reverted an out-of-scope edit** - the in-flight Vega-boot coalescing
Map-key
  separator is back to its original NUL byte.
- **Added `failure-classification.test.ts` coverage** - expanded from 7
to 17
  pinned codes, reading each back exactly how telemetry does
(`getFailureSignal(err).error_code`) across the chromium CDP discovery
family
(incl. the malformed-200 regression guard), vega input, and
native-profiler
  metadata / stack-query.

This branch also merges the latest `main`; the keyboard tool was
refactored on
`main` into per-platform modules + a `createKeyboardTool` factory, so
the
`KEYBOARD_KEY_UNSUPPORTED` / `KEYBOARD_CHARACTER_UNSUPPORTED`
classifications were
re-homed onto its new throw sites (`platforms/chromium.ts`,
`simulator-server-keys.ts`; the vega path keeps its classification in
`vega-input.ts`).

## What changed

By area (the 39 codes):

- **Chromium / CDP (16)** - discovery, input, viewport, storage, tabs,
describe,
and Electron boot: `CHROMIUM_CDP_UNREACHABLE`,
`CHROMIUM_CDP_NO_PAGE_TARGET`,
  `CHROMIUM_CDP_INVALID_RESPONSE`, `CHROMIUM_PARAM_INVALID`,
  `CHROMIUM_INPUT_INVALID`, `CHROMIUM_VIEWPORT_READ_FAILED`,
  `CHROMIUM_SCREENSHOT_FAILED`, `CHROMIUM_STORAGE_EVAL_FAILED`,
`CHROMIUM_TAB_NOT_FOUND`, `CHROMIUM_TAB_OPEN_FAILED`,
`CHROMIUM_DESCRIBE_FAILED`,
  `CHROMIUM_DEVICE_ID_INVALID`, `CHROMIUM_ELECTRON_APP_PATH_INVALID`,
  `CHROMIUM_ELECTRON_SPAWN_FAILED`, `CHROMIUM_ELECTRON_CDP_TIMEOUT`,
  `CHROMIUM_ELECTRON_EXITED_BEFORE_READY`.
  - Unreachable debug ports are distinguished from reached-but-malformed
responses; the `undici`-wrapped `ECONN*` code is read off `err.cause`
and
    mapped to a precise `network_failure` instead of always reporting
    `connection_refused`.
- **Vega (8)** - device discovery/registration, boot timeouts, image
lookup,
input, and screenshot: `VEGA_DEVICE_NOT_FOUND`,
`VEGA_DEVICE_NOT_REGISTERED`,
  `VEGA_MULTIPLE_DEVICES`, `VEGA_BOOT_TIMEOUT`, `VEGA_IMAGE_NOT_FOUND`,
  `VEGA_ALREADY_RUNNING`, `VEGA_SCREENSHOT_FAILED`, `VEGA_TEXT_INVALID`.
- `MultipleVegaDevicesError` extends `FailureError` so callers that
re-throw it
    (e.g. `describe`) surface a classified signal.
- **Native profiler (8)** - perfetto start failures (mirroring the iOS
xctrace
set), app-process detection, the metadata sidecar, and the shared
analyze
  guard: `NATIVE_PROFILER_PERFETTO_PROCESS_ERROR`,
  `NATIVE_PROFILER_PERFETTO_READY_TIMEOUT`,
  `NATIVE_PROFILER_PERFETTO_READY_EXITED`,
`NATIVE_PROFILER_APP_PROCESS_NOT_FOUND`,
`NATIVE_PROFILER_NO_EXPORTED_TRACE`,
`NATIVE_PROFILER_TRACE_TEMPLATE_MISSING`,
`PROFILER_NATIVE_METADATA_MISSING`,
  `PROFILER_NATIVE_METADATA_INVALID`.
- The iOS `analyze` guard moves off `PROFILER_NATIVE_TRACE_MISSING` onto
the
shared `NATIVE_PROFILER_NO_EXPORTED_TRACE` so the same logical failure
isn't
split by platform; `PROFILER_NATIVE_TRACE_MISSING` now means strictly a
trace
file missing on disk (and both of its sites use `error_kind:
not_found`).
- **Android / simulator / screenshot-diff (3)** -
  `ANDROID_UIAUTOMATOR_CAPTURE_FAILED`, `SIMULATOR_SCREENSHOT_FAILED`,
  `SCREENSHOT_DIFF_INPUT_INVALID`.
- **Flows / keyboard (4)** - `FLOW_PROJECT_ROOT_INVALID` (split from
`_REQUIRED`,
  which now means strictly "no root set yet"), `FLOW_NAME_INVALID`,
  `KEYBOARD_KEY_UNSUPPORTED`, `KEYBOARD_CHARACTER_UNSUPPORTED`.

`error_kind` is reserved consistently: `validation` for checks against
caller
input or a schema we own; `unknown` for malformed output from an
uncontrolled
source (renderer main world, toolkit RPC); `network` with a
`network_failure`
class for transport and reached-but-malformed responses; `not_found` for
a
missing resource / session / on-disk artifact.

## Testing

- `tsc --build` clean - `error_kind` / `failure_area` /
`failure_command` /
`network_failure` and every `FAILURE_CODES.X` are typed, so the compiler
  enforces structural validity of every signal.
- Unit tests: registry **83/83**, tool-server green (the one red
`await-ui-element` case is a pre-existing local-only timing flake
unrelated to
  this change - it fails identically on `main` and passes in CI).
- `failure-classification.test.ts` pins **17** codes, reading each back
via
`getFailureSignal(err).error_code`; the `CHROMIUM_CDP_INVALID_RESPONSE`
  malformed-200 fix is reproduced against a local HTTP server.
- `prettier --check` and `eslint` clean on all changed files.

## Notes

- No new codes are orphaned - every added `FAILURE_CODES` entry has at
least one
  throw site that can reach the telemetry boundary.
- A handful of internal-invariant throws (SQL-template/identifier
guards,
Tesseract-TSV parsing, unreachable `default:` branches) are
intentionally left
  as plain `Error`s - they represent programmer/tooling errors, not
  user-reachable failure modes, so they don't warrant telemetry codes.
2026-07-03 16:56:15 +02:00

327 lines
13 KiB
TypeScript

import { createServer, type Server } from "node:http";
import { describe, it, expect, afterEach } from "vitest";
import { FAILURE_CODES, getFailureSignal, type FailureCode } from "@argent/registry";
import {
setActiveProjectRoot,
clearActiveProjectRoot,
assertSafeFlowName,
getFlowPath,
} from "../src/tools/flows/flow-utils";
import type { DeviceInfo, Registry } from "@argent/registry";
import { makeChromiumImpl } from "../src/tools/keyboard/platforms/chromium";
import { chromiumCdpBlueprint } from "../src/blueprints/chromium-cdp";
import { ensureCdpReachable, discoverPrimaryPage } from "../src/chromium-server/cdp-session";
import { readViewport } from "../src/chromium-server/viewport";
import { captureScreenshot } from "../src/chromium-server/screenshot";
import type { CDPClient } from "../src/utils/debugger/cdp-client";
import { injectVegaText, injectVegaNamedKey } from "../src/utils/vega-input";
import {
readAndroidNativeProfilerMetadata,
androidNativeProfilerMetadataPath,
} from "../src/utils/android-profiler/session-metadata";
import { profilerStackQueryTool } from "../src/tools/profiler/query/profiler-stack-query";
import { tmpdir } from "node:os";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
/**
* Telemetry only reads a FailureError's signal when it propagates out of a tool's
* `execute` (the registry's `toolFailed` event walks the cause chain via
* `getFailureSignal`). These tests pin the `error_code` of representative
* newly-classified throw sites — across the flows, keyboard, chromium (blueprint
* factory + CDP discovery), vega input, and native-profiler (metadata + stack
* query) areas — so a regression that drops or mis-codes a classification is
* caught in CI rather than only in a one-off manual run.
*
* Each site here is reachable on a real telemetry path (a registered tool's
* execute, or a helper it calls un-swallowed) and driven with only a bare stub,
* a local HTTP server, or a temp file — no live device. Families that require a
* live CDP WebSocket or a booted device (electron boot, tab-open, vega adb/CLI
* paths) are exercised in the end-to-end suites, not here.
*/
/** Await a promise expected to reject and return the thrown error. */
async function captureError(p: Promise<unknown>): Promise<unknown> {
try {
await p;
} catch (e) {
return e;
}
throw new Error("expected the call to throw, but it resolved");
}
/** Run a synchronous function expected to throw and return the thrown error. */
function captureSync(fn: () => unknown): unknown {
try {
fn();
} catch (e) {
return e;
}
throw new Error("expected the call to throw, but it returned");
}
function expectCode(err: unknown, code: FailureCode): void {
expect(getFailureSignal(err)?.error_code).toBe(code);
}
const openServers: Server[] = [];
/** Start a local HTTP server on an ephemeral port and return its port. */
function startServer(handler: (path: string, res: import("node:http").ServerResponse) => void) {
return new Promise<number>((resolve) => {
const srv = createServer((req, res) => handler(req.url ?? "/", res));
openServers.push(srv);
srv.listen(0, "127.0.0.1", () => resolve((srv.address() as { port: number }).port));
});
}
afterEach(async () => {
// setActiveProjectRoot mutates module state; reset so cases don't leak.
clearActiveProjectRoot();
// Tear down any local servers a case spun up.
await Promise.all(openServers.splice(0).map((s) => new Promise<void>((r) => s.close(() => r()))));
});
describe("flow-utils classifications", () => {
it("classifies a relative project_root as FLOW_PROJECT_ROOT_INVALID", () => {
expectCode(
captureSync(() => setActiveProjectRoot("relative/path")),
FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID
);
});
it("classifies a project_root containing '..' as FLOW_PROJECT_ROOT_INVALID", () => {
expectCode(
captureSync(() => setActiveProjectRoot("/a/../b")),
FAILURE_CODES.FLOW_PROJECT_ROOT_INVALID
);
});
it("classifies path resolution with no active project_root as FLOW_PROJECT_ROOT_REQUIRED", () => {
// No active root → getFlowPath → getFlowsDir → requireActiveProjectRoot throws.
expectCode(
captureSync(() => getFlowPath("valid-name")),
FAILURE_CODES.FLOW_PROJECT_ROOT_REQUIRED
);
});
it("classifies an unsafe flow name as FLOW_NAME_INVALID", () => {
expectCode(
captureSync(() => assertSafeFlowName("bad name!")),
FAILURE_CODES.FLOW_NAME_INVALID
);
});
});
describe("keyboard classifications", () => {
// The chromium typing path lives in makeChromiumImpl's handler, which resolves
// the CDP service then validates the key/char before touching it. A stub
// registry (service resolves to a bare object) reaches the classified throw
// without a live browser.
const registry = { resolveService: async () => ({}) } as unknown as Registry;
const { handler } = makeChromiumImpl(registry);
const device = {
id: "chromium-cdp-9222",
platform: "chromium",
kind: "app",
} as unknown as DeviceInfo;
it("classifies an unknown named key as KEYBOARD_KEY_UNSUPPORTED", async () => {
expectCode(
await captureError(handler({}, { udid: device.id, key: "not-a-key" }, device)),
FAILURE_CODES.KEYBOARD_KEY_UNSUPPORTED
);
});
it("classifies an unmappable character as KEYBOARD_CHARACTER_UNSUPPORTED", async () => {
expectCode(
await captureError(handler({}, { udid: device.id, text: "\u{1F600}" }, device)),
FAILURE_CODES.KEYBOARD_CHARACTER_UNSUPPORTED
);
});
});
describe("chromium blueprint factory classifications", () => {
const device = (id: string) => ({ id, platform: "chromium" as const, kind: "app" as const });
it("classifies a malformed chromium device id as CHROMIUM_DEVICE_ID_INVALID", async () => {
// A well-formed id that agrees between options and URN passes the (uncoded)
// wiring guards, then the port parse fails — exercising the reachable
// CHROMIUM_DEVICE_ID_INVALID branch.
expectCode(
await captureError(
chromiumCdpBlueprint.factory({}, device("chromium-cdp-bogus"), {
device: device("chromium-cdp-bogus"),
})
),
FAILURE_CODES.CHROMIUM_DEVICE_ID_INVALID
);
});
});
describe("chromium CDP discovery classifications", () => {
// These drive the discovery helpers against a local HTTP server standing in
// for the debug port, exercising the reachable throw sites the way the tools
// do (ensureCdpReachable / discoverPrimaryPage bubble out of chromium tool
// execute → toolFailed → getFailureSignal).
it("classifies a 200 with a non-JSON body as CHROMIUM_CDP_INVALID_RESPONSE", async () => {
// A non-CDP service squatting the debug port answers 200 but the body can't
// be parsed — the reached-but-malformed case (regression guard for the
// res.json() parse that used to escape unclassified to the generic bucket).
const port = await startServer((_path, res) => {
res.writeHead(200, { "Content-Type": "text/html" });
res.end("<html>not a CDP endpoint</html>");
});
const err = await captureError(ensureCdpReachable(port));
expectCode(err, FAILURE_CODES.CHROMIUM_CDP_INVALID_RESPONSE);
expect(getFailureSignal(err)?.network_failure).toBe("invalid_response");
});
it("classifies a non-2xx status as CHROMIUM_CDP_INVALID_RESPONSE", async () => {
const port = await startServer((_path, res) => {
res.writeHead(500);
res.end("boom");
});
expectCode(
await captureError(ensureCdpReachable(port)),
FAILURE_CODES.CHROMIUM_CDP_INVALID_RESPONSE
);
});
it("classifies a refused connection as CHROMIUM_CDP_UNREACHABLE", async () => {
// Bind then immediately release a port so the connect is refused.
const port = await startServer(() => {});
await new Promise<void>((r) => openServers.splice(0)[0]!.close(() => r()));
const err = await captureError(ensureCdpReachable(port));
expectCode(err, FAILURE_CODES.CHROMIUM_CDP_UNREACHABLE);
expect(getFailureSignal(err)?.network_failure).toBe("connection_refused");
});
it("classifies an endpoint with no page targets as CHROMIUM_CDP_NO_PAGE_TARGET", async () => {
const port = await startServer((path, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
// /json/version resolves; /json/list has no page targets.
if (path.startsWith("/json/version")) {
res.end(JSON.stringify({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools/browser/x" }));
} else {
res.end(JSON.stringify([]));
}
});
expectCode(
await captureError(discoverPrimaryPage(port)),
FAILURE_CODES.CHROMIUM_CDP_NO_PAGE_TARGET
);
});
});
describe("chromium CDP-command malformed-response classifications", () => {
// A CDP command that round-trips but returns a payload missing the expected
// field is classified `error_kind: "unknown"` (a malformed response from a
// source we don't own), never `network`. These helpers are called
// un-swallowed from the chromium tools' execute, so the signal reaches
// telemetry. A stub CDPClient whose `send` resolves to `{}` reaches the
// classified throw without a live browser. This pins the convention that
// CHROMIUM_TAB_OPEN_FAILED (Target.createTarget with no targetId) also follows.
const stubCdp = (payload: unknown) => ({ send: async () => payload }) as unknown as CDPClient;
it("classifies a viewport read with no value as CHROMIUM_VIEWPORT_READ_FAILED (unknown)", async () => {
const err = await captureError(readViewport(stubCdp({})));
expectCode(err, FAILURE_CODES.CHROMIUM_VIEWPORT_READ_FAILED);
expect(getFailureSignal(err)?.error_kind).toBe("unknown");
});
it("classifies a screenshot with no data as CHROMIUM_SCREENSHOT_FAILED (unknown)", async () => {
const err = await captureError(
captureScreenshot({ cdp: stubCdp({}), deviceId: "chromium-cdp-9222" })
);
expectCode(err, FAILURE_CODES.CHROMIUM_SCREENSHOT_FAILED);
expect(getFailureSignal(err)?.error_kind).toBe("unknown");
});
});
describe("vega input classifications", () => {
// Both validate before any `adb`/inputd I/O, so they throw synchronously on
// bad input on the real typing path — no device needed to pin the code.
it("classifies newline-bearing text as VEGA_TEXT_INVALID", async () => {
expectCode(await captureError(injectVegaText("line1\nline2")), FAILURE_CODES.VEGA_TEXT_INVALID);
});
it("classifies an unknown named key as KEYBOARD_KEY_UNSUPPORTED", async () => {
expectCode(
await captureError(injectVegaNamedKey("not-a-real-key")),
FAILURE_CODES.KEYBOARD_KEY_UNSUPPORTED
);
});
});
describe("native-profiler metadata classifications", () => {
// readAndroidNativeProfilerMetadata is called un-swallowed on the profiler-load
// path; a corrupt or structurally-invalid sidecar propagates to telemetry.
let dir = "";
afterEach(async () => {
if (dir) await rm(dir, { recursive: true, force: true });
dir = "";
});
it("classifies a corrupt JSON metadata sidecar as PROFILER_NATIVE_METADATA_INVALID", async () => {
dir = await mkdtemp(join(tmpdir(), "argent-meta-"));
const pftrace = join(dir, "native-profiler-s1.pftrace");
await writeFile(androidNativeProfilerMetadataPath(pftrace), "{ not valid json", "utf8");
expectCode(
await captureError(readAndroidNativeProfilerMetadata(pftrace)),
FAILURE_CODES.PROFILER_NATIVE_METADATA_INVALID
);
});
it("classifies a structurally-invalid metadata sidecar as PROFILER_NATIVE_METADATA_INVALID", async () => {
dir = await mkdtemp(join(tmpdir(), "argent-meta-"));
const pftrace = join(dir, "native-profiler-s2.pftrace");
// Valid JSON, wrong shape (empty appProcess) → the validate branch.
await writeFile(
androidNativeProfilerMetadataPath(pftrace),
JSON.stringify({ platform: "android", appProcess: "", wallClockStartMs: null }),
"utf8"
);
expectCode(
await captureError(readAndroidNativeProfilerMetadata(pftrace)),
FAILURE_CODES.PROFILER_NATIVE_METADATA_INVALID
);
});
});
describe("native-profiler stack-query classifications", () => {
// The tool's execute has no try/catch, so these codes propagate to telemetry.
// A bare session stub is enough — the throws fire before any trace I/O.
const session = (api: object) => ({ session: api }) as never;
it("classifies a query with no parsed data as PROFILER_DATA_NOT_LOADED", async () => {
expectCode(
await captureError(
profilerStackQueryTool.execute(session({ platform: "ios", parsedData: null }), {
device_id: "x",
mode: "thread_breakdown",
top_n: 15,
})
),
FAILURE_CODES.PROFILER_DATA_NOT_LOADED
);
});
it("classifies hang_stacks without hang_index as PROFILER_QUERY_REQUIRED_PARAM_MISSING", async () => {
expectCode(
await captureError(
profilerStackQueryTool.execute(
session({
platform: "ios",
parsedData: { cpuSamples: [], uiHangs: [], cpuHotspots: [], memoryLeaks: [] },
}),
{ device_id: "x", mode: "hang_stacks", top_n: 15 }
)
),
FAILURE_CODES.PROFILER_QUERY_REQUIRED_PARAM_MISSING
);
});
});