Files
pi-config/extensions/steel-browser/tests/tools.test.ts

1191 lines
37 KiB
TypeScript

import { strict as assert } from "node:assert/strict";
import { rm } from "node:fs/promises";
import path from "node:path";
import { describe, it } from "node:test";
import steelExtension from "../dist/index.js";
import { navigateTool } from "../dist/tools/navigate.js";
import { scrapeTool } from "../dist/tools/scrape.js";
import { screenshotTool } from "../dist/tools/screenshot.js";
import { pdfTool } from "../dist/tools/pdf.js";
import { clickTool } from "../dist/tools/click.js";
import { computerTool } from "../dist/tools/computer.js";
import { typeTool } from "../dist/tools/type.js";
import { fillFormTool } from "../dist/tools/fill-form.js";
import { waitTool } from "../dist/tools/wait.js";
import { extractTool } from "../dist/tools/extract.js";
import { findElementsTool } from "../dist/tools/find-elements.js";
import { scrollTool } from "../dist/tools/scroll.js";
import { pinSessionTool, releaseSessionTool } from "../dist/tools/session-control.js";
import { goBackTool, getUrlTool, getTitleTool } from "../dist/tools/navigation.js";
import type { SteelSessionMode } from "../dist/session-mode.js";
type MockToolResult = {
content: Array<{ type: "text"; text: string }>;
details?: Record<string, unknown>;
};
type MockTool = {
name: string;
parameters?: {
type?: string;
properties?: Record<string, unknown>;
additionalProperties?: boolean;
};
execute: (
_toolCallId: string,
_params: Record<string, unknown>,
_signal: AbortSignal,
onUpdate: (update: string) => Promise<void>,
_ctx: unknown
) => Promise<MockToolResult>;
};
type MockPiApi = {
registerTool: (tool: MockTool) => void;
on: (eventName: string, _handler: (...args: unknown[]) => unknown) => void;
onShutdown: (handler: () => Promise<void> | void) => Promise<void>;
};
type MockSession = {
id: string;
[key: string]: unknown;
};
type MockClient = {
getOrCreateSession: () => Promise<MockSession>;
getCurrentSessionId?: () => string | null;
hasActiveSession?: () => boolean;
refreshSession?: (
options?: { useProxy?: boolean; proxyUrl?: string | null }
) => Promise<MockSession>;
isProxyConfigured?: () => boolean;
closeAllSessions?: () => Promise<void>;
};
function createMockClient(session: MockSession): MockClient {
return {
getOrCreateSession: async () => session,
getCurrentSessionId: () => session.id,
hasActiveSession: () => true,
};
}
function assertTextResult(result: MockToolResult): void {
assert.ok(Array.isArray(result.content), "tool should return content array");
assert.equal(result.content.length, 1);
assert.equal(result.content[0].type, "text");
assert.equal(typeof result.content[0].text, "string");
assert.ok(result.content[0].text.length > 0);
assert.equal(typeof result.details, "object");
assert.ok(result.details?.sessionId);
assert.equal(typeof result.details?.sessionViewerUrl, "string");
}
function createUpdatesCollector() {
const updates: string[] = [];
const onUpdate = async (update: string) => {
updates.push(update);
};
return { updates, onUpdate };
}
function withEnv<T>(key: string, value: string | undefined, fn: () => T): T {
const original = process.env[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
try {
return fn();
} finally {
if (original === undefined) {
delete process.env[key];
} else {
process.env[key] = original;
}
}
}
async function executeTool(tool: MockTool, params: Record<string, unknown>, session: MockSession): Promise<{
result: MockToolResult;
updates: string[];
}> {
const client = createMockClient(session);
const toolWithClient = (tool as unknown) as MockTool;
const actual = {
navigate: navigateTool,
scrape: scrapeTool,
screenshot: screenshotTool,
pdf: pdfTool,
click: clickTool,
computer: computerTool,
type: typeTool,
fillForm: fillFormTool,
wait: waitTool,
extract: extractTool,
findElements: findElementsTool,
scroll: scrollTool,
goBack: goBackTool,
getUrl: getUrlTool,
getTitle: getTitleTool,
} as Record<string, unknown>;
const boundTool =
toolWithClient === actual.navigate
? navigateTool(client as unknown as never)
: toolWithClient === actual.scrape
? scrapeTool(client as unknown as never)
: toolWithClient === actual.screenshot
? screenshotTool(client as unknown as never)
: toolWithClient === actual.pdf
? pdfTool(client as unknown as never)
: toolWithClient === actual.click
? clickTool(client as unknown as never)
: toolWithClient === actual.computer
? computerTool(client as unknown as never)
: toolWithClient === actual.type
? typeTool(client as unknown as never)
: toolWithClient === actual.fillForm
? fillFormTool(client as unknown as never)
: toolWithClient === actual.wait
? waitTool(client as unknown as never)
: toolWithClient === actual.extract
? extractTool(client as unknown as never)
: toolWithClient === actual.findElements
? findElementsTool(client as unknown as never)
: toolWithClient === actual.scroll
? scrollTool(client as unknown as never)
: toolWithClient === actual.goBack
? goBackTool(client as unknown as never)
: toolWithClient === actual.getUrl
? getUrlTool(client as unknown as never)
: toolWithClient === actual.getTitle
? getTitleTool(client as unknown as never)
: undefined;
assert.ok(boundTool, `Unable to bind mock client for tool ${toolWithClient.name}`);
const { updates, onUpdate } = createUpdatesCollector();
const result = await boundTool!.execute("call-001", params, new AbortController().signal, onUpdate, null);
return { result, updates };
}
describe("Tool registration contracts", () => {
const expectedTools = [
"steel_navigate",
"steel_scrape",
"steel_screenshot",
"steel_pdf",
"steel_click",
"steel_computer",
"steel_find_elements",
"steel_type",
"steel_fill_form",
"steel_wait",
"steel_extract",
"steel_scroll",
"steel_go_back",
"steel_get_url",
"steel_get_title",
"steel_pin_session",
"steel_release_session",
];
const requiredTopLevelParams: Record<string, string[]> = {
steel_navigate: ["url"],
steel_scrape: [],
steel_screenshot: [],
steel_pdf: [],
steel_click: ["selector"],
steel_computer: ["action"],
steel_find_elements: [],
steel_type: ["selector", "text"],
steel_fill_form: ["fields"],
steel_wait: ["selector"],
steel_extract: ["schema"],
steel_scroll: [],
steel_go_back: [],
steel_get_url: [],
steel_get_title: [],
steel_pin_session: [],
steel_release_session: [],
};
it("registers all tools in expected order", async () => {
const tools = withEnv("STEEL_API_KEY", "test-key", () => {
const registeredTools: MockTool[] = [];
steelExtension({
registerTool: (tool: MockTool) => {
registeredTools.push(tool);
},
on: () => {
return;
},
onShutdown: async () => {
return;
},
} as never);
return registeredTools;
});
assert.deepEqual(
tools.map((tool) => tool.name),
expectedTools,
"tool registration order changed"
);
for (const tool of tools) {
const expectedFields = requiredTopLevelParams[tool.name];
assert.equal(tool.name in requiredTopLevelParams, true);
assert.equal(tool.parameters?.type, "object");
const properties = tool.parameters?.properties ?? {};
for (const key of expectedFields) {
assert.ok(Object.prototype.hasOwnProperty.call(properties, key), `${tool.name} missing required schema field ${key}`);
}
assert.ok(tool.execute instanceof Function);
}
});
it("registers runtime cleanup hooks for turn, agent, and session boundaries", () => {
const registeredEvents = withEnv("STEEL_API_KEY", "test-key", () =>
withEnv("STEEL_SESSION_MODE", undefined, () => {
const eventNames: string[] = [];
steelExtension({
registerTool: () => {
return;
},
on: (eventName: string) => {
eventNames.push(eventName);
},
onShutdown: async () => {
return;
},
} as MockPiApi as never);
return eventNames;
})
);
assert.deepEqual(registeredEvents, [
"turn_end",
"agent_end",
"session_before_switch",
"session_shutdown",
]);
});
it("registers explicit session control tools", async () => {
let mode: SteelSessionMode = "agent";
let closeCalls = 0;
const client: MockClient = {
getOrCreateSession: async () => ({ id: "session-1" }),
getCurrentSessionId: () => "session-1",
hasActiveSession: () => true,
closeAllSessions: async () => {
closeCalls += 1;
},
};
const controller = {
getDefaultSessionMode: () => "agent" as const,
getSessionMode: () => mode,
setSessionMode: (nextMode: SteelSessionMode) => {
mode = nextMode;
},
closeSessions: async () => {
closeCalls += 1;
},
};
const pin = pinSessionTool(client as never, controller);
const release = releaseSessionTool(client as never, controller);
const pinResult = await pin.execute(
"call-001",
{},
new AbortController().signal,
async () => {},
null
);
assert.equal(mode, "session");
assert.match(pinResult.content[0].text, /Enabled Steel session persistence/i);
assert.match(pinResult.content[0].text, /Current session: session-1/i);
assert.equal(pinResult.details?.mode, "session");
const releaseResult = await release.execute(
"call-002",
{},
new AbortController().signal,
async () => {},
null
);
assert.equal(mode, "agent");
assert.equal(closeCalls, 1);
assert.match(releaseResult.content[0].text, /Released Steel session session-1/i);
assert.equal(releaseResult.details?.mode, "agent");
});
it("executes navigation tool with normalized URL and response contract", async () => {
const session: MockSession = {
id: "session-1",
goto: async () => {},
};
const { result } = await executeTool(navigateTool as unknown as MockTool, { url: "example.com" }, session);
assertTextResult(result);
assert.equal(result.details?.url, "https://example.com/");
assert.equal(result.details?.waitUntil, "networkidle");
});
it("accepts uppercase HTTP scheme without corrupting the target URL", async () => {
const calls: string[] = [];
const session: MockSession = {
id: "session-1",
goto: async (url: string) => {
calls.push(url);
},
};
const { result } = await executeTool(
navigateTool as unknown as MockTool,
{ url: "HTTP://example.com/path" },
session
);
assertTextResult(result);
assert.equal(calls[0], "http://example.com/path");
assert.equal(result.details?.url, "http://example.com/path");
});
it("accepts host:port input and normalizes to https", async () => {
const calls: string[] = [];
const session: MockSession = {
id: "session-1",
goto: async (url: string) => {
calls.push(url);
},
};
const { result } = await executeTool(
navigateTool as unknown as MockTool,
{ url: "localhost:3000/login" },
session
);
assertTextResult(result);
assert.equal(calls[0], "https://localhost:3000/login");
assert.equal(result.details?.url, "https://localhost:3000/login");
});
it("rejects non-http URL schemes", async () => {
const client = createMockClient({
id: "session-1",
goto: async () => {},
});
const tool = navigateTool(client as never);
await assert.rejects(
() =>
tool.execute(
"call-001",
{ url: "ftp://example.com" },
new AbortController().signal,
async () => {},
null
),
/Only http and https URLs are supported/,
"expected non-http scheme to be rejected"
);
});
it("retries tunnel failures with a fresh session before succeeding", async () => {
const previousRetries = process.env.STEEL_NAVIGATE_RETRY_COUNT;
process.env.STEEL_NAVIGATE_RETRY_COUNT = "0";
try {
const sessions: MockSession[] = [
{
id: "session-1",
goto: async () => {
throw new Error("page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://example.com");
},
},
{
id: "session-2",
goto: async () => {},
},
];
let refreshCalls = 0;
const client: MockClient = {
getOrCreateSession: async () => sessions[0],
refreshSession: async () => {
refreshCalls += 1;
return sessions[1];
},
isProxyConfigured: () => true,
};
const tool = navigateTool(client as never);
const result = await tool.execute(
"call-001",
{ url: "https://example.com" },
new AbortController().signal,
async () => {},
null
);
assertTextResult(result as unknown as MockToolResult);
assert.equal((result.details as Record<string, unknown>).sessionId, "session-2");
const recovery = (result.details as Record<string, unknown>).tunnelRecovery as
| Record<string, unknown>
| null;
assert.equal(recovery?.mode, "fresh_session");
assert.equal(refreshCalls, 1);
} finally {
process.env.STEEL_NAVIGATE_RETRY_COUNT = previousRetries;
}
});
it("falls back to no-proxy session after repeated tunnel failures", async () => {
const previousRetries = process.env.STEEL_NAVIGATE_RETRY_COUNT;
process.env.STEEL_NAVIGATE_RETRY_COUNT = "0";
try {
const sessions: MockSession[] = [
{
id: "session-1",
goto: async () => {
throw new Error("page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://example.com");
},
},
{
id: "session-2",
goto: async () => {
throw new Error("page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://example.com");
},
},
{
id: "session-3",
goto: async () => {},
},
];
const refreshOptions: Array<{ useProxy?: boolean; proxyUrl?: string | null } | undefined> = [];
let refreshIndex = 0;
const client: MockClient = {
getOrCreateSession: async () => sessions[0],
refreshSession: async (options) => {
refreshOptions.push(options);
refreshIndex += 1;
return sessions[refreshIndex];
},
isProxyConfigured: () => true,
};
const tool = navigateTool(client as never);
const result = await tool.execute(
"call-001",
{ url: "https://example.com" },
new AbortController().signal,
async () => {},
null
);
assertTextResult(result as unknown as MockToolResult);
assert.equal((result.details as Record<string, unknown>).sessionId, "session-3");
const recovery = (result.details as Record<string, unknown>).tunnelRecovery as
| Record<string, unknown>
| null;
assert.equal(recovery?.mode, "no_proxy");
assert.equal(refreshOptions.length, 2);
assert.equal(refreshOptions[1]?.useProxy, false);
assert.equal(refreshOptions[1]?.proxyUrl, null);
} finally {
process.env.STEEL_NAVIGATE_RETRY_COUNT = previousRetries;
}
});
it("executes scrape tool and returns extracted text", async () => {
const session: MockSession = {
id: "session-1",
content: async () => "<html><body><h1>Title</h1></body></html>",
evaluate: async (_fn: unknown, input: unknown) => {
assert.equal(typeof input, "object");
return "Title";
},
};
const { result } = await executeTool(scrapeTool as unknown as MockTool, { format: "text" }, session);
assertTextResult(result);
assert.equal(result.content[0].text, "Title");
assert.equal(result.details?.format, "text");
});
it("truncates scrape output when maxChars is exceeded", async () => {
const longText = "A".repeat(400);
const session: MockSession = {
id: "session-1",
content: async () => "<html><body>ignored</body></html>",
evaluate: async (_fn: unknown, input: unknown) => {
assert.equal(typeof input, "object");
return longText;
},
};
const { result } = await executeTool(
scrapeTool as unknown as MockTool,
{ format: "text", maxChars: 200 },
session
);
assertTextResult(result);
assert.equal(result.details?.truncated, true);
assert.equal(result.details?.originalContentLength, longText.length);
assert.equal(result.details?.maxChars, 200);
assert.ok((result.content[0].text ?? "").includes("[truncated "));
assert.ok((result.content[0].text ?? "").length <= 200);
});
it("supports short scrape excerpts below 200 characters", async () => {
const longText = "B".repeat(400);
const session: MockSession = {
id: "session-1",
content: async () => "<html><body>ignored</body></html>",
evaluate: async (_fn: unknown, input: unknown) => {
assert.equal(typeof input, "object");
return longText;
},
};
const { result } = await executeTool(
scrapeTool as unknown as MockTool,
{ format: "text", maxChars: 150 },
session
);
assertTextResult(result);
assert.equal(result.details?.maxChars, 150);
assert.equal(result.details?.truncated, true);
assert.ok((result.content[0].text ?? "").length <= 150);
});
it("captures screenshot artifact and returns artifact path", async () => {
const session: MockSession = {
id: "session-1",
url: "https://page.example/",
screenshot: async () => Buffer.from("png-bytes"),
};
const { result } = await executeTool(screenshotTool as unknown as MockTool, { fullPage: true }, session);
assertTextResult(result);
const filePath = result.details?.filePath;
assert.equal(typeof filePath, "string");
assert.ok(path.basename(filePath as string).startsWith("steel-screenshot-"));
assert.equal(path.extname(filePath as string), ".png");
await rm(filePath as string);
});
it("generates PDF artifact and returns artifact metadata", async () => {
const session: MockSession = {
id: "session-1",
url: "https://page.example/",
pdf: async () => Buffer.from("pdf-bytes"),
};
const { result } = await executeTool(pdfTool as unknown as MockTool, {}, session);
assertTextResult(result);
assert.match(result.content[0].text, /^PDF saved: \.artifacts\/pdfs\/steel-pdf-/);
const filePath = result.details?.filePath;
assert.equal(typeof filePath, "string");
assert.ok(path.basename(filePath as string).startsWith("steel-pdf-"));
assert.equal(path.extname(filePath as string), ".pdf");
const absoluteFilePath = result.details?.absoluteFilePath;
assert.equal(typeof absoluteFilePath, "string");
assert.ok(path.isAbsolute(absoluteFilePath as string));
const artifact = result.details?.artifact as Record<string, unknown> | undefined;
assert.ok(artifact);
assert.equal(artifact?.type, "pdf");
assert.equal(artifact?.mimeType, "application/pdf");
const artifactPath = artifact?.path;
assert.equal(typeof artifactPath, "string");
assert.equal(artifactPath, filePath);
await rm(artifactPath as string);
});
it("executes click tool when element is clickable", async () => {
const calls: string[] = [];
const session: MockSession = {
id: "session-1",
waitForSelector: async (selector) => {
calls.push(`wait:${selector}`);
},
evaluate: async () => ({ found: true, visible: true, clickable: true, disabled: false }),
click: async (selector) => {
calls.push(`click:${selector}`);
},
};
const { result } = await executeTool(clickTool as unknown as MockTool, { selector: "#btn" }, session);
assertTextResult(result);
assert.equal(calls[0], "wait:#btn");
assert.equal(calls[1], "click:#btn");
assert.equal(result.details?.selector, "#btn");
});
it("executes click tool with Playwright text selectors via locator", async () => {
const calls: string[] = [];
const session: MockSession = {
id: "session-1",
locator: (selector: string) => ({
waitFor: async () => {
calls.push(`wait:${selector}`);
},
isVisible: async () => true,
isEnabled: async () => true,
click: async () => {
calls.push(`click:${selector}`);
},
}),
};
const { result } = await executeTool(
clickTool as unknown as MockTool,
{ selector: "text=Signup" },
session
);
assertTextResult(result);
assert.equal(calls[0], "wait:text=Signup");
assert.equal(calls[1], "click:text=Signup");
});
it("retries click via captcha recovery when overlay blocks pointer events", async () => {
const previousWait = process.env.STEEL_CAPTCHA_WAIT_MS;
const previousPoll = process.env.STEEL_CAPTCHA_POLL_INTERVAL_MS;
const previousRetries = process.env.STEEL_CAPTCHA_MAX_RETRIES;
process.env.STEEL_CAPTCHA_WAIT_MS = "1000";
process.env.STEEL_CAPTCHA_POLL_INTERVAL_MS = "250";
process.env.STEEL_CAPTCHA_MAX_RETRIES = "1";
try {
let clickAttempts = 0;
let statusChecks = 0;
const session: MockSession = {
id: "session-1",
waitForSelector: async () => {},
evaluate: async () => ({
found: true,
visible: true,
clickable: true,
disabled: false,
}),
click: async () => {
clickAttempts += 1;
if (clickAttempts === 1) {
throw new Error("subtree intercepts pointer events");
}
},
captchasStatus: async () => {
statusChecks += 1;
if (statusChecks === 1) {
return [{ isSolvingCaptcha: false, tasks: [{}] }];
}
return [{ isSolvingCaptcha: false, tasks: [] }];
},
captchasSolve: async () => ({ success: true, message: "captcha solve requested" }),
};
const { result } = await executeTool(
clickTool as unknown as MockTool,
{ selector: "#btn" },
session
);
assertTextResult(result);
assert.equal(clickAttempts, 2);
const captchaRecovery = result.details?.captchaRecovery as Record<string, unknown>;
assert.equal(captchaRecovery?.triggered, true);
assert.equal(captchaRecovery?.retries, 1);
assert.equal(captchaRecovery?.solveAttempts, 1);
assert.ok(Number(captchaRecovery?.statusChecks) >= 1);
} finally {
process.env.STEEL_CAPTCHA_WAIT_MS = previousWait;
process.env.STEEL_CAPTCHA_POLL_INTERVAL_MS = previousPoll;
process.env.STEEL_CAPTCHA_MAX_RETRIES = previousRetries;
}
});
it("executes computer action and persists screenshot artifact", async () => {
const session: MockSession = {
id: "session-1",
computer: async () => ({
base64_image: Buffer.from("png-bytes").toString("base64"),
output: "clicked",
}),
};
const { result } = await executeTool(
computerTool as unknown as MockTool,
{
action: "click_mouse",
button: "left",
coordinates: [100, 220],
screenshot: true,
},
session
);
assertTextResult(result);
assert.equal(result.details?.action, "click_mouse");
const filePath = result.details?.filePath;
assert.equal(typeof filePath, "string");
assert.ok(path.basename(filePath as string).startsWith("steel-computer-"));
assert.equal(path.extname(filePath as string), ".png");
await rm(filePath as string);
});
it("types text into field after clearing and returns field metadata", async () => {
const session: MockSession = {
id: "session-1",
waitForSelector: async () => {},
evaluate: async () => ({ found: true, editable: true }),
fill: async () => {},
};
const { result } = await executeTool(typeTool as unknown as MockTool, { selector: "input[name=user]", text: "Alice" }, session);
assertTextResult(result);
assert.equal(result.details?.selector, "input[name=user]");
assert.equal(result.details?.clear, true);
assert.equal(result.details?.textLength, 5);
});
it("preserves literal escape characters in steel_type input text", async () => {
let filledValue = "";
const session: MockSession = {
id: "session-1",
waitForSelector: async () => {},
evaluate: async () => ({ found: true, editable: true }),
fill: async (_selector, text) => {
filledValue = text;
},
};
const { result } = await executeTool(
typeTool as unknown as MockTool,
{ selector: "input[name=user]", text: "C\\new" },
session
);
assertTextResult(result);
assert.equal(filledValue, "C\\new");
assert.equal(result.details?.textLength, 5);
});
it("fills multiple form fields with partial success details", async () => {
const filled: string[] = [];
const session: MockSession = {
id: "session-1",
waitForSelector: async (selector) => {
if (selector === ".missing") {
throw new Error("No element matched selector: .missing");
}
},
evaluate: async (_selector: string) => ({ found: true, editable: true }),
fill: async (selector) => {
filled.push(selector);
},
};
const { result } = await executeTool(
fillFormTool as unknown as MockTool,
{
fields: [
{ selector: "#a", value: "1" },
{ selector: ".missing", value: "2" },
{ selector: "#b", value: "3" },
],
},
session
);
assertTextResult(result);
assert.equal(filled[0], "#a");
assert.equal(filled[1], "#b");
assert.equal(result.details?.successCount, 2);
assert.equal(result.details?.total, 3);
});
it("preserves literal escape characters in steel_fill_form values", async () => {
const values: string[] = [];
const session: MockSession = {
id: "session-1",
waitForSelector: async () => {},
evaluate: async () => true,
fill: async (_selector, value) => {
values.push(value);
},
};
const { result } = await executeTool(
fillFormTool as unknown as MockTool,
{
fields: [{ selector: "#a", value: "A\\tB" }],
},
session
);
assertTextResult(result);
assert.equal(values[0], "A\\tB");
});
it("waits for selector with state and timeout contract", async () => {
const session: MockSession = {
id: "session-1",
waitForSelector: async () => {},
url: "https://waiting.example/",
};
const { result } = await executeTool(waitTool as unknown as MockTool, { selector: "#ready", timeout: 1000 }, session);
assertTextResult(result);
assert.equal(result.details?.selector, "#ready");
assert.equal(result.details?.timeoutMs, 1000);
});
it("extracts structured data and validates contract", async () => {
const session: MockSession = {
id: "session-1",
url: "https://extract.example/",
evaluate: async () => ({ title: "Hello", version: 1 }),
};
const schema = {
type: "object" as const,
properties: {
title: { type: "string" },
version: { type: "number" },
},
required: ["title", "version"],
additionalProperties: false,
};
const { result } = await executeTool(
extractTool as unknown as MockTool,
{
schema,
instructions: "extract title and version",
strict: true,
},
session
);
assertTextResult(result);
const parsed = JSON.parse(result.content[0].text);
assert.equal(parsed.title, "Hello");
assert.equal(parsed.version, 1);
assert.equal(result.details?.schemaEnforced, true);
});
it("finds candidate selectors for interactive elements", async () => {
const session: MockSession = {
id: "session-1",
url: "https://find.example/",
evaluate: async () => [
{
selector: "a[href='/signup']",
text: "Sign up",
tag: "a",
role: null,
clickable: true,
visible: true,
},
],
};
const { result } = await executeTool(
findElementsTool as unknown as MockTool,
{ query: "sign up", limit: 5 },
session
);
assertTextResult(result);
const parsed = JSON.parse(result.content[0].text);
assert.equal(Array.isArray(parsed), true);
assert.equal(parsed[0].selector, "a[href='/signup']");
assert.equal(result.details?.count, 1);
});
it("scrolls page and reports movement bounds", async () => {
const session: MockSession = {
id: "session-1",
evaluate: async () => ({
before: 0,
after: 700,
maxScrollY: 1200,
effectiveAmount: 700,
viewportHeight: 500,
contentHeight: 1400,
targetType: "page",
targetSelector: null,
}),
};
const { result } = await executeTool(scrollTool as unknown as MockTool, { direction: "down", amount: 700 }, session);
assertTextResult(result);
assert.equal(result.details?.effectiveAmount, 700);
assert.equal(result.details?.direction, "down");
assert.equal(result.details?.targetType, "page");
});
it("scrolls a nested container when selector is provided", async () => {
const session: MockSession = {
id: "session-1",
evaluate: async () => ({
before: 120,
after: 720,
maxScrollY: 2400,
effectiveAmount: 600,
viewportHeight: 640,
contentHeight: 3040,
targetType: "container",
targetSelector: 'div[role="feed"]',
}),
};
const { result } = await executeTool(
scrollTool as unknown as MockTool,
{ direction: "down", amount: 600, selector: 'div[role="feed"]' },
session
);
assertTextResult(result);
assert.equal(result.details?.requestedSelector, 'div[role="feed"]');
assert.equal(result.details?.targetType, "container");
assert.equal(result.details?.targetSelector, 'div[role="feed"]');
assert.equal(result.details?.effectiveAmount, 600);
});
it("reads page history and title/url details", async () => {
const historySession: MockSession = {
id: "session-1",
url: "https://history.example/",
goBack: async () => {},
};
const { result: goBackResult } = await executeTool(goBackTool as unknown as MockTool, {}, historySession);
assertTextResult(goBackResult);
assert.equal(goBackResult.details?.url, "https://history.example/");
const urlSession: MockSession = {
id: "session-1",
url: async () => "https://current.example/",
};
const { result: urlResult } = await executeTool(getUrlTool as unknown as MockTool, {}, urlSession);
assertTextResult(urlResult);
assert.equal(urlResult.content[0].text, "Current URL: https://current.example/");
const titleSession: MockSession = {
id: "session-1",
title: () => "Current Title",
};
const { result: titleResult } = await executeTool(getTitleTool as unknown as MockTool, {}, titleSession);
assertTextResult(titleResult);
assert.equal(titleResult.content[0].text, "Current title: Current Title");
});
it("recovers go_back when history navigation completes after a timeout", async () => {
let currentUrl = "https://news.ycombinator.com/";
const session: MockSession = {
id: "session-1",
url: async () => currentUrl,
goBack: async () => {
currentUrl = "https://example.com/";
throw new Error('page.goBack: Timeout 30000ms exceeded. Call log: waiting for navigation until "load"');
},
};
const { result } = await executeTool(goBackTool as unknown as MockTool, {}, session);
assertTextResult(result);
assert.equal(result.content[0].text, "Navigated back to https://example.com/");
assert.equal(result.details?.previousUrl, "https://news.ycombinator.com/");
assert.equal(result.details?.url, "https://example.com/");
assert.equal(result.details?.timeoutRecovered, true);
});
it("reports about:blank as a fresh session in get_url", async () => {
const session: MockSession = {
id: "session-1",
url: "about:blank",
};
const { result } = await executeTool(getUrlTool as unknown as MockTool, {}, session);
assertTextResult(result);
assert.match(result.content[0].text, /fresh Steel session/i);
assert.equal(result.details?.url, "about:blank");
assert.equal(result.details?.isFreshSession, true);
});
it("fails get_title on about:blank with continuity guidance", async () => {
const client = createMockClient({
id: "session-1",
url: "about:blank",
title: async () => "",
});
const tool = getTitleTool(client as never);
await assert.rejects(
() =>
tool.execute(
"call-001",
{},
new AbortController().signal,
async () => {},
null
),
/about:blank.*STEEL_SESSION_MODE=session/i
);
});
it("fails scrape on about:blank with continuity guidance", async () => {
const client = createMockClient({
id: "session-1",
url: "about:blank",
content: async () => "<html><head></head><body></body></html>",
});
const tool = scrapeTool(client as never);
await assert.rejects(
() =>
tool.execute(
"call-001",
{ format: "text" },
new AbortController().signal,
async () => {},
null
),
/about:blank.*STEEL_SESSION_MODE=session/i
);
});
it("fails find_elements on about:blank with continuity guidance", async () => {
const client = createMockClient({
id: "session-1",
url: "about:blank",
evaluate: async () => [],
});
const tool = findElementsTool(client as never);
await assert.rejects(
() =>
tool.execute(
"call-001",
{},
new AbortController().signal,
async () => {},
null
),
/about:blank.*STEEL_SESSION_MODE=session/i
);
});
it("fails on selector validation errors", async () => {
const client = createMockClient({ id: "session-1" });
const tool = clickTool(client as never);
await assert.rejects(
() =>
tool.execute(
"call-001",
{ selector: "" },
new AbortController().signal,
async () => {},
null
),
/Selector cannot be empty/,
"expected selector validation failure"
);
});
it("fails on timeout validation errors", async () => {
const client = createMockClient({ id: "session-1" });
const tool = waitTool(client as never);
await assert.rejects(
() =>
tool.execute(
"call-001",
{ selector: "#item", timeout: 0 },
new AbortController().signal,
async () => {},
null
),
/timeout must be a positive number/,
"expected timeout validation failure"
);
});
it("fails extraction when schema validation rejects result", async () => {
const client = createMockClient({
id: "session-1",
evaluate: async () => ({ version: 1 }),
});
const tool = extractTool(client as never);
const schema = {
type: "object" as const,
properties: {
title: { type: "string" },
},
required: ["title"],
additionalProperties: false,
};
await assert.rejects(
() =>
tool.execute(
"call-001",
{ schema },
new AbortController().signal,
async () => {},
null
),
/Extraction result does not match requested schema/,
"expected extraction validation failure"
);
});
it("cancels wait tool when abort signal fires", async () => {
const client = createMockClient({
id: "session-1",
waitForSelector: async () => {
await new Promise(() => {});
},
});
const tool = waitTool(client as never);
const controller = new AbortController();
const pending = tool.execute(
"call-001",
{ selector: "#slow", timeout: 60_000 },
controller.signal,
async () => {},
null
);
setTimeout(() => controller.abort(), 10);
await assert.rejects(
() => pending,
/cancelled/i,
"expected cancellation to abort wait tool execution"
);
});
});