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
5 changes: 5 additions & 0 deletions .changeset/nasty-kids-visit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@browserbasehq/stagehand": patch
---

fix: file uploads failing on Browserbase
55 changes: 55 additions & 0 deletions packages/core/lib/v3/dom/locatorScripts/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,61 @@ export function ensureFileInputElement(this: Element): boolean {
}
}

export interface SerializedFilePayload {
name: string;
mimeType: string;
base64: string;
lastModified?: number;
}

/** Attach File objects created from serialized payloads to an <input type="file">. */
export function assignFilePayloadsToInputElement(
this: Element,
payloads: SerializedFilePayload[],
): boolean {
try {
const input = this as HTMLInputElement;
if (!input || input.tagName?.toLowerCase() !== "input") return false;
if ((input.type ?? "").toLowerCase() !== "file") return false;

const transfer: DataTransfer | null = (() => {
try {
return new DataTransfer();
} catch {
return null;
}
})();
if (!transfer) return false;

const entries = Array.isArray(payloads) ? payloads : [];
for (const payload of entries) {
if (!payload) continue;
const name = payload.name || "upload.bin";
const mimeType = payload.mimeType || "application/octet-stream";
const lastModified =
typeof payload.lastModified === "number"
? payload.lastModified
: Date.now();

const binary = window.atob(payload.base64 ?? "");
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const file = new File([blob], name, { type: mimeType, lastModified });
transfer.items.add(file);
}

input.files = transfer.files;
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
return true;
} catch {
return false;
}
}

