Files
yaak-mountain-loop/apps/yaak-proxy/lib/columns.test.ts
T
Gregory SchierandClaude Opus 5.5 80b587f507 Slide columns aside for a dragged one instead of drawing a drop line
While a column is dragged it hides where it is and the columns between it and
where it would land slide by its width, header and cells together, so the gap
shows the drop. One style element carries a rule a column and changes only when
the target does; the rows don't render. On drop the rules go as the order
changes, in the same task, so nothing animates; on Escape the columns slide
home and the column shows again. The drop line and its code are gone.

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

576 lines
23 KiB
TypeScript

import type { Flow, FlowSortKey } from "@yaakapp-internal/proxy-lib";
import { describe, expect, test } from "vite-plus/test";
import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtures/custom_column_sort.json";
import {
addCustomColumn,
addSortKey,
allColumns,
cellFilterTerm,
columnShifts,
type ColumnPrefs,
customPath,
customPathProblem,
customValue,
defaultColumnPrefs,
dropBoundary,
fieldReference,
fieldReferenceChanges,
gridTemplate,
gridWidth,
hideOtherColumns,
moveColumn,
moveColumnBy,
nextSort,
operationOf,
parseColumnPrefs,
removeCustomColumn,
resetOrder,
resetWidth,
resetWidths,
resizeColumn,
showOperationFor,
shownColumns,
sortFlows,
toggleColumn,
toggleSortKey,
urlParts,
} from "./columns";
function flow(number: number, extra: Partial<Flow> = {}): Flow {
return {
id: `fl_${String(number).padStart(3, "0")}`,
number,
startedAt: `2026-11-09T12:00:${String(number).padStart(2, "0")}`,
url: "https://example.com/",
method: "GET",
status: 200,
error: null,
requestSize: 0,
responseSize: 0,
elapsedMs: null,
elapsedHeadersMs: null,
contentType: null,
clientName: null,
clientBundleId: null,
clientPath: null,
clientCwd: null,
clientHttpVersion: "HTTP/1.1",
upstreamHttpVersion: null,
upstreamTlsVersion: null,
remoteAddr: null,
requestHeaders: [],
responseHeaders: [],
grpcService: null,
grpcMethod: null,
graphqlOperation: null,
tunnelled: false,
...extra,
} as Flow;
}
const ids = (prefs: ColumnPrefs) => shownColumns(prefs).map((c) => c.id);
describe("shownColumns", () => {
test("shows every visible column, Query after Path", () => {
expect(ids(defaultColumnPrefs)).toEqual([
"number",
"app",
"method",
"host",
"path",
"query",
"status",
"type",
"size",
"time",
]);
});
test("follows the picker, Path included, but keeps the last column", () => {
let prefs = toggleColumn(defaultColumnPrefs, "remote");
prefs = toggleColumn(prefs, "type");
prefs = toggleColumn(prefs, "path");
expect(ids(prefs)).toContain("remote");
expect(ids(prefs)).not.toContain("type");
expect(ids(prefs)).not.toContain("path");
const alone = hideOtherColumns(defaultColumnPrefs, "host");
expect(ids(alone)).toEqual(["host"]);
expect(toggleColumn(alone, "host")).toBe(alone);
// Where App has nothing to show, the last other column is the last one.
const withApp = hideOtherColumns(defaultColumnPrefs, "app");
const both = toggleColumn(withApp, "host");
expect(toggleColumn(both, "host", ["app"])).toBe(both);
expect(ids(both)).toEqual(["app", "host"]);
expect(shownColumns(both, ["app"]).map((c) => c.id)).toEqual(["host"]);
});
test("lays out every column at a fixed width", () => {
let prefs = resizeColumn(defaultColumnPrefs, "host", 240.4);
prefs = resizeColumn(prefs, "path", 500);
const columns = shownColumns(prefs);
const pad = (w: number) => `calc(${w}px + 0.75rem)`;
expect(gridTemplate(columns)).toBe(
["24px", ...[46, 20, 50, 240, 500, 120, 46, 50, 72, 64].map(pad)].join(" "),
);
expect(gridWidth(columns)).toBe("calc(1232px + 10 * 0.75rem)");
});
});
describe("column preferences", () => {
test("persist through storage and drop what no longer applies", () => {
let prefs = resizeColumn(defaultColumnPrefs, "host", 10);
prefs = toggleColumn(prefs, "number");
prefs = addCustomColumn(prefs, "header.x-cache");
prefs = { ...prefs, sort: [{ column: "status", descending: true }] };
const stored = JSON.parse(JSON.stringify(prefs));
expect(parseColumnPrefs(stored)).toEqual({
visible: { number: false, "field:header.x-cache": true },
widths: { host: 60 },
custom: ["field:header.x-cache"],
order: [],
sort: [{ column: "status", descending: true }],
});
expect(
parseColumnPrefs({
visible: { gone: true, size: "yes" },
widths: { time: -1, size: 90 },
custom: ["field:nope.x", 3, "field:query.page"],
sort: [
{ column: "gone", descending: true },
{ column: "host" },
{ column: "time", descending: false },
],
}),
).toEqual({
visible: {},
widths: { size: 90 },
custom: ["field:query.page"],
order: [],
sort: [{ column: "time", descending: false }],
});
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
expect(resetWidths(prefs).widths).toEqual({});
});
test("a double-click resets one column's width", () => {
let prefs = resizeColumn(defaultColumnPrefs, "host", 300);
prefs = resizeColumn(prefs, "path", 400);
expect(resetWidth(prefs, "path").widths).toEqual({ host: 300 });
});
test("custom columns are added at the end and removed with their settings", () => {
let prefs = addCustomColumn(defaultColumnPrefs, "res.header.x-cache");
prefs = resizeColumn(prefs, "field:res.header.x-cache", 200);
expect(ids(prefs).at(-1)).toBe("field:res.header.x-cache");
prefs = removeCustomColumn(prefs, "field:res.header.x-cache");
expect(prefs).toEqual(defaultColumnPrefs);
});
test("a custom column's sort persists with it and goes when it does", () => {
let prefs = addCustomColumn(defaultColumnPrefs, "header.x-cache");
const sort: FlowSortKey[] = [
{ column: "field:header.x-cache", descending: true },
{ column: "status", descending: false },
];
prefs = { ...prefs, sort };
expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).sort).toEqual(sort);
// A key for a column that is no longer there is dropped
expect(parseColumnPrefs({ ...prefs, custom: [] }).sort).toEqual([sort[1]]);
expect(removeCustomColumn(prefs, "field:header.x-cache").sort).toEqual([sort[1]]);
});
test("Operation shows itself once, unless the person set it", () => {
const grpc = flow(1, { grpcService: "pkg.Greeter", grpcMethod: "SayHello" });
const anonymous = flow(2, { graphqlOperation: "" });
expect(showOperationFor(defaultColumnPrefs, [flow(3), anonymous])).toBe(defaultColumnPrefs);
expect(showOperationFor(defaultColumnPrefs, [grpc]).visible.operation).toBe(true);
const hidden = { ...defaultColumnPrefs, visible: { operation: false } };
expect(showOperationFor(hidden, [grpc])).toBe(hidden);
});
});
describe("column order", () => {
const defaults = ids(defaultColumnPrefs);
test("a column moves to the boundary it was dropped at", () => {
expect(ids(moveColumn(defaultColumnPrefs, "status", 2))).toEqual([
"number",
"app",
"status",
"method",
"host",
"path",
"query",
"type",
"size",
"time",
]);
// Rightwards, before the column at the boundary
expect(ids(moveColumn(defaultColumnPrefs, "method", 5)).slice(2, 6)).toEqual([
"host",
"path",
"method",
"query",
]);
// To either end
expect(ids(moveColumn(defaultColumnPrefs, "time", 0))[0]).toBe("time");
expect(ids(moveColumn(defaultColumnPrefs, "number", defaults.length)).at(-1)).toBe("number");
// The picker lists them in the same order
const moved = moveColumn(defaultColumnPrefs, "time", 0);
expect(allColumns(moved)[0]?.id).toBe("time");
});
test("a drop where the column already is changes nothing", () => {
expect(moveColumn(defaultColumnPrefs, "host", 3)).toBe(defaultColumnPrefs);
expect(moveColumn(defaultColumnPrefs, "host", 4)).toBe(defaultColumnPrefs);
expect(moveColumn(defaultColumnPrefs, "host", -1)).toBe(defaultColumnPrefs);
expect(moveColumn(defaultColumnPrefs, "host", defaults.length + 1)).toBe(defaultColumnPrefs);
// A hidden column has no place among those shown
expect(moveColumn(defaultColumnPrefs, "latency", 0)).toBe(defaultColumnPrefs);
});
test("Move Left and Move Right go one place, and not past the ends", () => {
expect(ids(moveColumnBy(defaultColumnPrefs, "host", -1)).slice(2, 5)).toEqual([
"host",
"method",
"path",
]);
expect(ids(moveColumnBy(defaultColumnPrefs, "host", 1)).slice(3, 6)).toEqual([
"path",
"host",
"query",
]);
expect(moveColumnBy(defaultColumnPrefs, "number", -1)).toBe(defaultColumnPrefs);
expect(moveColumnBy(defaultColumnPrefs, "time", 1)).toBe(defaultColumnPrefs);
// Where App has nothing to show, Number is next to Method
expect(
shownColumns(moveColumnBy(defaultColumnPrefs, "method", -1, ["app"]), ["app"]).map(
(c) => c.id,
)[0],
).toBe("method");
});
test("a hidden column keeps its place and comes back to it", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "status");
prefs = moveColumn(prefs, "host", 0);
expect(ids(prefs)).not.toContain("status");
prefs = toggleColumn(prefs, "status");
expect(ids(prefs)).toEqual([
"host",
"time",
"number",
"app",
"method",
"path",
"query",
"status",
"type",
"size",
]);
});
test("never reordered, a built-in column turned on takes its default place", () => {
// Request Size's default place is before Size
const prefs = toggleColumn(defaultColumnPrefs, "requestSize");
expect(prefs.order).toEqual([]);
expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]);
// Among the built-in ones, so a custom column stays at the end
const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache");
expect(ids(custom).at(-1)).toBe("field:header.x-cache");
const both = toggleColumn(custom, "requestSize");
expect(both.order).toEqual([]);
expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]);
});
test("once reordered, a column turned on goes at the end, unless it had a place", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "requestSize");
expect(ids(prefs).at(-1)).toBe("requestSize");
// Moved, hidden and shown again, it goes back where it was moved to
const before = ids(prefs).indexOf("size");
prefs = moveColumn(prefs, "requestSize", before);
prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize");
expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]);
// A custom column added goes at the end too
expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page");
});
test("a custom column is added at the end and leaves the order when removed", () => {
const moved = moveColumn(defaultColumnPrefs, "size", 0);
let prefs = addCustomColumn(moved, "header.x-cache");
expect(ids(prefs).at(-1)).toBe("field:header.x-cache");
prefs = moveColumn(prefs, "field:header.x-cache", 1);
expect(ids(prefs).slice(0, 2)).toEqual(["size", "field:header.x-cache"]);
// Hidden and added again, it keeps its place
prefs = addCustomColumn(toggleColumn(prefs, "field:header.x-cache"), "header.x-cache");
expect(ids(prefs)[1]).toBe("field:header.x-cache");
prefs = removeCustomColumn(prefs, "field:header.x-cache");
expect(prefs.order).not.toContain("field:header.x-cache");
expect(ids(prefs)).toEqual(ids(moved));
// Added again, it starts over at the end
expect(ids(addCustomColumn(prefs, "header.x-cache")).at(-1)).toBe("field:header.x-cache");
});
test("the order persists, and columns it doesn't list follow it in their default order", () => {
let prefs = addCustomColumn(defaultColumnPrefs, "query.page");
prefs = moveColumn(prefs, "field:query.page", 0);
expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).order).toEqual(prefs.order);
const parsed = parseColumnPrefs({
custom: ["field:query.page"],
order: ["time", "gone", 3, "time", "field:header.x-gone", "field:query.page", "host"],
});
expect(parsed.order).toEqual(["time", "field:query.page", "host"]);
expect(ids(parsed)).toEqual([
"time",
"field:query.page",
"host",
"number",
"app",
"method",
"path",
"query",
"status",
"type",
"size",
]);
expect(parseColumnPrefs({ order: "time" }).order).toEqual([]);
});
test("a drag's target is the boundary nearest the pointer, null in its own slot", () => {
// Four columns 100px wide from x 24, after the mark column; the third is dragged
const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 }));
const at = (x: number) => dropBoundary(x, cells, 2);
// Over the mark column or the first's left half: the first boundary
expect(at(5)).toBe(0);
expect(at(60)).toBe(0);
expect(at(80)).toBe(1);
// The left neighbour's near half, its own column, the right neighbour's near half: staying
expect(at(190)).toBeNull();
expect(at(250)).toBeNull();
expect(at(290)).toBeNull();
expect(at(350)).toBeNull();
// Past the right neighbour's middle, and past the end
expect(at(380)).toBe(4);
expect(at(900)).toBe(4);
// The last column dragged to the end stays
expect(dropBoundary(900, cells, 3)).toBeNull();
});
test("the columns a drag passes slide by its width to make room", () => {
// Five columns; the second, 80px wide, is dragged
expect(columnShifts(5, 1, 80, null)).toEqual([0, 0, 0, 0, 0]);
// Right, to before the fifth: the third and fourth slide left
expect(columnShifts(5, 1, 80, 4)).toEqual([0, 0, -80, -80, 0]);
// Right, to the end: all after it slide left
expect(columnShifts(5, 1, 80, 5)).toEqual([0, 0, -80, -80, -80]);
// Left, to the start: the first slides right
expect(columnShifts(5, 1, 80, 0)).toEqual([80, 0, 0, 0, 0]);
// The fourth, dragged left to before the second: the second and third slide right
expect(columnShifts(5, 3, 60, 1)).toEqual([0, 60, 60, 0, 0]);
});
test("Reset Column Order puts every column back", () => {
let prefs = moveColumn(defaultColumnPrefs, "time", 0);
prefs = toggleColumn(prefs, "latency");
prefs = resetOrder(prefs);
expect(prefs.order).toEqual([]);
expect(ids(prefs)).toEqual([...defaults.slice(0, -1), "time", "latency"]);
});
});
describe("sorting", () => {
const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }];
test("a click sorts by a column alone, reverses it, then goes back to arrival order", () => {
expect(nextSort([], "size")).toEqual(one("size", true));
expect(nextSort([], "host")).toEqual(one("host", false));
expect(nextSort(one("host", false), "host")).toEqual(one("host", true));
expect(nextSort(one("host", true), "host")).toEqual([]);
expect(nextSort(one("size", true), "size")).toEqual(one("size", false));
expect(nextSort(one("size", false), "size")).toEqual([]);
// Another column, or a sort on several, starts over.
expect(nextSort(one("host", true), "size")).toEqual(one("size", true));
const two = [...one("host", false), ...one("size", true)];
expect(nextSort(two, "host")).toEqual(one("host", false));
});
test("Option-click adds a key ascending, reverses it, then takes it out", () => {
let sort = toggleSortKey(one("status", true), "host");
expect(sort).toEqual([...one("status", true), ...one("host", false)]);
sort = toggleSortKey(sort, "host");
expect(sort).toEqual([...one("status", true), ...one("host", true)]);
expect(toggleSortKey(sort, "host")).toEqual(one("status", true));
expect(addSortKey(sort, "status", false)).toEqual([
...one("status", false),
...one("host", true),
]);
expect(addSortKey([], "time", true)).toEqual(one("time", true));
});
test("a custom column sorts on click and Option-click as the others do", () => {
const cache = "field:header.x-cache";
expect(nextSort([], cache)).toEqual(one(cache, false));
expect(nextSort(one(cache, false), cache)).toEqual(one(cache, true));
expect(toggleSortKey(one("status", true), cache)).toEqual([
...one("status", true),
...one(cache, false),
]);
});
// Flows and the order each sort lists them in, shared with
// `a_custom_column_sorts_by_its_value_and_pages` in the lib's `flows.rs`, which checks the
// database lists them in the same order.
test("a custom column sorts as the database does", () => {
const fixture = customSortFixture as {
flows: Partial<Flow>[];
sorts: { sort: FlowSortKey[]; order: number[] }[];
};
const flows = fixture.flows.map((f, i) => flow(i, f));
for (const { sort, order } of fixture.sorts) {
expect(
sortFlows(flows, sort).map((f) => f.number),
JSON.stringify(sort),
).toEqual(order);
}
});
// The same flows and orders as `a_sort_on_several_columns_pages_in_its_order` in the lib's
// `flows.rs`, so the app places a flow where the database would.
const flows = [
flow(0, { url: "https://B.com/x", status: 200 }),
flow(1, { url: "https://a.com/y", status: 404 }),
flow(2, { url: "https://a.com/z", status: null }),
flow(3, { url: "https://c.com/", status: 200 }),
flow(4, { url: "https://a.com/", status: null, error: "boom" }),
flow(5, { url: "https://b.com/q", status: 200 }),
flow(6, { url: "https://A.com/", status: 404 }),
flow(7, { url: "https://c.com/", status: 200 }),
];
const numbers = (sort: FlowSortKey[]) => sortFlows(flows, sort).map((f) => f.number);
test("sorts on several columns as the database does", () => {
const status = [...one("status", true), ...one("host", false)];
expect(numbers(status)).toEqual([1, 6, 0, 5, 3, 7, 4, 2]);
const ascending = [...one("status", false), ...one("host", false)];
expect(numbers(ascending)).toEqual([2, 4, 0, 5, 3, 7, 1, 6]);
expect(numbers(one("query", true))).toEqual([0, 1, 2, 3, 4, 5, 6, 7]);
expect(sortFlows(flows, [])).toBe(flows);
});
test("sizes, times and the rest put flows without a value lowest", () => {
const a = flow(1, { responseSize: 10, elapsedMs: 5, requestSize: 3, elapsedHeadersMs: 2 });
const b = flow(2, { responseSize: 300, elapsedMs: null, requestSize: 0 });
const c = flow(3, { responseSize: 10, elapsedMs: 50, elapsedHeadersMs: 40 });
const d = flow(4, { error: "DNS lookup failed", status: null, responseSize: 99 });
const four = [a, b, c, d];
const by = (column: FlowSortKey["column"], descending: boolean) =>
sortFlows(four, one(column, descending)).map((f) => f.number);
expect(by("size", true)).toEqual([2, 1, 3, 4]);
expect(by("size", false)).toEqual([4, 1, 3, 2]);
expect(by("time", true)).toEqual([3, 1, 2, 4]);
expect(by("latency", true)).toEqual([3, 1, 2, 4]);
expect(by("requestSize", true)).toEqual([1, 2, 3, 4]);
expect(by("number", true)).toEqual([4, 3, 2, 1]);
// Failures sort below every status.
expect(by("status", true)).toEqual([1, 2, 3, 4]);
expect(by("status", false)).toEqual([4, 1, 2, 3]);
});
});
describe("values", () => {
test("splits URLs as the lib does", () => {
expect(urlParts("https://a.com:8080/v1/x?page=2&q=1#top")).toEqual({
authority: "a.com:8080",
path: "/v1/x",
query: "page=2&q=1",
});
expect(urlParts("https://a.com?x")).toEqual({ authority: "a.com", path: "", query: "x" });
expect(urlParts("bank.example.com:443")).toEqual({
authority: "bank.example.com:443",
path: "",
query: null,
});
});
test("reads a custom column's path from what was typed", () => {
expect(customPath("@header.X-Request-Id:")).toBe("header.x-request-id");
expect(customPath("query.page")).toBe("query.page");
expect(customPath(" res.header.x-cache ")).toBe("res.header.x-cache");
expect(customPath("@host")).toBeNull();
expect(customPath("header.")).toBeNull();
expect(customPath("header.a b")).toBeNull();
expect(customPath("query.filter[status]")).toBeNull();
});
test("says tersely why what was typed names no column", () => {
expect(customPathProblem("")).toBeNull();
expect(customPathProblem("@")).toBeNull();
expect(customPathProblem("@req.header.user-agent")).toBeNull();
expect(customPathProblem("@hea")).toBe("Incomplete");
expect(customPathProblem("@res.header")).toBe("Incomplete");
expect(customPathProblem("@header.")).toBe("Missing name");
expect(customPathProblem("@query.a b")).toBe("Invalid name");
expect(customPathProblem("@host")).toBe("Unknown field");
});
test("keeps the Add Column field a reference to a field", () => {
expect(fieldReference("")).toBe("");
expect(fieldReference("@")).toBe("@");
expect(fieldReference("h")).toBe("@h");
expect(fieldReference("header.x-cache")).toBe("@header.x-cache");
expect(fieldReference("@req.header.user-agent:")).toBe("@req.header.user-agent");
expect(fieldReference(" @@Header.X-Cache: ")).toBe("@Header.X-Cache");
expect(fieldReference(":")).toBe("");
expect(fieldReference("@:")).toBe("@");
expect(fieldReferenceChanges("@header.x")).toEqual([]);
expect(fieldReferenceChanges("query.page:")).toEqual([
{ from: 0, to: 0, insert: "@" },
{ from: 10, to: 11, insert: "" },
]);
});
test("a custom column shows the first value, the response's header before the request's", () => {
const f = flow(1, {
url: "https://a.com/?Page=2&page=3",
requestHeaders: [
{ name: "X-Cache", value: "req" },
{ name: "Accept", value: "a" },
{ name: "accept", value: "b" },
],
responseHeaders: [{ name: "x-cache", value: "HIT" }],
});
expect(customValue(f, "header.x-cache")).toBe("HIT");
expect(customValue(f, "req.header.x-cache")).toBe("req");
expect(customValue(f, "header.accept")).toBe("a");
expect(customValue(f, "query.page")).toBe("2");
expect(customValue(f, "query.nope")).toBeNull();
});
test("a cell's filter terms, where its column has a field to match", () => {
const f = flow(1, {
url: "https://api.stripe.com:443/v1/charges?x=1",
status: 404,
clientName: "Google Chrome",
contentType: "application/json; charset=utf-8",
responseHeaders: [{ name: "X-Cache", value: "HIT" }],
});
expect(cellFilterTerm(f, "host", false)).toBe("@host:api.stripe.com");
expect(cellFilterTerm(f, "status", true)).toBe("-@status:404");
expect(cellFilterTerm(f, "app", false)).toBe('@app:"Google Chrome"');
expect(cellFilterTerm(f, "path", false)).toBe("@path:/v1/charges");
expect(cellFilterTerm(f, "type", false)).toBe("@type:application/json");
expect(cellFilterTerm(f, "field:header.x-cache", false)).toBe("@header.x-cache:HIT");
for (const column of ["number", "size", "time", "query", "started"] as const) {
expect(cellFilterTerm(f, column, false)).toBeNull();
}
const grpc = flow(2, { grpcService: "pkg.Greeter", grpcMethod: "SayHello" });
expect(operationOf(grpc)).toBe("pkg.Greeter/SayHello");
expect(cellFilterTerm(grpc, "operation", true)).toBe(
"-(@grpc.service:pkg.Greeter @grpc.method:SayHello)",
);
const graphql = flow(3, { graphqlOperation: "GetUser" });
expect(cellFilterTerm(graphql, "operation", false)).toBe("@graphql.operation:GetUser");
});
});