flat: convert steel-browser from submodule to regular folder
This commit is contained in:
304
extensions/steel-browser/src/tools/fill-form.ts
Normal file
304
extensions/steel-browser/src/tools/fill-form.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { ExtensionContext, ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { sessionDetails, type SteelClient } from "../steel-client.js";
|
||||
import { runWithCaptchaRecovery, type CaptchaRecoverySummary } from "./captcha-guard.js";
|
||||
import {
|
||||
emitProgress,
|
||||
isAbortError,
|
||||
throwIfAborted,
|
||||
withAbortSignal,
|
||||
withToolError,
|
||||
type ToolProgressUpdater,
|
||||
} from "./tool-runtime.js";
|
||||
import {
|
||||
MAX_TOOL_TIMEOUT_MS,
|
||||
resolveToolTimeoutMs,
|
||||
} from "./tool-settings.js";
|
||||
|
||||
type SessionLike = {
|
||||
id: string;
|
||||
sessionViewerUrl?: string | null;
|
||||
captchasStatus?: () => Promise<unknown>;
|
||||
captchasSolve?: () => Promise<unknown>;
|
||||
waitForSelector?: (
|
||||
selector: string,
|
||||
options?: { state?: "attached" | "visible"; timeout?: number }
|
||||
) => Promise<unknown>;
|
||||
fill?: (selector: string, text: string) => Promise<unknown>;
|
||||
evaluate?: <T>(fn: (...args: any[]) => T, ...args: any[]) => Promise<T>;
|
||||
locator?: (selector: string) => {
|
||||
fill?: (text: string) => Promise<unknown>;
|
||||
waitFor?: (options?: { state?: "attached" | "visible"; timeout?: number }) => Promise<unknown>;
|
||||
};
|
||||
page?: {
|
||||
waitForSelector?: (
|
||||
selector: string,
|
||||
options?: { state?: "attached" | "visible"; timeout?: number }
|
||||
) => Promise<unknown>;
|
||||
fill?: (selector: string, text: string) => Promise<unknown>;
|
||||
evaluate?: <T>(fn: (...args: any[]) => T, ...args: any[]) => Promise<T>;
|
||||
locator?: (selector: string) => {
|
||||
fill?: (text: string) => Promise<unknown>;
|
||||
waitFor?: (options?: { state?: "attached" | "visible"; timeout?: number }) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type FieldInput = {
|
||||
selector: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type FieldResult = {
|
||||
selector: string;
|
||||
status: "success" | "error";
|
||||
reason?: string;
|
||||
valueLength: number;
|
||||
captchaRecovery?: {
|
||||
triggered: boolean;
|
||||
retries: number;
|
||||
solveAttempts: number;
|
||||
statusChecks: number;
|
||||
waitTimedOut: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function compactCaptchaRecovery(summary: CaptchaRecoverySummary) {
|
||||
return {
|
||||
triggered: summary.triggered,
|
||||
retries: summary.retries,
|
||||
solveAttempts: summary.solveAttempts,
|
||||
statusChecks: summary.statusChecks,
|
||||
waitTimedOut: summary.waitTimedOut,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSelector(selector: string): string {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Selector cannot be empty.");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeTimeout(timeoutMs?: number): number {
|
||||
return resolveToolTimeoutMs(timeoutMs);
|
||||
}
|
||||
|
||||
function normalizeValue(raw: string): string {
|
||||
return raw;
|
||||
}
|
||||
|
||||
function asArray(input: unknown): FieldInput[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return input
|
||||
.map((entry): FieldInput | null => {
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = entry as Partial<FieldInput>;
|
||||
if (typeof record.selector !== "string" || typeof record.value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
selector: normalizeSelector(record.selector),
|
||||
value: normalizeValue(record.value),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is FieldInput => Boolean(entry));
|
||||
}
|
||||
|
||||
async function ensureField(session: SessionLike, selector: string, timeoutMs: number): Promise<void> {
|
||||
if (typeof session.waitForSelector === "function") {
|
||||
await session.waitForSelector(selector, { state: "visible", timeout: timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof session.page?.waitForSelector === "function") {
|
||||
await session.page.waitForSelector(selector, { state: "visible", timeout: timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const evaluate = session.evaluate ?? session.page?.evaluate;
|
||||
if (typeof evaluate !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await evaluate((rawSelector: string) => {
|
||||
const element = document.querySelector(rawSelector);
|
||||
return Boolean(element);
|
||||
}, selector);
|
||||
|
||||
if (!valid) {
|
||||
throw new Error(`No element matched selector: ${selector}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fill(session: SessionLike, selector: string, value: string): Promise<void> {
|
||||
if (typeof session.fill === "function") {
|
||||
await session.fill(selector, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof session.page?.fill === "function") {
|
||||
await session.page.fill(selector, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const locator =
|
||||
typeof session.locator === "function"
|
||||
? session.locator(selector)
|
||||
: session.page?.locator?.(selector);
|
||||
|
||||
const locatorFill = locator?.fill;
|
||||
if (typeof locatorFill === "function") {
|
||||
await locatorFill.call(locator, value);
|
||||
return;
|
||||
}
|
||||
|
||||
const evaluate = session.evaluate ?? session.page?.evaluate;
|
||||
if (typeof evaluate !== "function") {
|
||||
throw new Error("Session does not support setting input values.");
|
||||
}
|
||||
|
||||
const ok = await evaluate(
|
||||
(input: { selector: string; value: string }) => {
|
||||
const element = document.querySelector(input.selector) as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
|
||||
element.value = input.value;
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
return true;
|
||||
},
|
||||
{ selector, value }
|
||||
);
|
||||
|
||||
if (!ok) {
|
||||
throw new Error(`Could not set value for selector: ${selector}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function fillFormTool(client: SteelClient): ToolDefinition<any, any> {
|
||||
return {
|
||||
name: "steel_fill_form",
|
||||
label: "Fill Form",
|
||||
description: "Fill multiple input fields in a single tool call",
|
||||
parameters: Type.Object({
|
||||
fields: Type.Array(
|
||||
Type.Object({
|
||||
selector: Type.String({ description: "CSS selector for the field" }),
|
||||
value: Type.String({ description: "Value for the field" }),
|
||||
})
|
||||
),
|
||||
timeout: Type.Optional(
|
||||
Type.Integer({
|
||||
minimum: 100,
|
||||
maximum: MAX_TOOL_TIMEOUT_MS,
|
||||
description: "Maximum milliseconds to wait for each field",
|
||||
})
|
||||
),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: { fields: unknown; timeout?: number },
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ToolProgressUpdater,
|
||||
_ctx: ExtensionContext
|
||||
): Promise<{ content: Array<{ type: "text"; text: string }>; details: object }> {
|
||||
return withToolError("steel_fill_form", async () => {
|
||||
throwIfAborted(signal);
|
||||
const fields = asArray(params.fields);
|
||||
if (!fields.length) {
|
||||
throw new Error("At least one field with selector and value is required.");
|
||||
}
|
||||
|
||||
const timeoutMs = normalizeTimeout(params.timeout);
|
||||
await emitProgress(onUpdate, "steel_fill_form", `Preparing ${fields.length} field(s)`);
|
||||
|
||||
const session = (await withAbortSignal(
|
||||
client.getOrCreateSession(),
|
||||
signal
|
||||
)) as SessionLike;
|
||||
|
||||
const results: FieldResult[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let index = 0; index < fields.length; index += 1) {
|
||||
throwIfAborted(signal);
|
||||
const entry = fields[index];
|
||||
const result: FieldResult = {
|
||||
selector: entry.selector,
|
||||
status: "error",
|
||||
valueLength: entry.value.length,
|
||||
};
|
||||
|
||||
await emitProgress(onUpdate, "steel_fill_form", `Processing ${index + 1}/${fields.length}: ${entry.selector}`);
|
||||
try {
|
||||
const captchaRecovery = await runWithCaptchaRecovery({
|
||||
session,
|
||||
context: "steel_fill_form",
|
||||
actionLabel: `fill ${entry.selector}`,
|
||||
onUpdate,
|
||||
signal,
|
||||
operation: async () => {
|
||||
throwIfAborted(signal);
|
||||
await withAbortSignal(
|
||||
ensureField(session, entry.selector, timeoutMs),
|
||||
signal
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
await withAbortSignal(fill(session, entry.selector, entry.value), signal);
|
||||
},
|
||||
});
|
||||
|
||||
result.status = "success";
|
||||
result.captchaRecovery = compactCaptchaRecovery(captchaRecovery);
|
||||
successCount += 1;
|
||||
await emitProgress(onUpdate, "steel_fill_form", `Filled ${entry.selector}`);
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
throw error;
|
||||
}
|
||||
result.reason = error instanceof Error ? error.message : "Unknown error";
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
throw new Error("No form fields were filled successfully.");
|
||||
}
|
||||
|
||||
await emitProgress(onUpdate, "steel_fill_form", `Filled ${successCount}/${fields.length} field(s).`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
successCount === fields.length
|
||||
? `Filled ${fields.length} form field(s).`
|
||||
: `Filled ${successCount}/${fields.length} form fields. Some fields failed.`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
...sessionDetails(session),
|
||||
timeoutMs,
|
||||
total: fields.length,
|
||||
successCount,
|
||||
results,
|
||||
},
|
||||
};
|
||||
}, signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user