Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/agent-forge/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ test('the page has landmarks, an h1 and a live run status', async ({ page }) =>
await expect(page.getByRole('navigation', { name: 'Primary' })).toHaveCount(1);
await expect(page.getByRole('navigation', { name: 'Agents and node palette' })).toHaveCount(1);
await expect(page.getByRole('main')).toHaveCount(1);
// Nothing is selected on load, so the Inspector starts collapsed; its toggle opens it from the keyboard.
await expect(page.getByRole('complementary', { name: 'Inspector' })).toHaveCount(0);
const showInspector = page.getByRole('button', { name: 'Show inspector' });
await expect(showInspector).toHaveAttribute('aria-expanded', 'false');
await showInspector.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('complementary', { name: 'Inspector' })).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Hide inspector' })).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Agent Forge');
await expect(page.locator('.topbar [role="status"][aria-live="polite"]')).toContainText('idle');
});
Expand Down Expand Up @@ -119,6 +126,16 @@ test('Ctrl+K opens the command palette; ? lists the shortcuts', async ({ page })

/** Run axe (WCAG 2 A/AA) on the page as it stands; fail on any serious/critical violation, naming the rule and its targets. */
async function expectNoSeriousAxeViolations(page: Page, label: string) {
// Let entrance animations (a dialog's fade-in) land first: mid-fade text blends with the scrim and
// reads as low contrast. Infinite ones (skeleton shimmer, running pulse) never finish, so they are skipped.
await page.evaluate(() =>
Promise.all(
document
.getAnimations()
.filter((a) => a.effect?.getComputedTiming().iterations !== Infinity)
.map((a) => a.finished.catch(() => undefined))
)
);
const { violations } = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
const serious = violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
const report = serious.map((v) => `${v.id} (${v.impact}): ${v.nodes.map((n) => n.target.join(' ')).join(', ')}`);
Expand Down
28 changes: 25 additions & 3 deletions apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,34 @@ test('agents x: a graph with a node selected', async ({ page }) => {
await expect.poll(triggerInPane).toBe(true);
// An edge can be selected (drawn in accent); selecting a node then moves the selection.
const edge = page.locator('.react-flow__edge').nth(1);
await edge.locator('.react-flow__edge-interaction').click({ force: true });
// Ports sit level with each card's header, so a pipeline edge is a flat line whose box has no height
// (Playwright calls it off-screen): click the middle of its path instead.
const mid = await edge.locator('.react-flow__edge-interaction').evaluate((el) => {
const path = el as SVGPathElement;
const point = path.getPointAtLength(path.getTotalLength() / 2);
const m = path.getScreenCTM()!;
return { x: point.x * m.a + point.y * m.c + m.e, y: point.x * m.b + point.y * m.d + m.f };
});
await page.mouse.click(mid.x, mid.y);
await expect(edge).toHaveClass(/selected/);
await page.locator('[data-node-type="tool"]').click();
await expect(page.locator('.react-flow__node.selected [data-node-type="tool"]')).toBeVisible();
await shoot(page, 'x-agents-selected');
});

test('agents x: the default graph on load at 1280, 1440, 1920 and 860 wide', async ({ page }) => {
const name = `fit-${Date.now()}`;
await newAgent(page, name, 'support-bot');
for (const [width, height] of [[1280, 800], [1440, 900], [1920, 1080], [860, 800]] as const) {
// Reopened at each size, so every shot is the on-load fit (nothing selected, Inspector collapsed).
await page.setViewportSize({ width, height });
await page.reload();
await expect(page.locator('.crumbs b')).toHaveText(name);
await expect(page.locator('.canvas-wrap[data-fitting]')).toHaveCount(0);
await shoot(page, `x-agents-fit-${width}`);
}
});

test('agents x: a run in progress', async ({ page }) => {
const sockets: WebSocketRoute[] = [];
const heardFromServer = new Set<WebSocketRoute>();
Expand Down Expand Up @@ -659,10 +680,11 @@ test('agents x: a run in progress', async ({ page }) => {
await expect(llm).toHaveClass(/selected/);
const rings = await llm.evaluate((el) => {
const s = getComputedStyle(el);
return { outline: s.outlineStyle, shadow: s.boxShadow, pulse: getComputedStyle(el, '::after').animationName };
return { outline: s.outlineStyle, pulse: getComputedStyle(el, '::after').animationName };
});
expect(rings.outline).toBe('solid'); // breakpoint
expect(rings.shadow).toMatch(/0px 0px 0px 3px/); // selected --ring
// Nothing is selected on load, so the click above just selected it: let the ring's transition land.
await expect.poll(() => llm.evaluate((el) => getComputedStyle(el).boxShadow)).toMatch(/0px 0px 0px 3px/); // selected --ring
expect(rings.pulse).toBe('rf-node-running'); // running, on its own layer
await shoot(page, 'x-agents-running');
});
Expand Down
92 changes: 92 additions & 0 deletions apps/agent-forge/e2e/narrow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ test('at 1280px the three-column layout has no drawer toggles', async ({ page })
await open(page, 1280, 800);
expect(await horizontalOverflow(page)).toBe(0);
await expect(page.getByRole('navigation', { name: 'Agents and node palette' })).toBeVisible();
// The docked Inspector starts collapsed (nothing selected) and opens from its canvas-edge toggle.
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden();
await page.getByRole('button', { name: 'Show inspector' }).click();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible();
await expect(page.getByRole('button', { name: 'More actions' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible();
Expand All @@ -99,3 +102,92 @@ test('#/playground at 375px stacks into one column with nothing scrolling sidewa
expect(box && box.x >= 0 && box.x + box.width <= 375, `${name} is on-screen`).toBe(true);
}
});

/** Creates a saved support-bot agent (trigger -> llm -> tool -> output) and waits for its first fit. */
async function createSupportBot(page: Page, name: string) {
await page.getByRole('button', { name: 'New agent', exact: true }).click();
await page.locator('#new-agent-name').fill(name);
await page.locator('#new-agent-template').selectOption('support-bot');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await expect(page.locator('.crumbs b')).toHaveText(name);
await expect(page.locator('[data-node-type="output"]')).toBeVisible();
await expect(page.locator('.canvas-wrap[data-fitting]')).toHaveCount(0);
}

/** Every node card inside the canvas pane, and each title's on-screen size (computed font-size x viewport zoom). */
async function canvasFit(page: Page) {
return page.evaluate(() => {
const pane = document.querySelector('.react-flow')!.getBoundingClientRect();
const viewport = document.querySelector<HTMLElement>('.react-flow__viewport')!;
const zoom = new DOMMatrix(getComputedStyle(viewport).transform).a;
const cards = [...document.querySelectorAll<HTMLElement>('.rf-node')];
return {
count: cards.length,
outside: cards
.filter((el) => {
const r = el.getBoundingClientRect();
return r.left < pane.left || r.right > pane.right || r.top < pane.top || r.bottom > pane.bottom;
})
.map((el) => el.dataset.nodeType),
titlePx: Math.min(...cards.map((el) => parseFloat(getComputedStyle(el.querySelector('.rf-node-title')!).fontSize) * zoom)),
};
});
}

test('at 1280x800 the default graph opens whole and legible, and selecting a node keeps it in view', async ({ page }) => {
await open(page, 1280, 800);
await createSupportBot(page, `fit-${Date.now()}`);
// Measurement guard: all four nodes inside the pane, titles at >= 10px on screen.
await expect.poll(async () => (await canvasFit(page)).outside).toEqual([]);
const fit = await canvasFit(page);
expect(fit.count).toBe(4);
expect(fit.titlePx).toBeGreaterThanOrEqual(10);

// Selecting the right-most node opens the Inspector, and the canvas pans so the node stays visible.
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden();
await page.locator('[data-node-type="output"]').click();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible();
const outputInPane = () =>
page.locator('[data-node-type="output"]').evaluate((el) => {
const node = el.getBoundingClientRect();
const pane = el.closest('.react-flow')!.getBoundingClientRect();
return node.left >= pane.left && node.right <= pane.right;
});
await expect.poll(outputInPane).toBe(true);

// Clicking the empty pane deselects: the Inspector folds away again.
await page.locator('.react-flow__pane').click({ position: { x: 300, y: 60 } });
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden();
});

test('an explicit Show inspector is remembered across reloads; Hide folds it back', async ({ page }) => {
await open(page, 1280, 800);
await page.getByRole('button', { name: 'Show inspector' }).click();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible();
await page.reload();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeVisible();
await page.getByRole('button', { name: 'Hide inspector' }).click();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden();
await page.reload();
await expect(page.getByRole('button', { name: 'Show inspector' })).toBeVisible();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden();
});

for (const [width, height] of [
[1440, 900],
[1920, 1080],
[860, 800],
] as const) {
test(`at ${width}x${height} the default graph opens whole and legible`, async ({ page }) => {
// Created at 1280 (below 860 the rail is a drawer), then reopened at the target size.
await open(page, 1280, 800);
const name = `fit-${width}-${Date.now()}`;
await createSupportBot(page, name);
await page.setViewportSize({ width, height });
await page.reload();
await expect(page.locator('.crumbs b')).toHaveText(name);
await expect(page.locator('.canvas-wrap[data-fitting]')).toHaveCount(0);
await expect.poll(async () => (await canvasFit(page)).outside).toEqual([]);
expect((await canvasFit(page)).titlePx).toBeGreaterThanOrEqual(10);
});
}
2 changes: 1 addition & 1 deletion apps/agent-forge/src/canvas/AgentNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { StatusPill } from '../ui/StatusPill';
import { NODE_KINDS } from './nodeKinds';

/**
* Custom ReactFlow node renderer for every `AgentGraphNodeType`: a 240px card
* Custom ReactFlow node renderer for every `AgentGraphNodeType`: a 208px card
* whose header carries a kind icon tile, the node's name and its run status
* pill; a meta row (an LLM node's `provider/model` chip and tool count); a
* body of key/value config rows read straight from the node's
Expand Down
6 changes: 3 additions & 3 deletions apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import { nextTabId, tabIds } from '../../components/tabs';

describe('viewportCentrePosition', () => {
it('centres a node under the middle of an unpanned, unzoomed pane', () => {
expect(viewportCentrePosition([0, 0, 1], 800, 600)).toEqual({ x: 280, y: 260 });
expect(viewportCentrePosition([0, 0, 1], 800, 600)).toEqual({ x: 296, y: 260 });
});

it('accounts for pan and zoom', () => {
// Panned 100px right/50px down at 2x: the pane centre (400, 300) is flow (150, 125).
expect(viewportCentrePosition([100, 50, 2], 800, 600)).toEqual({ x: 150 - 120, y: 125 - 40 });
expect(viewportCentrePosition([100, 50, 2], 800, 600)).toEqual({ x: 150 - 104, y: 125 - 40 });
});

it('survives a zero zoom before the pane has measured', () => {
expect(viewportCentrePosition([0, 0, 0], 0, 0)).toEqual({ x: -120, y: -40 });
expect(viewportCentrePosition([0, 0, 0], 0, 0)).toEqual({ x: -104, y: -40 });
});
});

Expand Down
6 changes: 4 additions & 2 deletions apps/agent-forge/src/canvas/layout.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import dagre from 'dagre';
import type { AgentGraphSpec } from '../graph/types';

const NODE_WIDTH = 240;
/** `.rf-node`'s width (agents.css). */
const NODE_WIDTH = 208;
const NODE_HEIGHT = 96;

/**
Expand All @@ -16,7 +17,8 @@ export function autoLayout(graph: AgentGraphSpec): AgentGraphSpec {
if (graph.nodes.length === 0) return graph;

const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 90 });
// ranksep 48: the same 256px column pitch specToGraph.ts lays a fresh graph out on.
g.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 48 });
g.setDefaultEdgeLabel(() => ({}));

for (const node of graph.nodes) {
Expand Down
2 changes: 1 addition & 1 deletion apps/agent-forge/src/canvas/paletteActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { HOOK_TEMPLATES, type HookTemplate } from '../hooks/hookTemplates';
import type { AgentNodeHookPhase, GraphPosition } from '../graph/types';

/** Approximate rendered size of an `.rf-node` (screens/agents/agents.css), used to centre a newly added node. */
const NODE_SIZE = { width: 240, height: 80 };
const NODE_SIZE = { width: 208, height: 80 };

/**
* Where a palette item added from the keyboard (or a click)
Expand Down
Loading
Loading