Files
yaak-mountain-loop/apps/yaak-proxy/lib/sidebarTree.test.ts
T
Gregory SchierandClaude Opus 5.5 13ef9c60f1 Fix review findings in the flow list polish
Search actions keep the editor focused so a wrapped box does not collapse
under the click. Header scrolls from focus move the rows too. Timeline
details always show their tooltip. A sidebar row shared by same-named
folders or programs in different places offers no path rather than an
arbitrary one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 10:54:09 -07:00

299 lines
11 KiB
TypeScript

import type { Flow } from "@yaakapp-internal/proxy-lib";
import type { TreeNode } from "@yaakapp-internal/ui";
import { describe, expect, test } from "vite-plus/test";
import { parseQuery } from "@yaakapp-internal/query";
import {
buildSidebarTree,
itemsById,
queryInText,
type SidebarItem,
selectionText,
} from "./sidebarTree";
function flow(url: string, extra: Partial<Flow> = {}): Flow {
return {
url,
method: "GET",
clientName: null,
clientBundleId: null,
clientPath: null,
clientCwd: null,
...extra,
} as Flow;
}
const slack = { clientName: "Slack", clientBundleId: "com.slack", clientPath: "/A/Slack.app" };
const node = { clientName: "node", clientPath: "/bin/node", clientCwd: "/src/fieldwork" };
const daemon = { clientName: "trustd", clientPath: "/usr/libexec/trustd" };
const flows = [
flow("https://api.github.com/repos/yaak", node),
flow("https://github.com/login", node),
flow("https://edgeapi.slack.com/cache", slack),
flow("https://ocsp.apple.com/ocsp", daemon),
];
function shape(n: TreeNode<SidebarItem>, depth = 0): string[] {
const { label, sublabel, count, countLabel } = n.item;
const line = `${" ".repeat(depth)}${label}${sublabel ? ` (${sublabel})` : ""} ${countLabel ?? count}`;
return [line, ...(n.children ?? []).flatMap((c) => shape(c, depth + 1))];
}
describe("buildSidebarTree", () => {
const root = buildSidebarTree(flows, 40);
test("groups apps, programs by folder, sites and System", () => {
expect((root.children ?? []).flatMap((c) => shape(c))).toEqual([
"All traffic 40",
"Apps 3",
" node (fieldwork) 2",
" github.com 2",
" api.github.com 1",
" /repos 1",
" /yaak 1",
" github.com 1",
" /login 1",
" Slack 1",
" slack.com 1",
" edgeapi.slack.com 1",
" /cache 1",
"Domains 4 hosts",
" apple.com 1",
" ocsp.apple.com 1",
" /ocsp 1",
" github.com 2",
" api.github.com 1",
" /repos 1",
" /yaak 1",
" github.com 1",
" /login 1",
" slack.com 1",
" edgeapi.slack.com 1",
" /cache 1",
"System 1",
" trustd 1",
" apple.com 1",
" ocsp.apple.com 1",
" /ocsp 1",
]);
});
test("gives each row the query that selects its flows", () => {
const items = itemsById(root);
const query = (id: string) => items.get(id)?.query;
expect(query("all")).toBe("");
expect(query("app:fieldwork")).toBe("@app:fieldwork");
// A path row takes in what is under the path.
expect(query("app:fieldwork|path:api.github.com/repos")).toBe(
"@app:fieldwork @host:api.github.com @path:/repos/*",
);
expect(query("site:github.com")).toBe("@host:*.github.com");
expect(query("system")).toBe("@is:system");
// A daemon's rows show its flows while System is hidden from All traffic.
expect(query("app:trustd")).toBe("@is:system @app:trustd");
expect(query("app:trustd|host:ocsp.apple.com")).toBe(
"@is:system @app:trustd @host:ocsp.apple.com",
);
// What each writes reads back as the terms it wrote.
for (const item of items.values()) {
if (item.kind === "group" || item.kind === "all") continue;
const parsed = parseQuery(item.query);
expect(parsed, item.id).toBeTruthy();
expect(parsed?.type, item.id).not.toBe("Or");
}
});
});
describe("programs outside an app", () => {
const claude = (cwd: string) => ({
clientName: "claude",
clientPath: "/usr/local/bin/claude",
clientCwd: cwd,
});
const python = {
clientName: "python3.12",
clientPath: "/usr/bin/python3.12",
clientCwd: "/src/ml",
};
const curl = { clientName: "curl", clientPath: "/usr/bin/curl", clientCwd: null };
const root = buildSidebarTree(
[
flow("https://api.anthropic.com/v1/messages", claude("/src/yaak")),
flow("https://api.anthropic.com/v1/models", claude("/src/fieldwork")),
flow("https://statsig.anthropic.com/v1", claude("/src/yaak")),
flow("https://pypi.org/simple", python),
flow("https://example.com/", curl),
flow("https://api.github.com/repos", node),
],
6,
);
const items = itemsById(root);
const apps = (root.children ?? []).find((c) => c.item.id === "apps");
test("one row per program, its working directories one level down above the hosts", () => {
expect(shape(apps as TreeNode<SidebarItem>).slice(0, 12)).toEqual([
"Apps 6",
" claude 3",
" fieldwork 1",
" anthropic.com 1",
" api.anthropic.com 1",
" /v1 1",
" /models 1",
" yaak 2",
" anthropic.com 2",
" api.anthropic.com 1",
" /v1 1",
" /messages 1",
]);
});
test("generic runtimes keep their folder in the row's name", () => {
const labels = (apps?.children ?? []).map((c) => [c.item.label, c.item.sublabel ?? null]);
expect(labels).toEqual([
["claude", null],
["curl", null],
["node", "fieldwork"],
["python3.12", "ml"],
]);
// A program with no working directory has its hosts straight under it.
expect(items.get("app:curl|site:example.com")?.kind).toBe("site");
});
test("folder rows carry their full path, and programs their executable", () => {
expect(items.get("app:claude|cwd:yaak")?.path).toBe("/src/yaak");
expect(items.get("app:claude|cwd:fieldwork")?.path).toBe("/src/fieldwork");
expect(items.get("app:claude")?.path).toBe("/usr/local/bin/claude");
// Hosts and paths under a folder are not on disk.
expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.path).toBeUndefined();
});
test("a folder row selects that program in that folder", () => {
const query = items.get("app:claude|cwd:yaak")?.query ?? "";
expect(query).toBe("@app:claude @cwd:yaak");
expect(items.get("app:claude|cwd:yaak|host:statsig.anthropic.com")?.query).toBe(
"@app:claude @cwd:yaak @host:statsig.anthropic.com",
);
expect(parseQuery(query)?.type).toBe("And");
});
});
test("a row shared by folders or programs in different places names no path", () => {
const tool = { clientName: "claude", clientPath: "/usr/local/bin/claude" };
const items = itemsById(
buildSidebarTree(
[
flow("https://a.com/", { ...tool, clientCwd: "/src/yaak" }),
flow("https://b.com/", { ...tool, clientCwd: "/worktrees/123/yaak" }),
flow("https://c.com/", { ...tool, clientPath: "/opt/bin/claude", clientCwd: "/src/ml" }),
],
3,
),
);
expect(items.get("app:claude|cwd:yaak")?.kind).toBe("folder");
expect(items.get("app:claude|cwd:yaak")?.path).toBeUndefined();
expect(items.get("app:claude|cwd:ml")?.path).toBe("/src/ml");
expect(items.get("app:claude")?.path).toBeUndefined();
});
test("an app in a bundle has no path to reveal", () => {
const items = itemsById(buildSidebarTree([flow("https://slack.com/", slack)], 1));
expect(items.get("app:Slack")?.path).toBeUndefined();
});
describe("decrypting from the sidebar", () => {
const tunnel = flow("https://bank.example.com", {
...slack,
method: "CONNECT",
tunnelled: true,
});
const root = buildSidebarTree([...flows, tunnel], 5);
const items = itemsById(root);
test("Not decrypted follows All traffic while there are tunnelled flows", () => {
expect((root.children ?? []).slice(0, 2).map((c) => shape(c)[0])).toEqual([
"All traffic 5",
"Not decrypted 1",
]);
expect(items.get("tunnelled")?.query).toBe("@is:tunnelled");
expect(itemsById(buildSidebarTree(flows, 4)).has("tunnelled")).toBe(false);
});
test("hosts, sites and apps name what their menu's Decrypt covers", () => {
expect(items.get("host:bank.example.com")?.decrypt).toEqual({
kind: "host",
host: "bank.example.com",
});
expect(items.get("site:example.com")?.decrypt).toEqual({ kind: "site", site: "example.com" });
expect(items.get("app:Slack|host:edgeapi.slack.com")?.decrypt).toEqual({
kind: "host",
host: "edgeapi.slack.com",
});
expect(items.get("app:Slack")?.decrypt).toEqual({
kind: "app",
name: "Slack",
bundleId: "com.slack",
});
expect(items.get("path:api.github.com/repos")?.decrypt).toBeUndefined();
expect(items.get("domains")?.decrypt).toBeUndefined();
});
});
describe("selection and the search text", () => {
test("selects rows under different apps as groups of their own", () => {
const items = itemsById(buildSidebarTree(flows, 4));
const pick = (id: string) => items.get(id) as SidebarItem;
const text = selectionText([
pick("app:fieldwork|host:api.github.com"),
pick("app:Slack|site:slack.com"),
]);
expect(text).toBe("@app:fieldwork @host:api.github.com OR @app:Slack @host:*.slack.com");
// Two groups, each judged whole
const parsed = parseQuery(text);
expect(parsed?.type).toBe("Or");
expect(parsed?.type === "Or" ? parsed.children.map((c) => c.type) : []).toEqual(["And", "And"]);
});
test("writes the selected rows' queries, OR'd, or nothing for All traffic", () => {
const a = { query: "@app:Slack" } as SidebarItem;
const b = { query: "@host:*.github.com" } as SidebarItem;
const all = { query: "" } as SidebarItem;
expect(selectionText([a])).toBe("@app:Slack");
expect(selectionText([a, b])).toBe("@app:Slack OR @host:*.github.com");
expect(selectionText([a, all])).toBe("");
});
test("keeps a row selected while every term of its query is in the text", () => {
expect(queryInText("@app:Slack", "@app:Slack card")).toBe(true);
expect(queryInText("@app:slack", "@app:Slack")).toBe(true);
expect(queryInText("@app:Slack @host:x.com", "@app:Slack")).toBe(false);
expect(queryInText("", "")).toBe(true);
expect(queryInText("", "card")).toBe(false);
});
});
describe("saved searches", () => {
test("follow All traffic, each a row with its count that writes its query", () => {
const saved = [
{ id: "sq_1", name: "Errors", query: "@status:>=400", count: 12 },
{ id: "sq_2", name: "Stripe", query: "@host:*.stripe.com", count: 0 },
];
const root = buildSidebarTree(flows, 4, saved);
expect((root.children ?? []).slice(0, 2).flatMap((c) => shape(c))).toEqual([
"All traffic 4",
"Saved 0",
" Errors 12",
" Stripe 0",
]);
const items = itemsById(root);
expect(items.get("saved:sq_1")).toMatchObject({
kind: "saved",
query: "@status:>=400",
savedId: "sq_1",
});
// Rows, not folders
const group = (root.children ?? []).find((c) => c.item.id === "saved");
expect(group?.children?.every((c) => c.children == null)).toBe(true);
expect(itemsById(buildSidebarTree(flows, 4)).has("saved")).toBe(false);
});
});