Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e480efd43a | |||
| 0ae8887f2a | |||
| d3d5a71d09 | |||
| 9529fc9eb7 |
@@ -332,6 +332,105 @@ describe("handleCanvasEvent – WORKSPACE_PROVISIONING", () => {
|
||||
const bPos = lastNodes.find((n) => n.id === "ws-b")!.position;
|
||||
expect(bPos).toEqual({ x: 420, y: 100 }); // idx 1 = (100 + 320, 100)
|
||||
});
|
||||
|
||||
it("uses finalX/finalY from payload when parentId is set and parent exists in store", () => {
|
||||
// Org-import child lands with explicit coords — these are server-computed
|
||||
// parent-relative positions. The handler must trust them verbatim.
|
||||
const parent = makeNode("parent-root", { name: "Root" });
|
||||
const { get, set } = makeStore([parent]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "WORKSPACE_PROVISIONING",
|
||||
workspace_id: "child-org",
|
||||
payload: {
|
||||
name: "Org Child",
|
||||
tier: 2,
|
||||
parent_id: "parent-root",
|
||||
x: 500,
|
||||
y: 300,
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
|
||||
expect(newNodes).toHaveLength(2);
|
||||
const child = newNodes.find((n) => n.id === "child-org")!;
|
||||
|
||||
// Must use the server-provided coords, not grid
|
||||
expect(child.position).toEqual({ x: 500, y: 300 });
|
||||
// Must bind parentId so RF renders it nested inside the parent card
|
||||
expect(child.parentId).toBe("parent-root");
|
||||
expect(child.data.parentId).toBe("parent-root");
|
||||
expect(child.data.name).toBe("Org Child");
|
||||
expect(child.data.status).toBe("provisioning");
|
||||
});
|
||||
|
||||
it("uses grid position when parentId is set but parent is NOT in store yet", () => {
|
||||
// Rare WS-reorder: child event arrives before parent's PROVISIONING event.
|
||||
// Must not crash — uses grid slot as fallback. Parent will reparent
|
||||
// the child when it lands.
|
||||
const { get, set } = makeStore([]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "WORKSPACE_PROVISIONING",
|
||||
workspace_id: "orphan-child",
|
||||
payload: {
|
||||
name: "Orphan",
|
||||
parent_id: "unknown-parent",
|
||||
x: 999,
|
||||
y: 888,
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
|
||||
const child = newNodes.find((n) => n.id === "orphan-child")!;
|
||||
|
||||
// Must NOT use finalX/finalY — parent isn't in store so grid slot is used
|
||||
expect(child.position).not.toEqual({ x: 999, y: 888 });
|
||||
// Grid slot for idx 0: (100, 100)
|
||||
expect(child.position).toEqual({ x: 100, y: 100 });
|
||||
// parentId is NOT set on the node when parent is unknown:
|
||||
// the node will be reparented when the parent eventually lands
|
||||
expect(child.data.parentId).not.toBe("unknown-parent");
|
||||
});
|
||||
|
||||
it("no-op cascade: parent in store but no finalX/Y → grid position, no parentId", () => {
|
||||
// Parent exists but payload has no x/y → must not crash, uses grid slot.
|
||||
// parentId is NOT set because we don't have parent-relative coords.
|
||||
const parent = makeNode("parent-exists");
|
||||
const { get, set } = makeStore([parent]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "WORKSPACE_PROVISIONING",
|
||||
workspace_id: "child-no-coords",
|
||||
payload: {
|
||||
name: "No Coords",
|
||||
parent_id: "parent-exists",
|
||||
// no x or y
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const newNodes = (set.mock.calls[0][0] as { nodes: Node<WorkspaceNodeData>[] }).nodes;
|
||||
const child = newNodes.find((n) => n.id === "child-no-coords")!;
|
||||
|
||||
// Grid slot for idx 0: (100, 100)
|
||||
expect(child.position).toEqual({ x: 100, y: 100 });
|
||||
// parentId stays null (not undefined) when no finalX/Y — server has no
|
||||
// position for this node, and the handler initialises parentId=null
|
||||
expect(child.parentId).toBeUndefined();
|
||||
expect(child.data.parentId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -848,6 +848,374 @@ describe("hydrationError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- growParentsToFitChildren ----------
|
||||
//
|
||||
// growParentsToFitChildren walks every parent node and expands its width/height
|
||||
// so all children fit inside with padding. Collapsed parents are skipped (grow-
|
||||
// only, never shrink). Returns the same array reference when no changes are
|
||||
// needed, a new array when at least one parent grew.
|
||||
//
|
||||
// Constants (from canvas-topology.ts):
|
||||
// CHILD_DEFAULT_WIDTH = 240
|
||||
// CHILD_DEFAULT_HEIGHT = 130
|
||||
// PARENT_SIDE_PADDING = 16
|
||||
// PARENT_BOTTOM_PADDING = 16
|
||||
//
|
||||
// For a child at (childX, childY) with size (childW, childH):
|
||||
// requiredParentW = childX + childW + PARENT_SIDE_PADDING
|
||||
// requiredParentH = childY + childH + PARENT_BOTTOM_PADDING
|
||||
//
|
||||
// Coverage targets:
|
||||
// - Node with no parentId → skipped entirely (returns same node)
|
||||
// - Parent with no children → skipped (kids.length === 0 → returns n)
|
||||
// - Collapsed parent → skipped even when children overflow
|
||||
// - Child fits within existing parent → no-op (requiredW <= currentW && requiredH <= currentH)
|
||||
// - Child overflows parent width → grows width only
|
||||
// - Child overflows parent height → grows height only
|
||||
// - Child overflows both → grows both
|
||||
// - Missing measured.width (falls back to width, then CHILD_DEFAULT_WIDTH)
|
||||
// - Missing measured.height (falls back to height, then CHILD_DEFAULT_HEIGHT)
|
||||
// - Missing parent measured.width (falls back to width, then 0)
|
||||
// - Missing parent measured.height (falls back to height, then 0)
|
||||
// - No change at all → returns same array reference (changed=false path)
|
||||
|
||||
describe("growParentsToFitChildren", () => {
|
||||
it("skips nodes with no parentId (standalone roots)", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "root",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Root", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 200, height: 150 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same array reference (no change needed)
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("skips parent with no children (orphan parentId)", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "orphan",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "nonexistent",
|
||||
data: { name: "Orphan", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 100, height: 100 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same array reference (parentId exists but no children reference it)
|
||||
expect(after).toBe(before);
|
||||
expect(after[0].measured).toEqual({ width: 100, height: 100 });
|
||||
});
|
||||
|
||||
it("skips collapsed parents even when children overflow", () => {
|
||||
// Child at (500, 400) → requires parent 500+240+16=756w, 400+130+16=546h
|
||||
// Parent is collapsed AND tiny — must NOT grow
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: true, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 200, height: 150 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 500, y: 400 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same reference (collapsed → skipped entirely)
|
||||
expect(after).toBe(before);
|
||||
const parent = after.find((n) => n.id === "parent")!;
|
||||
expect(parent.measured).toEqual({ width: 200, height: 150 });
|
||||
});
|
||||
|
||||
it("no-op when child fits within existing parent size", () => {
|
||||
// Child at (0,0) 240x130 → requires 0+240+16=256w, 0+130+16=146h
|
||||
// Parent is exactly 256×146 → fits perfectly
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same array reference (no change needed)
|
||||
expect(after).toBe(before);
|
||||
const parent = after.find((n) => n.id === "parent")!;
|
||||
expect(parent.measured).toEqual({ width: 256, height: 146 });
|
||||
});
|
||||
|
||||
it("grows parent width only when child overflows width but not height", () => {
|
||||
// Child at (100, 0) 240x130 → requires 100+240+16=356w, 0+130+16=146h
|
||||
// Parent is 256×146 → fits height, overflows width → grows to 356×146
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 100, y: 0 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(356); // 100+240+16
|
||||
expect(parent.height).toBe(146); // unchanged
|
||||
});
|
||||
|
||||
it("grows parent height only when child overflows height but not width", () => {
|
||||
// Child at (0, 50) 240x130 → requires 0+240+16=256w, 50+130+16=196h
|
||||
// Parent is 256×146 → fits width, overflows height → grows to 256×196
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 50 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(256); // unchanged
|
||||
expect(parent.height).toBe(196); // 50+130+16
|
||||
});
|
||||
|
||||
it("grows parent in both dimensions when child overflows both", () => {
|
||||
// Child at (200, 100) 240x130 → requires 200+240+16=456w, 100+130+16=246h
|
||||
// Parent is 256×146 → grows to 456×246
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 200, y: 100 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(456); // 200+240+16
|
||||
expect(parent.height).toBe(246); // 100+130+16
|
||||
});
|
||||
|
||||
it("uses CHILD_DEFAULT_WIDTH/HEIGHT when child has no measured or explicit dimensions", () => {
|
||||
// Child with NO measured, NO width/height → falls back to 240×130 defaults
|
||||
// Child at (500, 200) → requires 500+240+16=756w, 200+130+16=346h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 100, height: 100 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 500, y: 200 },
|
||||
parentId: "parent",
|
||||
// No measured, no width/height → uses CHILD_DEFAULT_WIDTH=240, CHILD_DEFAULT_HEIGHT=130
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(756); // 500+240+16
|
||||
expect(parent.height).toBe(346); // 200+130+16
|
||||
});
|
||||
|
||||
it("uses explicit width/height when measured is absent on child", () => {
|
||||
// Child has width/height but NOT measured
|
||||
// Child at (300, 50) with explicit 200×100 → requires 300+200+16=516w, 50+100+16=166h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 200, height: 100 },
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 300, y: 50 },
|
||||
parentId: "parent",
|
||||
width: 200,
|
||||
height: 100,
|
||||
// No measured → falls back to width=200, height=100
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(516); // 300+200+16
|
||||
expect(parent.height).toBe(166); // 50+100+16
|
||||
});
|
||||
|
||||
it("uses measured when present (takes precedence over explicit width/height)", () => {
|
||||
// Child has both measured AND explicit width/height — measured should win
|
||||
// Child at (0,0) measured=240×130 explicit=100×50 → uses measured
|
||||
// Required: 0+240+16=256w, 0+130+16=146h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 256, height: 146 }, // fits exactly
|
||||
},
|
||||
{
|
||||
id: "child",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "parent",
|
||||
width: 100, // ignored (measured present)
|
||||
height: 50, // ignored
|
||||
measured: { width: 240, height: 130 },
|
||||
data: { name: "Child", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const before = useCanvasStore.getState().nodes;
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const after = useCanvasStore.getState().nodes;
|
||||
|
||||
// Same reference (measured fits exactly)
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("multiple children: grows to fit the furthest child in each dimension", () => {
|
||||
// Child 1 at (0, 0) 240×130 → maxRight=240, maxBottom=130
|
||||
// Child 2 at (300, 200) 240×130 → maxRight=540, maxBottom=330
|
||||
// Required: 540+16=556w, 330+16=346h
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "parent",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { name: "Parent", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: null, currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 100, height: 100 },
|
||||
},
|
||||
{
|
||||
id: "child1",
|
||||
type: "workspaceNode",
|
||||
position: { x: 0, y: 0 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child1", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
{
|
||||
id: "child2",
|
||||
type: "workspaceNode",
|
||||
position: { x: 300, y: 200 },
|
||||
parentId: "parent",
|
||||
data: { name: "Child2", status: "online", tier: 1, agentCard: null, activeTasks: 0, collapsed: false, role: "agent", lastErrorRate: 0, lastSampleError: "", url: "", parentId: "parent", currentTask: "", needsRestart: false, runtime: "", budgetLimit: null },
|
||||
measured: { width: 240, height: 130 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useCanvasStore.getState().growParentsToFitChildren();
|
||||
const parent = useCanvasStore.getState().nodes.find((n) => n.id === "parent")!;
|
||||
|
||||
expect(parent.width).toBe(556); // max(0+240, 300+240)+16
|
||||
expect(parent.height).toBe(346); // max(0+130, 200+130)+16
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- ACTIVITY_LOGGED event ----------
|
||||
|
||||
describe("ACTIVITY_LOGGED event", () => {
|
||||
|
||||
@@ -19,15 +19,23 @@ import (
|
||||
|
||||
// allowedRoots are the container paths that the Files API can browse.
|
||||
var allowedRoots = map[string]bool{
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/configs": true,
|
||||
"/workspace": true,
|
||||
"/home": true,
|
||||
"/plugins": true,
|
||||
"/agent-home": true, // Phase 1 stub (RFC internal#425); full implementation to follow
|
||||
}
|
||||
|
||||
// maxUploadFiles limits the number of files in a single import/replace.
|
||||
const maxUploadFiles = 200
|
||||
|
||||
// isAgentHomeStubRequest returns true when the rootPath is /agent-home,
|
||||
// which is a Phase 1 stub (RFC internal#425). Canvas designs against the
|
||||
// shape; the full implementation will follow in a later phase.
|
||||
func isAgentHomeStubRequest(rootPath string) bool {
|
||||
return rootPath == "/agent-home"
|
||||
}
|
||||
|
||||
type TemplatesHandler struct {
|
||||
configsDir string
|
||||
docker *client.Client
|
||||
@@ -218,6 +226,11 @@ func (h *TemplatesHandler) ListFiles(c *gin.Context) {
|
||||
// ?path= — subdirectory to list (relative to root, default: "")
|
||||
// ?depth= — max depth to recurse (default: 1, max: 5)
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
@@ -382,6 +395,11 @@ func (h *TemplatesHandler) ReadFile(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
@@ -495,6 +513,11 @@ func (h *TemplatesHandler) WriteFile(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
@@ -572,6 +595,11 @@ func (h *TemplatesHandler) DeleteFile(c *gin.Context) {
|
||||
|
||||
ctx := c.Request.Context()
|
||||
rootPath := c.DefaultQuery("root", "/configs")
|
||||
// Phase 1 stub — RFC internal#425
|
||||
if isAgentHomeStubRequest(rootPath) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "/agent-home is not yet implemented"})
|
||||
return
|
||||
}
|
||||
if !allowedRoots[rootPath] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "root must be one of: /configs, /workspace, /home, /plugins"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package secrets provides the canonical SSOT for credential-shaped
|
||||
// regex patterns used by:
|
||||
//
|
||||
// - the CI `Secret scan` workflow (.gitea/workflows/secret-scan.yml)
|
||||
// - the runtime's bundled pre-commit hook
|
||||
// (molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh)
|
||||
// - the upcoming Phase 2b docker-exec Files API backend, which has
|
||||
// to refuse to surface files whose path OR content matches a
|
||||
// credential shape (RFC internal#425, Hongming 2026-05-15)
|
||||
//
|
||||
// Before this package, the same regex set lived as duplicate bash
|
||||
// arrays in two unrelated repos; adding a pattern required editing
|
||||
// both, and pattern drift was caught only via secret-scan workflow
|
||||
// failures on PRs that had unrelated changes (#2090-class incident
|
||||
// vector). Centralising in Go makes the Files API the SSOT, with the
|
||||
// YAML + bash arrays generated/asserted from this package so drift
|
||||
// is detected at CI time, not at exfiltration time.
|
||||
//
|
||||
// This file is Phase 2a of the internal#425 RFC. Phase 2b will import
|
||||
// `Patterns` from `template_files_docker_exec.go` to gate
|
||||
// `listFilesViaDockerExec` / `readFileViaDockerExec` against
|
||||
// secret-shaped paths AND content. Until 2b lands, the package has
|
||||
// one consumer: this package's own unit tests, which pin the regex
|
||||
// strings so a refactor that drops or weakens one is caught here.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Pattern is one named credential shape — a human label plus the
|
||||
// compiled regex. The label appears in CI error output ("matched:
|
||||
// github-pat") so an operator can identify the family without seeing
|
||||
// the actual matched bytes (echoing the bytes widens the blast radius
|
||||
// per the secret-scan workflow's recovery prose).
|
||||
type Pattern struct {
|
||||
// Name is a short kebab-case identifier (e.g. "github-pat",
|
||||
// "anthropic-api-key"). Stable across versions — consumers may
|
||||
// switch on it.
|
||||
Name string
|
||||
// Description is a one-line human-readable explanation of what
|
||||
// the pattern matches. Used in CI error messages and the Files
|
||||
// API "<denied: secret-shape>" placeholder rationale.
|
||||
Description string
|
||||
// regexSource is the regex literal in Go-RE2 syntax. Stored as a
|
||||
// string so the slice declaration below stays readable; compiled
|
||||
// once via sync.Once into a *regexp.Regexp.
|
||||
regexSource string
|
||||
}
|
||||
|
||||
// Patterns is the canonical credential-shape regex set.
|
||||
//
|
||||
// Adding a pattern here:
|
||||
//
|
||||
// 1. Add a new Pattern{} entry below with a kebab-case Name, a
|
||||
// one-line Description, and the regex literal. Anchor on a
|
||||
// low-false-positive prefix.
|
||||
// 2. Add a positive + negative test case in patterns_test.go.
|
||||
// 3. Mirror the regex string into:
|
||||
// a. .gitea/workflows/secret-scan.yml SECRET_PATTERNS array
|
||||
// b. molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
|
||||
// (or wait for the codegen target that consumes this slice — TBD
|
||||
// follow-up; tracked in the Phase 2a PR description.)
|
||||
//
|
||||
// The order is: alphabetical within each provider family, families
|
||||
// grouped by ecosystem (GitHub family, AI-provider family, chat
|
||||
// family, cloud family). Keep this stable so diffs are reviewable.
|
||||
var Patterns = []Pattern{
|
||||
// --- GitHub token family ---
|
||||
{
|
||||
Name: "github-pat-classic",
|
||||
Description: "GitHub personal access token (classic)",
|
||||
regexSource: `ghp_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-app-installation-token",
|
||||
Description: "GitHub App installation token (#2090 vector)",
|
||||
regexSource: `ghs_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-user-to-server",
|
||||
Description: "GitHub OAuth user-to-server token",
|
||||
regexSource: `gho_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-user",
|
||||
Description: "GitHub OAuth user token",
|
||||
regexSource: `ghu_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-oauth-refresh",
|
||||
Description: "GitHub OAuth refresh token",
|
||||
regexSource: `ghr_[A-Za-z0-9]{36,}`,
|
||||
},
|
||||
{
|
||||
Name: "github-pat-fine-grained",
|
||||
Description: "GitHub fine-grained personal access token",
|
||||
regexSource: `github_pat_[A-Za-z0-9_]{82,}`,
|
||||
},
|
||||
|
||||
// --- AI-provider API key family ---
|
||||
{
|
||||
Name: "anthropic-api-key",
|
||||
Description: "Anthropic API key",
|
||||
regexSource: `sk-ant-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "openai-project-key",
|
||||
Description: "OpenAI project API key",
|
||||
regexSource: `sk-proj-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "openai-service-account-key",
|
||||
Description: "OpenAI service-account API key",
|
||||
regexSource: `sk-svcacct-[A-Za-z0-9_-]{40,}`,
|
||||
},
|
||||
{
|
||||
Name: "minimax-api-key",
|
||||
Description: "MiniMax API key (F1088 vector)",
|
||||
regexSource: `sk-cp-[A-Za-z0-9_-]{60,}`,
|
||||
},
|
||||
|
||||
// --- Chat-platform token family ---
|
||||
{
|
||||
Name: "slack-token",
|
||||
Description: "Slack token (xoxb/xoxa/xoxp/xoxr/xoxs)",
|
||||
regexSource: `xox[baprs]-[A-Za-z0-9-]{20,}`,
|
||||
},
|
||||
|
||||
// --- Cloud-provider credential family ---
|
||||
{
|
||||
Name: "aws-access-key-id",
|
||||
Description: "AWS access key ID",
|
||||
regexSource: `AKIA[0-9A-Z]{16}`,
|
||||
},
|
||||
{
|
||||
Name: "aws-sts-temp-access-key-id",
|
||||
Description: "AWS STS temporary access key ID",
|
||||
regexSource: `ASIA[0-9A-Z]{16}`,
|
||||
},
|
||||
}
|
||||
|
||||
// compiledOnce protects the lazy build of compiledPatterns. We compile
|
||||
// lazily so package init is cheap; callers pay only on first match
|
||||
// (typically once per workspace-server boot).
|
||||
var (
|
||||
compiledOnce sync.Once
|
||||
compiledPatterns []*compiledPattern
|
||||
compileErr error
|
||||
)
|
||||
|
||||
type compiledPattern struct {
|
||||
Name string
|
||||
Description string
|
||||
Re *regexp.Regexp
|
||||
}
|
||||
|
||||
// compileAll compiles every Pattern.regexSource into a *regexp.Regexp.
|
||||
// Called once via compiledOnce. Any compile failure here is a build
|
||||
// bug (the unit tests assert each regex compiles) — surfacing via
|
||||
// returned error so callers don't panic in request handling.
|
||||
func compileAll() {
|
||||
out := make([]*compiledPattern, 0, len(Patterns))
|
||||
for _, p := range Patterns {
|
||||
re, err := regexp.Compile(p.regexSource)
|
||||
if err != nil {
|
||||
compileErr = fmt.Errorf("secrets: pattern %q failed to compile: %w", p.Name, err)
|
||||
return
|
||||
}
|
||||
out = append(out, &compiledPattern{Name: p.Name, Description: p.Description, Re: re})
|
||||
}
|
||||
compiledPatterns = out
|
||||
}
|
||||
|
||||
// ScanBytes returns a non-nil Match if any pattern matches anywhere
|
||||
// inside b. Returns (nil, nil) on no match. Returns (nil, err) only
|
||||
// if a regex in the package fails to compile — that's a build bug,
|
||||
// not a runtime data issue.
|
||||
//
|
||||
// Match contains the pattern Name + Description so the caller can
|
||||
// emit a path-or-content-denial rationale WITHOUT round-tripping the
|
||||
// matched bytes (which would defeat the purpose). The matched bytes
|
||||
// stay inside this function.
|
||||
//
|
||||
// The Files API Phase 2b backend will call ScanBytes on:
|
||||
//
|
||||
// - the absolute path string (catches a file literally named
|
||||
// `ghs_abc.txt`)
|
||||
// - the file content (catches a credential pasted into a workspace
|
||||
// file by an agent or user — the Files API refuses to surface it
|
||||
// and the canvas renders "<denied: secret-shape>")
|
||||
//
|
||||
// Ordering: patterns are tried in declaration order. First match
|
||||
// wins. This means narrower patterns (e.g. `sk-svcacct-…`) should
|
||||
// appear in `Patterns` before broader ones (`sk-…`) — today there's
|
||||
// no overlap, so order is descriptive only.
|
||||
func ScanBytes(b []byte) (*Match, error) {
|
||||
compiledOnce.Do(compileAll)
|
||||
if compileErr != nil {
|
||||
return nil, compileErr
|
||||
}
|
||||
for _, cp := range compiledPatterns {
|
||||
if cp.Re.Match(b) {
|
||||
return &Match{Name: cp.Name, Description: cp.Description}, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ScanString is the string-input convenience wrapper around ScanBytes.
|
||||
// Identical semantics — the body never copies, []byte(s) is a
|
||||
// zero-copy reinterpret for the regex matcher.
|
||||
func ScanString(s string) (*Match, error) {
|
||||
return ScanBytes([]byte(s))
|
||||
}
|
||||
|
||||
// Match describes which pattern caught a value. Deliberately does
|
||||
// NOT include the matched substring — callers must not echo it.
|
||||
type Match struct {
|
||||
// Name is the pattern's kebab-case identifier (e.g. "github-pat-classic").
|
||||
Name string
|
||||
// Description is the human-readable line for UI / log surfaces.
|
||||
Description string
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEveryPatternCompiles pins that every Pattern.regexSource is a
|
||||
// valid Go-RE2 expression. Without this, a bad regex would silently
|
||||
// disable ScanBytes for everything after it (the lazy compile would
|
||||
// set compileErr and ScanBytes would return that error every call).
|
||||
func TestEveryPatternCompiles(t *testing.T) {
|
||||
for _, p := range Patterns {
|
||||
if p.Name == "" {
|
||||
t.Errorf("pattern with empty Name: regex=%q", p.regexSource)
|
||||
}
|
||||
if p.Description == "" {
|
||||
t.Errorf("pattern %q has empty Description", p.Name)
|
||||
}
|
||||
}
|
||||
// Force compile + check error.
|
||||
if _, err := ScanBytes([]byte("placeholder")); err != nil {
|
||||
t.Fatalf("ScanBytes init failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoDuplicateNames — a duplicate pattern Name would make the
|
||||
// "first match wins" semantics surprising to readers and any caller
|
||||
// switching on Match.Name (none today but adding the guard is cheap).
|
||||
func TestNoDuplicateNames(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
if seen[p.Name] {
|
||||
t.Errorf("duplicate pattern Name: %q", p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownPatternsAllPresent — pins which specific Name values are
|
||||
// expected. A future refactor that renames or removes one without
|
||||
// updating consumers (CI workflow, runtime pre-commit hook, Files
|
||||
// API Phase 2b backend) would silently widen the leak surface.
|
||||
// Failing here forces the rename to be intentional.
|
||||
func TestKnownPatternsAllPresent(t *testing.T) {
|
||||
expected := []string{
|
||||
"github-pat-classic",
|
||||
"github-app-installation-token",
|
||||
"github-oauth-user-to-server",
|
||||
"github-oauth-user",
|
||||
"github-oauth-refresh",
|
||||
"github-pat-fine-grained",
|
||||
"anthropic-api-key",
|
||||
"openai-project-key",
|
||||
"openai-service-account-key",
|
||||
"minimax-api-key",
|
||||
"slack-token",
|
||||
"aws-access-key-id",
|
||||
"aws-sts-temp-access-key-id",
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, p := range Patterns {
|
||||
got[p.Name] = true
|
||||
}
|
||||
for _, want := range expected {
|
||||
if !got[want] {
|
||||
t.Errorf("expected pattern %q missing from Patterns slice", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPositiveMatches — for each pattern, supply a representative
|
||||
// shape and assert ScanBytes returns a Match with the right Name.
|
||||
// These are TEST FIXTURES, not real credentials — each is the
|
||||
// pattern's prefix + a long-enough trailing run of placeholder chars.
|
||||
// `EXAMPLE` is sprinkled in to make grep-finds in CI logs obviously
|
||||
// fake to a human reader (matches saved memory
|
||||
// feedback_assert_exact_not_substring: tighten by Name not body).
|
||||
func TestPositiveMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
fixture string
|
||||
expectedName string
|
||||
}{
|
||||
{"ghp_EXAMPLE111122223333444455556666777788889999", "github-pat-classic"},
|
||||
{"ghs_EXAMPLE111122223333444455556666777788889999", "github-app-installation-token"},
|
||||
{"gho_EXAMPLE111122223333444455556666777788889999", "github-oauth-user-to-server"},
|
||||
{"ghu_EXAMPLE111122223333444455556666777788889999", "github-oauth-user"},
|
||||
{"ghr_EXAMPLE111122223333444455556666777788889999", "github-oauth-refresh"},
|
||||
{"github_pat_EXAMPLE" + strings.Repeat("1", 80), "github-pat-fine-grained"},
|
||||
{"sk-ant-EXAMPLE" + strings.Repeat("1", 40), "anthropic-api-key"},
|
||||
{"sk-proj-EXAMPLE" + strings.Repeat("1", 40), "openai-project-key"},
|
||||
{"sk-svcacct-EXAMPLE" + strings.Repeat("1", 40), "openai-service-account-key"},
|
||||
{"sk-cp-EXAMPLE" + strings.Repeat("1", 60), "minimax-api-key"},
|
||||
{"xoxb-" + strings.Repeat("a", 25), "slack-token"},
|
||||
{"xoxa-" + strings.Repeat("a", 25), "slack-token"},
|
||||
// AWS regex requires [0-9A-Z]{16} — uppercase + digits only.
|
||||
{"AKIA1234567890ABCDEF", "aws-access-key-id"},
|
||||
{"ASIA1234567890ABCDEF", "aws-sts-temp-access-key-id"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expectedName, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(tc.fixture))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", tc.fixture, err)
|
||||
}
|
||||
if m == nil {
|
||||
t.Fatalf("ScanBytes(%q) returned no match — expected %q", tc.fixture, tc.expectedName)
|
||||
}
|
||||
if m.Name != tc.expectedName {
|
||||
t.Errorf("ScanBytes(%q) matched %q; expected %q", tc.fixture, m.Name, tc.expectedName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNegativeShapes — strings that look credential-adjacent but
|
||||
// shouldn't match (too short, wrong prefix, missing trailing bytes).
|
||||
// Failing here means a pattern is too loose, which would generate
|
||||
// false-positive denial in Files API and false-positive workflow
|
||||
// failures in CI.
|
||||
func TestNegativeShapes(t *testing.T) {
|
||||
cases := []string{
|
||||
// Too-short variants — anchored on the length suffix.
|
||||
"ghp_tooshort",
|
||||
"ghs_alsoshort1234",
|
||||
"github_pat_short",
|
||||
"sk-ant-short",
|
||||
"sk-cp-not-enough-bytes-here",
|
||||
// Looks like one of the prefixes but isn't (different letter).
|
||||
"gha_EXAMPLE_thirty_six_or_more_chars_here_xxx",
|
||||
// Slack family — wrong letter after xox.
|
||||
"xoxz-aaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
// AWS-shaped but wrong length suffix.
|
||||
"AKIATOOSHORT",
|
||||
// Empty / whitespace.
|
||||
"",
|
||||
" ",
|
||||
// Plain prose mentioning the prefix as part of a longer word.
|
||||
"see also `ghp_HOWTO.md` in the repo",
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
m, err := ScanBytes([]byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("ScanBytes(%q) errored: %v", c, err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("ScanBytes(%q) unexpectedly matched %q", c, m.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanString_NoOp — sanity-check ScanString is the zero-copy
|
||||
// wrapper around ScanBytes. Without this, a future refactor that
|
||||
// makes ScanString do its own thing (e.g. accidentally normalises
|
||||
// case) would diverge silently.
|
||||
func TestScanString_NoOp(t *testing.T) {
|
||||
in := "ghp_EXAMPLE111122223333444455556666777788889999"
|
||||
m1, err1 := ScanBytes([]byte(in))
|
||||
if err1 != nil {
|
||||
t.Fatalf("ScanBytes errored: %v", err1)
|
||||
}
|
||||
m2, err2 := ScanString(in)
|
||||
if err2 != nil {
|
||||
t.Fatalf("ScanString errored: %v", err2)
|
||||
}
|
||||
if m1 == nil || m2 == nil {
|
||||
t.Fatalf("expected matches; got bytes=%+v string=%+v", m1, m2)
|
||||
}
|
||||
if m1.Name != m2.Name {
|
||||
t.Errorf("ScanString and ScanBytes returned different Names: %q vs %q", m1.Name, m2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatch_NoRoundtrip — assert the Match struct does NOT include
|
||||
// the matched substring as a field. Adding such a field would
|
||||
// regress the "matched bytes never leave ScanBytes" invariant that
|
||||
// makes this package safe to call from log/UI surfaces. This is a
|
||||
// reflection-light contract test — checks the field names statically.
|
||||
func TestMatch_NoRoundtrip(t *testing.T) {
|
||||
var m Match
|
||||
// If someone adds a `Matched string` (or similar) field, this
|
||||
// test reads as the canonical place to update + reconsider.
|
||||
_ = m.Name
|
||||
_ = m.Description
|
||||
// The two-field shape is part of the public contract; new fields
|
||||
// require deliberation about whether they leak the secret value.
|
||||
}
|
||||
|
||||
// TestCompileError verifies that compileAll() sets compileErr and
|
||||
// leaves compiledPatterns nil when a Pattern.regexSource is an invalid
|
||||
// Go-RE2 expression. The unbalanced-paren pattern is a real compile
|
||||
// error that regexp.Compile() rejects.
|
||||
//
|
||||
// We reset compiledOnce between attempts by re-assigning the package
|
||||
// variable directly so each test run starts from a clean slate.
|
||||
func TestCompileError(t *testing.T) {
|
||||
// Reset the sync.Once and error state so this test is fully
|
||||
// deterministic regardless of test execution order.
|
||||
compiledOnce = sync.Once{}
|
||||
compiledPatterns = nil
|
||||
compileErr = nil
|
||||
|
||||
// Swap in an invalid pattern for the duration of this test.
|
||||
// The shadow prevents modifying the global Patterns slice.
|
||||
orig := Patterns
|
||||
Patterns = []Pattern{{Name: "bad", Description: "invalid", regexSource: "(unbalanced"}}
|
||||
compileAll()
|
||||
Patterns = orig
|
||||
|
||||
if compileErr == nil {
|
||||
t.Fatal("compileAll() with invalid regex did not set compileErr")
|
||||
}
|
||||
if compiledPatterns != nil {
|
||||
t.Errorf("compiledPatterns should be nil on compile error; got %d entries", len(compiledPatterns))
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanBytes_CompileErr verifies that ScanBytes returns the
|
||||
// compileErr error (not nil, not a Match) when the lazy compilation
|
||||
// previously failed. This is the "compile failed" branch of the
|
||||
// ScanBytes function, distinct from the "compiled ok, no match" branch
|
||||
// (nil, nil) and the "compiled ok, match" branch (Match, nil).
|
||||
//
|
||||
// compiledOnce and compileErr must already be set from a prior failed
|
||||
// compile attempt. We reset compiledOnce and deliberately trigger a
|
||||
// compile failure first so this test is self-contained.
|
||||
func TestScanBytes_CompileErr(t *testing.T) {
|
||||
// Force a compile failure so compileErr is populated.
|
||||
compiledOnce = sync.Once{}
|
||||
compiledPatterns = nil
|
||||
compileErr = nil
|
||||
|
||||
orig := Patterns
|
||||
Patterns = []Pattern{{Name: "bad2", Description: "bad2", regexSource: "[unclosed"}}
|
||||
compileAll()
|
||||
Patterns = orig
|
||||
|
||||
if compileErr == nil {
|
||||
t.Fatal("precondition failed: compileErr must be set before TestScanBytes_CompileErr")
|
||||
}
|
||||
|
||||
// ScanBytes should propagate compileErr, not return a match.
|
||||
m, err := ScanBytes([]byte("some content"))
|
||||
if err == nil {
|
||||
t.Fatal("ScanBytes returned nil error when compileErr is set")
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("ScanBytes should return nil Match on compile error; got %+v", m)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user