fix: allow direct file input uploads

This commit is contained in:
Peter Steinberger
2026-01-01 09:44:29 +00:00
parent bf0bee58b3
commit 6ea10dd153
10 changed files with 116 additions and 10 deletions

View File

@@ -94,6 +94,8 @@ export async function browserArmFileChooser(
opts: {
paths: string[];
ref?: string;
inputRef?: string;
element?: string;
targetId?: string;
timeoutMs?: number;
},
@@ -106,6 +108,8 @@ export async function browserArmFileChooser(
body: JSON.stringify({
paths: opts.paths,
ref: opts.ref,
inputRef: opts.inputRef,
element: opts.element,
targetId: opts.targetId,
timeoutMs: opts.timeoutMs,
}),

View File

@@ -22,6 +22,7 @@ export {
pressKeyViaPlaywright,
resizeViewportViaPlaywright,
selectOptionViaPlaywright,
setInputFilesViaPlaywright,
snapshotAiViaPlaywright,
takeScreenshotViaPlaywright,
typeViaPlaywright,

View File

@@ -303,6 +303,44 @@ export async function armFileUploadViaPlaywright(opts: {
});
}
export async function setInputFilesViaPlaywright(opts: {
cdpPort: number;
targetId?: string;
inputRef?: string;
element?: string;
paths: string[];
}): Promise<void> {
const page = await getPageForTargetId(opts);
ensurePageState(page);
if (!opts.paths.length) throw new Error("paths are required");
const inputRef =
typeof opts.inputRef === "string" ? opts.inputRef.trim() : "";
const element = typeof opts.element === "string" ? opts.element.trim() : "";
if (inputRef && element) {
throw new Error("inputRef and element are mutually exclusive");
}
if (!inputRef && !element) {
throw new Error("inputRef or element is required");
}
const locator = inputRef
? refLocator(page, inputRef)
: page.locator(element).first();
await locator.setInputFiles(opts.paths);
try {
const handle = await locator.elementHandle();
if (handle) {
await handle.evaluate((el) => {
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
});
}
} catch {
// Best-effort for sites that don't react to setInputFiles alone.
}
}
export async function armDialogViaPlaywright(opts: {
cdpPort: number;
targetId?: string;

View File

@@ -339,6 +339,8 @@ export function registerBrowserAgentRoutes(
const body = readBody(req);
const targetId = toStringOrEmpty(body.targetId) || undefined;
const ref = toStringOrEmpty(body.ref) || undefined;
const inputRef = toStringOrEmpty(body.inputRef) || undefined;
const element = toStringOrEmpty(body.element) || undefined;
const paths = toStringArray(body.paths) ?? [];
const timeoutMs = toNumber(body.timeoutMs);
if (!paths.length) return jsonError(res, 400, "paths are required");
@@ -346,18 +348,35 @@ export function registerBrowserAgentRoutes(
const tab = await ctx.ensureTabAvailable(targetId);
const pw = await requirePwAi(res, "file chooser hook");
if (!pw) return;
await pw.armFileUploadViaPlaywright({
cdpPort: ctx.state().cdpPort,
targetId: tab.targetId,
paths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
if (inputRef || element) {
if (ref) {
return jsonError(
res,
400,
"ref cannot be combined with inputRef/element",
);
}
await pw.setInputFilesViaPlaywright({
cdpPort: ctx.state().cdpPort,
targetId: tab.targetId,
ref,
inputRef,
element,
paths,
});
} else {
await pw.armFileUploadViaPlaywright({
cdpPort: ctx.state().cdpPort,
targetId: tab.targetId,
paths,
timeoutMs: timeoutMs ?? undefined,
});
if (ref) {
await pw.clickViaPlaywright({
cdpPort: ctx.state().cdpPort,
targetId: tab.targetId,
ref,
});
}
}
res.json({ ok: true });
} catch (err) {

View File

@@ -33,6 +33,7 @@ const pwMocks = vi.hoisted(() => ({
pressKeyViaPlaywright: vi.fn(async () => {}),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
@@ -493,6 +494,37 @@ describe("browser control server", () => {
ref: "e12",
});
const uploadWithInputRef = await realFetch(`${base}/hooks/file-chooser`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: ["/tmp/c.txt"], inputRef: "e99" }),
}).then((r) => r.json());
expect(uploadWithInputRef).toMatchObject({ ok: true });
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
targetId: "abcd1234",
inputRef: "e99",
element: undefined,
paths: ["/tmp/c.txt"],
});
const uploadWithElement = await realFetch(`${base}/hooks/file-chooser`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paths: ["/tmp/d.txt"],
element: "input[type=file]",
}),
}).then((r) => r.json());
expect(uploadWithElement).toMatchObject({ ok: true });
expect(pwMocks.setInputFilesViaPlaywright).toHaveBeenCalledWith({
cdpPort: testPort + 1,
targetId: "abcd1234",
inputRef: undefined,
element: "input[type=file]",
paths: ["/tmp/d.txt"],
});
const dialog = await realFetch(`${base}/hooks/dialog`, {
method: "POST",
headers: { "Content-Type": "application/json" },