Compare commits

...

4 Commits

Author SHA1 Message Date
fullstack-engineer e480efd43a test(secrets): 100% coverage — compileAll + ScanBytes error-path tests
Adds two tests to close the coverage gaps flagged post-merge of PR #1255
(issue #1269):

- TestCompileError: injects an invalid regex ("(unbalanced") into
  Patterns via package-level shadow, calls compileAll() directly, and
  asserts compileErr is set and compiledPatterns is nil.

- TestScanBytes_CompileErr: forces a compile failure first (invalid
  regex "[unclosed"), then calls ScanBytes and asserts it returns
  (nil, compileErr) — verifying the "compile failed" error-return
  branch that the happy-path tests never exercise.

Both tests reset compiledOnce between attempts so each is self-contained
and deterministic regardless of test execution order.

Coverage: 100.0% of statements.

Refs: #1269

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 01:12:11 +00:00
fullstack-engineer 0ae8887f2a feat(files): Phase 1 /agent-home stub for Files API (RFC internal#425)
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / detect-changes (pull_request) Waiting to run
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
publish-runtime-autobump / pr-validate (pull_request) Waiting to run
Runtime PR-Built Compatibility / detect-changes (pull_request) Waiting to run
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Secret scan / Scan diff for credential-shaped strings (pull_request) Waiting to run
gate-check-v3 / gate-check (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
security-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
E2E API Smoke Test / detect-changes (pull_request) Successful in 2m50s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 2m14s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 38s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request) Successful in 53s
CI / Platform (Go) (pull_request) Failing after 26m36s
CI / Detect changes (pull_request) Has been cancelled
CI / all-required (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
CI / Canvas (Next.js) (pull_request) Has been cancelled
CI / Shellcheck (E2E scripts) (pull_request) Has been cancelled
CI / Python Lint & Test (pull_request) Has been cancelled
Phase 1 stub so canvas can design against the shape without a full
implementation in place.

- Add /agent-home to allowedRoots (templates.go:21)
- Add isAgentHomeStubRequest() helper function
- Short-circuit all four verbs (ListFiles/ReadFile/WriteFile/DeleteFile)
  to 501 Not Implemented when root=/agent-home

RFC internal#425 must NOT be closed on this commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:13:20 +00:00
fullstack-engineer d3d5a71d09 test(canvas): add WORKSPACE_PROVISIONING parentId+coord coverage (3 cases)
CI / all-required (pull_request) Blocked by required conditions
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
audit-force-merge / audit (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 27s
CI / Detect changes (pull_request) Successful in 1m10s
Harness Replays / detect-changes (pull_request) Successful in 47s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m37s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 53s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 1m33s
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Successful in 2m16s
gate-check-v3 / gate-check (pull_request) Successful in 46s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 49s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m21s
sop-tier-check / tier-check (pull_request) Successful in 39s
sop-checklist / all-items-acked (pull_request) Successful in 47s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m41s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 2m17s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 3m39s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 3m16s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 3m31s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 31s
Harness Replays / Harness Replays (pull_request) Successful in 13s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 25s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 18m35s
CI / Platform (Go) (pull_request) Failing after 19m22s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 2m28s
CI / Python Lint & Test (pull_request) Successful in 7m47s
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
qa-review / approved (pull_request) Refired via /qa-recheck by unknown
security-review / approved (pull_request) Refired via /security-recheck by unknown
Adds three tests that cover the conditional branching in the
WORKSPACE_PROVISIONING handler:
1. finalX/finalY + parent in store → trusts server coords verbatim,
   sets parentId so the node renders nested inside the parent card.
2. parent_id present but parent NOT in store (WS-reorder race) →
   falls back to grid slot; does not crash; parentId not set.
3. parent in store but no x/y in payload → grid slot; parentId
   stays null (not undefined) since the server has no position.

None of these were previously exercised in the test suite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:19:23 +00:00
fullstack-engineer 9529fc9eb7 test(canvas): add growParentsToFitChildren unit tests (11 cases)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 14s
Harness Replays / detect-changes (pull_request) Successful in 22s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 24s
gate-check-v3 / gate-check (pull_request) Successful in 27s
qa-review / approved (pull_request) Successful in 29s
security-review / approved (pull_request) Successful in 24s
Harness Replays / Harness Replays (pull_request) Successful in 6s
sop-checklist / all-items-acked (pull_request) Successful in 18s
sop-tier-check / tier-check (pull_request) Successful in 14s
CI / Detect changes (pull_request) Successful in 57s
E2E API Smoke Test / detect-changes (pull_request) Successful in 55s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 56s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 51s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 9s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 10s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m23s
CI / Platform (Go) (pull_request) Failing after 13m14s
CI / Canvas (Next.js) (pull_request) Successful in 13m33s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 3s
Covers canvas.ts growParentsToFitChildren store action:
- Standalone root with no parentId → skipped, same reference
- Orphan parentId (no children reference it) → skipped, same reference
- Collapsed parent with overflowing children → skipped entirely
- Child fits exactly within parent → no-op, same reference
- Child overflows width only → grows width, height unchanged
- Child overflows height only → grows height, width unchanged
- Child overflows both dimensions → grows both
- Child with no measured/width/height → uses CHILD_DEFAULT 240×130
- Child with explicit width/height (no measured) → uses explicit dims
- Child with both measured and explicit → measured takes precedence
- Multiple children → grows to fit furthest extent in each dimension

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-05-15 21:25:19 +00:00
5 changed files with 978 additions and 4 deletions
@@ -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();
});
});
// ---------------------------------------------------------------------------
+368
View File
@@ -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)
}
}