export function dispatchDomClick(
this: Element,
options?: ClickEventOptions,
Expand Down
163 changes: 140 additions & 23 deletions packages/core/lib/v3/tests/setinputfiles.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import { expect, test } from "@playwright/test";
import { Buffer } from "buffer";
import { promises as fs } from "fs";
import path from "path";
import crypto from "crypto";
import type { Page as V3Page } from "../understudy/page";
import { V3 } from "../v3";
import { v3TestConfig } from "./v3.config";

const FILE_UPLOAD_IFRAME_URL =
"https://browserbase.github.io/stagehand-eval-sites/sites/file-uploads-iframe/";
const FILE_UPLOAD_V2_URL =
"https://browserbase.github.io/stagehand-eval-sites/sites/file-uploads-2/";

const RESUME_INPUT = "#resumeUpload";
const RESUME_SUCCESS = "#resumeSuccess";
const IMAGES_INPUT = "#imagesUpload";
const IMAGES_SUCCESS = "#imagesSuccess";
const AUDIO_INPUT = "#audioUpload";
const AUDIO_SUCCESS = "#audioSuccess";
const IFRAME_UPLOAD_INPUT = "/html/body/div/iframe/html/body/div/div[1]/input";
const IFRAME_SUCCESS =
"body > div > iframe >> html > body > div > div:nth-of-type(2)";

test.describe("tests setInputFiles()", () => {
let v3: V3;
const fixtures: string[] = [];

test.beforeEach(async () => {
v3 = new V3(v3TestConfig);
Expand All @@ -12,37 +33,133 @@ test.describe("tests setInputFiles()", () => {

test.afterEach(async () => {
await v3?.close?.().catch(() => {});
await Promise.all(
fixtures.splice(0).map((file) => fs.unlink(file).catch(() => {})),
);
});

test("deepLocator().setInputFiles() (inside an iframe)", async () => {
const createFixture = async (
namePrefix: string,
contents: string,
ext = ".txt",
): Promise<string> => {
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`;
const filename = `${namePrefix}-${crypto.randomBytes(4).toString("hex")}${normalizedExt}`;
const filePath = path.resolve(process.cwd(), filename);
await fs.writeFile(filePath, contents, "utf-8");
fixtures.push(filePath);
return filePath;
};

const expectUploadSuccess = async (
page: V3Page,
successSelector: string,
expectedText: string,
) => {
await expect
.poll(
() =>
page.evaluate((selector) => {
const el = document.querySelector(selector);
if (!el) return "";
const display = window.getComputedStyle(el).display;
if (display === "none") return "";
return el.textContent ?? "";
}, successSelector),
{ message: `wait for success message at ${successSelector}` },
)
.toContain(expectedText);
};

const getInputFileCount = async (page: V3Page, inputSelector: string) => {
return await page.evaluate((selector) => {
const el = document.querySelector(selector);
if (!(el instanceof HTMLInputElement)) return 0;
return el.files?.length ?? 0;
}, inputSelector);
};

const expectFileCount = async (
page: V3Page,
inputSelector: string,
expected: number,
) => {
await expect
.poll(() => getInputFileCount(page, inputSelector), {
message: `wait for file count on ${inputSelector}`,
})
.toBe(expected);
};

test("deepLocator uploads and validates within iframe", async () => {
const page = v3.context.pages()[0];
await page.goto(
"https://browserbase.github.io/stagehand-eval-sites/sites/file-uploads-iframe/",
await page.goto(FILE_UPLOAD_IFRAME_URL);
const fixture = await createFixture(
"iframe-upload",
"<p>iframe upload</p>",
".txt",
);
await page
.deepLocator("/html/body/div/iframe/html/body/div/div[1]/input")
.setInputFiles("fake.html");
await new Promise((resolve) => setTimeout(resolve, 3000));
const successMessage = await page
.deepLocator(
"body > div > iframe >> html > body > div > div:nth-of-type(2)",
)
.textContent();
expect(successMessage).toContain("file uploaded successfully");
.deepLocator(IFRAME_UPLOAD_INPUT)
.setInputFiles(path.relative(process.cwd(), fixture));

const successLocator = page.deepLocator(IFRAME_SUCCESS);
await expect
.poll(async () => (await successLocator.textContent()) ?? "", {
message: "wait for iframe upload success",
})
.toContain("file uploaded successfully");
});

test("locator().setInputFiles() (no iframe)", async () => {
test("locator uploads resume via relative path string", async () => {
const page = v3.context.pages()[0];
await page.goto(
"https://browserbase.github.io/stagehand-eval-sites/sites/file-uploads/",
);
await page.goto(FILE_UPLOAD_V2_URL);
const fixture = await createFixture("resume", "<p>resume</p>", ".pdf");
await page
.locator("/html/body/div/div[1]/input")
.setInputFiles("fake.html");
await new Promise((resolve) => setTimeout(resolve, 3000));
const successMessage = await page
.locator("body > div > div:nth-of-type(2)")
.textContent();
expect(successMessage).toContain("file uploaded successfully");
.locator(RESUME_INPUT)
.setInputFiles(path.relative(process.cwd(), fixture));
await expectUploadSuccess(page, RESUME_SUCCESS, "Resume uploaded!");
await expectFileCount(page, RESUME_INPUT, 1);
});

test("locator uploads multiple images via absolute paths", async () => {
const page = v3.context.pages()[0];
await page.goto(FILE_UPLOAD_V2_URL);
const first = await createFixture("image-a", "<p>A</p>", ".png");
const second = await createFixture("image-b", "<p>B</p>", ".jpeg");
await page.locator(IMAGES_INPUT).setInputFiles([first, second]);
await expectUploadSuccess(page, IMAGES_SUCCESS, "Images uploaded!");
await expectFileCount(page, IMAGES_INPUT, 2);
});

test("locator uploads audio via payload object", async () => {
const page = v3.context.pages()[0];
await page.goto(FILE_UPLOAD_V2_URL);
await page.locator(AUDIO_INPUT).setInputFiles({
name: "voice-sample.mp3",
mimeType: "audio/mpeg",
buffer: Buffer.from("fake audio bytes", "utf-8"),
});
await expectUploadSuccess(page, AUDIO_SUCCESS, "Audio file uploaded!");
await expectFileCount(page, AUDIO_INPUT, 1);
});

test("locator uploads multiple payload objects to images input", async () => {
const page = v3.context.pages()[0];
await page.goto(FILE_UPLOAD_V2_URL);
await page.locator(IMAGES_INPUT).setInputFiles([
{
name: "payload-a.png",
mimeType: "image/png",
buffer: Buffer.from("payload-a", "utf-8"),
},
{
name: "payload-b.png",
mimeType: "image/png",
buffer: Buffer.from("payload-b", "utf-8"),
},
]);
await expectUploadSuccess(page, IMAGES_SUCCESS, "Images uploaded!");
await expectFileCount(page, IMAGES_INPUT, 2);
});
});
10 changes: 10 additions & 0 deletions packages/core/lib/v3/types/private/locator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Buffer } from "buffer";

export interface NormalizedFilePayload {
name: string;
mimeType: string;
buffer: Buffer;
lastModified: number;
/** Absolute path to the source file when provided by the caller. */
absolutePath?: string;
}
14 changes: 14 additions & 0 deletions packages/core/lib/v3/types/public/locator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Buffer } from "buffer";

export interface SetInputFilePayload {
name: string;
mimeType?: string;
buffer: ArrayBuffer | Uint8Array | Buffer | string;
lastModified?: number;
}

export type SetInputFilesArgument =
| string
| string[]
| SetInputFilePayload
| SetInputFilePayload[];
1 change: 1 addition & 0 deletions packages/core/lib/v3/understudy/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ export class V3Context {
info.targetId,
this.apiClient,
this.localBrowserLaunchOptions,
this.env === "BROWSERBASE",
);
this.wireSessionToOwnerPage(sessionId, page);
this.pagesByTarget.set(info.targetId, page);
Expand Down
102 changes: 102 additions & 0 deletions packages/core/lib/v3/understudy/fileUploadUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { promises as fs, type Stats } from "fs";
import path from "path";
import { Buffer } from "buffer";
import { StagehandInvalidArgumentError } from "../types/public/sdkErrors";
import {
SetInputFilesArgument,
SetInputFilePayload,
} from "../types/public/locator";
import { NormalizedFilePayload } from "../types/private/locator";

const DEFAULT_MIME_TYPE = "application/octet-stream";

/**
* Normalize user-provided setInputFiles arguments into in-memory payloads.
* - Resolves string paths relative to the provided base directory.
* - Validates that each path exists and is a regular file.
* - Converts all buffers into Node Buffers for downstream processing.
*/
export async function normalizeInputFiles(
files: SetInputFilesArgument,
opts: { baseDir?: string } = {},
): Promise<NormalizedFilePayload[]> {
if (files === null || files === undefined) return [];

const flattened = Array.isArray(files)
? (files as Array<string | SetInputFilePayload>)
: [files];
if (!flattened.length) return [];

const baseDir = opts.baseDir ?? process.cwd();
const normalized: NormalizedFilePayload[] = [];

for (const entry of flattened) {
if (typeof entry === "string") {
const absolutePath = path.isAbsolute(entry)
? entry
: path.resolve(baseDir, entry);
const stat = await statFile(absolutePath);
if (!stat.isFile()) {
throw new StagehandInvalidArgumentError(
`setInputFiles(): expected a file but received directory or special entry at ${absolutePath}`,
);
}
const buffer = await fs.readFile(absolutePath);
normalized.push({
name: path.basename(absolutePath) || "upload.bin",
mimeType: DEFAULT_MIME_TYPE,
buffer,
lastModified: stat.mtimeMs || Date.now(),
absolutePath,
});
continue;
}

if (entry && typeof entry === "object" && "buffer" in entry) {
const payload = entry as SetInputFilePayload;
const buffer = toBuffer(payload.buffer);
normalized.push({
name: payload.name || "upload.bin",
mimeType: payload.mimeType || DEFAULT_MIME_TYPE,
buffer,
lastModified:
typeof payload.lastModified === "number"
? payload.lastModified
: Date.now(),
});
continue;
}

throw new StagehandInvalidArgumentError(
"setInputFiles(): expected file path(s) or payload object(s)",
);
}

return normalized;
}

async function statFile(absolutePath: string): Promise<Stats> {
try {
return await fs.stat(absolutePath);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code;
if (code === "ENOENT") {
throw new StagehandInvalidArgumentError(
`setInputFiles(): file not found at ${absolutePath}`,
);
}
throw error;
}
}

export function toBuffer(
data: ArrayBuffer | Uint8Array | Buffer | string,
): Buffer {
if (Buffer.isBuffer(data)) return data;
if (data instanceof Uint8Array) return Buffer.from(data);
if (typeof data === "string") return Buffer.from(data);
if (data instanceof ArrayBuffer) return Buffer.from(new Uint8Array(data));
throw new StagehandInvalidArgumentError(
"Unsupported file payload buffer type",
);
}
Loading