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; }; type MockTool = { name: string; parameters?: { type?: string; properties?: Record; additionalProperties?: boolean; }; execute: ( _toolCallId: string, _params: Record, _signal: AbortSignal, onUpdate: (update: string) => Promise, _ctx: unknown ) => Promise; }; type MockPiApi = { registerTool: (tool: MockTool) => void; on: (eventName: string, _handler: (...args: unknown[]) => unknown) => void; onShutdown: (handler: () => Promise | void) => Promise; }; type MockSession = { id: string; [key: string]: unknown; }; type MockClient = { getOrCreateSession: () => Promise; getCurrentSessionId?: () => string | null; hasActiveSession?: () => boolean; refreshSession?: ( options?: { useProxy?: boolean; proxyUrl?: string | null } ) => Promise; isProxyConfigured?: () => boolean; closeAllSessions?: () => Promise; }; 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(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, 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; 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 = { 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).sessionId, "session-2"); const recovery = (result.details as Record).tunnelRecovery as | Record | 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).sessionId, "session-3"); const recovery = (result.details as Record).tunnelRecovery as | Record | 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 () => "

Title

", 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 () => "ignored", 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 () => "ignored", 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 | 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; 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 () => "", }); 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" ); }); });