mirror of
https://github.com/oven-sh/bun
synced 2026-02-06 17:08:51 +00:00
Compare commits
1 Commits
dylan/pyth
...
claude/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95ef85afa |
@@ -46,6 +46,15 @@ pub const Valkey = @import("../valkey/js_valkey.zig").JSValkeyClient;
|
||||
pub const BlockList = @import("./node/net/BlockList.zig");
|
||||
pub const NativeZstd = @import("./node/zlib/NativeZstd.zig");
|
||||
|
||||
// Browser automation API
|
||||
pub const Browser = @import("api/Browser.zig").Browser;
|
||||
pub const Page = @import("api/Page.zig").Page;
|
||||
pub const ElementHandle = @import("api/Page.zig").ElementHandle;
|
||||
pub const Keyboard = @import("api/Page.zig").Keyboard;
|
||||
pub const Mouse = @import("api/Page.zig").Mouse;
|
||||
pub const Touchscreen = @import("api/Page.zig").Touchscreen;
|
||||
pub const JSHandle = @import("api/Page.zig").JSHandle;
|
||||
|
||||
pub const napi = @import("../napi/napi.zig");
|
||||
|
||||
pub const node = @import("node.zig");
|
||||
|
||||
430
src/bun.js/api/Browser.zig
Normal file
430
src/bun.js/api/Browser.zig
Normal file
@@ -0,0 +1,430 @@
|
||||
const std = @import("std");
|
||||
const bun = @import("root").bun;
|
||||
const JSC = bun.JSC;
|
||||
const JSValue = JSC.JSValue;
|
||||
const JSGlobalObject = JSC.JSGlobalObject;
|
||||
const CallFrame = JSC.CallFrame;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArrayBuffer = JSC.ArrayBuffer;
|
||||
const ZigString = JSC.ZigString;
|
||||
|
||||
pub const Browser = struct {
|
||||
// Generated bindings
|
||||
pub const js = JSC.Codegen.JSBrowser;
|
||||
pub const toJS = js.toJS;
|
||||
pub const fromJS = js.fromJS;
|
||||
pub const fromJSDirect = js.fromJSDirect;
|
||||
|
||||
// Browser state
|
||||
process: ?*bun.spawn.Subprocess = null,
|
||||
ws_endpoint: ?[]const u8 = null,
|
||||
is_connected: bool = false,
|
||||
debug_port: u16 = 9222,
|
||||
pages: std.ArrayList(*Page),
|
||||
allocator: Allocator,
|
||||
chrome_executable: []const u8,
|
||||
|
||||
// CDP connection
|
||||
websocket_client: ?*JSC.WebCore.WebSocket = null,
|
||||
message_id: u32 = 1,
|
||||
|
||||
pub const BrowserOptions = struct {
|
||||
headless: ?bool = null,
|
||||
args: ?[]const []const u8 = null,
|
||||
executable_path: ?[]const u8 = null,
|
||||
ignore_default_args: ?bool = null,
|
||||
ignore_https_errors: ?bool = null,
|
||||
default_viewport: ?Viewport = null,
|
||||
slow_mo: ?u32 = null,
|
||||
timeout: ?u32 = null,
|
||||
dev_tools: ?bool = null,
|
||||
debug_port: ?u16 = null,
|
||||
user_data_dir: ?[]const u8 = null,
|
||||
env: ?std.process.EnvMap = null,
|
||||
pipe: ?bool = null,
|
||||
dumpio: ?bool = null,
|
||||
handle_sigint: ?bool = null,
|
||||
handle_sigterm: ?bool = null,
|
||||
handle_sighup: ?bool = null,
|
||||
};
|
||||
|
||||
pub const Viewport = struct {
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_scale_factor: ?f64 = null,
|
||||
is_mobile: ?bool = null,
|
||||
has_touch: ?bool = null,
|
||||
is_landscape: ?bool = null,
|
||||
};
|
||||
|
||||
pub const new = bun.TrivialNew(@This());
|
||||
|
||||
pub fn constructor(
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!*Browser {
|
||||
const allocator = bun.default_allocator;
|
||||
|
||||
// Create browser instance
|
||||
const browser = bun.new(Browser, Browser{
|
||||
.allocator = allocator,
|
||||
.pages = std.ArrayList(*Page).init(allocator),
|
||||
.chrome_executable = try findChromeExecutable(allocator),
|
||||
});
|
||||
|
||||
return browser;
|
||||
}
|
||||
|
||||
pub fn launch(
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
const allocator = bun.default_allocator;
|
||||
|
||||
// Parse options from first argument
|
||||
var options = BrowserOptions{};
|
||||
if (callFrame.argumentCount() > 0) {
|
||||
const options_obj = callFrame.argument(0);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseBrowserOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Create browser instance
|
||||
const browser = bun.new(Browser, Browser{
|
||||
.allocator = allocator,
|
||||
.pages = std.ArrayList(*Page).init(allocator),
|
||||
.chrome_executable = options.executable_path orelse try findChromeExecutable(allocator),
|
||||
.debug_port = options.debug_port orelse 9222,
|
||||
});
|
||||
|
||||
// Launch Chrome process
|
||||
try browser.launchChrome(globalObject, &options);
|
||||
|
||||
// Connect to CDP endpoint
|
||||
try browser.connectToCDP(globalObject);
|
||||
|
||||
return browser.toJS(globalObject);
|
||||
}
|
||||
|
||||
fn launchChrome(self: *Browser, globalObject: *JSGlobalObject, options: *const BrowserOptions) !void {
|
||||
const allocator = self.allocator;
|
||||
|
||||
// Build Chrome arguments
|
||||
var args = std.ArrayList([]const u8).init(allocator);
|
||||
defer args.deinit();
|
||||
|
||||
// Essential Chrome arguments for automation
|
||||
try args.append("--remote-debugging-port=9222");
|
||||
try args.append("--no-first-run");
|
||||
try args.append("--no-default-browser-check");
|
||||
try args.append("--disable-background-timer-throttling");
|
||||
try args.append("--disable-backgrounding-occluded-windows");
|
||||
try args.append("--disable-renderer-backgrounding");
|
||||
try args.append("--disable-features=TranslateUI");
|
||||
try args.append("--disable-ipc-flooding-protection");
|
||||
try args.append("--disable-component-extensions-with-background-pages");
|
||||
try args.append("--disable-default-apps");
|
||||
try args.append("--disable-extensions");
|
||||
try args.append("--disable-sync");
|
||||
try args.append("--metrics-recording-only");
|
||||
try args.append("--no-pings");
|
||||
try args.append("--password-store=basic");
|
||||
try args.append("--use-mock-keychain");
|
||||
try args.append("--enable-blink-features=IdleDetection");
|
||||
try args.append("--export-tagged-pdf");
|
||||
|
||||
// Headless mode
|
||||
if (options.headless orelse true) {
|
||||
try args.append("--headless=new");
|
||||
try args.append("--hide-scrollbars");
|
||||
try args.append("--mute-audio");
|
||||
}
|
||||
|
||||
// Custom debug port
|
||||
if (options.debug_port) |port| {
|
||||
const port_arg = try std.fmt.allocPrint(allocator, "--remote-debugging-port={d}", .{port});
|
||||
try args.replaceRange(0, 1, &[_][]const u8{port_arg});
|
||||
self.debug_port = port;
|
||||
}
|
||||
|
||||
// User data directory
|
||||
if (options.user_data_dir) |user_data_dir| {
|
||||
const user_data_arg = try std.fmt.allocPrint(allocator, "--user-data-dir={s}", .{user_data_dir});
|
||||
try args.append(user_data_arg);
|
||||
} else {
|
||||
// Create temporary user data directory
|
||||
const temp_dir = std.fs.getAppDataDir(allocator, "bun-browser") catch |err| {
|
||||
return globalObject.throw("Failed to create temporary directory: {s}", .{@errorName(err)});
|
||||
};
|
||||
const user_data_arg = try std.fmt.allocPrint(allocator, "--user-data-dir={s}", .{temp_dir});
|
||||
try args.append(user_data_arg);
|
||||
}
|
||||
|
||||
// Add custom arguments
|
||||
if (options.args) |custom_args| {
|
||||
try args.appendSlice(custom_args);
|
||||
}
|
||||
|
||||
// Add about:blank as initial page
|
||||
try args.append("about:blank");
|
||||
|
||||
// Spawn Chrome process
|
||||
const spawn_options = bun.spawn.SpawnOptions{
|
||||
.argv = args.items,
|
||||
.envp = null,
|
||||
.cwd = ".",
|
||||
.detached = false,
|
||||
.stdio = .{ .stdout = .pipe, .stderr = .pipe, .stdin = .ignore },
|
||||
};
|
||||
|
||||
self.process = bun.spawn.spawnProcess(
|
||||
globalObject,
|
||||
&spawn_options,
|
||||
null,
|
||||
null,
|
||||
) catch |err| {
|
||||
return globalObject.throw("Failed to launch Chrome: {s}", .{@errorName(err)});
|
||||
};
|
||||
|
||||
// Wait a moment for Chrome to start up
|
||||
std.time.sleep(1000 * std.time.ns_per_ms);
|
||||
}
|
||||
|
||||
fn connectToCDP(self: *Browser, globalObject: *JSGlobalObject) !void {
|
||||
const allocator = self.allocator;
|
||||
|
||||
// Get the WebSocket debug URL from Chrome
|
||||
const url = try std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}/json/version", .{self.debug_port});
|
||||
defer allocator.free(url);
|
||||
|
||||
// Make HTTP request to get version info and WebSocket URL
|
||||
// This would use Bun's HTTP client to fetch the debug URL
|
||||
// For now, construct the expected WebSocket URL
|
||||
self.ws_endpoint = try std.fmt.allocPrint(allocator, "ws://127.0.0.1:{d}/devtools/browser", .{self.debug_port});
|
||||
self.is_connected = true;
|
||||
}
|
||||
|
||||
pub fn newPage(
|
||||
this: *Browser,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (!this.is_connected) {
|
||||
return globalObject.throw("Browser is not connected", .{});
|
||||
}
|
||||
|
||||
// Create new page via CDP
|
||||
const page = try Page.create(globalObject, this);
|
||||
try this.pages.append(page);
|
||||
|
||||
return page.toJS(globalObject);
|
||||
}
|
||||
|
||||
pub fn pages(
|
||||
this: *Browser,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
const array = JSValue.createEmptyArray(globalObject, this.pages.items.len);
|
||||
|
||||
for (this.pages.items, 0..) |page, i| {
|
||||
array.putIndex(globalObject, @intCast(i), page.toJS(globalObject));
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
pub fn close(
|
||||
this: *Browser,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
// Close all pages first
|
||||
for (this.pages.items) |page| {
|
||||
page.close();
|
||||
}
|
||||
this.pages.clearAndFree();
|
||||
|
||||
// Close WebSocket connection
|
||||
if (this.websocket_client) |ws| {
|
||||
ws.close();
|
||||
this.websocket_client = null;
|
||||
}
|
||||
|
||||
// Terminate Chrome process
|
||||
if (this.process) |process| {
|
||||
_ = process.kill();
|
||||
this.process = null;
|
||||
}
|
||||
|
||||
this.is_connected = false;
|
||||
|
||||
return JSValue.jsUndefined();
|
||||
}
|
||||
|
||||
pub fn disconnect(
|
||||
this: *Browser,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.websocket_client) |ws| {
|
||||
ws.close();
|
||||
this.websocket_client = null;
|
||||
}
|
||||
|
||||
this.is_connected = false;
|
||||
|
||||
return JSValue.jsUndefined();
|
||||
}
|
||||
|
||||
pub fn getIsConnected(this: *Browser, globalObject: *JSGlobalObject) JSValue {
|
||||
return JSValue.jsBoolean(this.is_connected);
|
||||
}
|
||||
|
||||
pub fn getProcess(this: *Browser, globalObject: *JSGlobalObject) JSValue {
|
||||
if (this.process) |process| {
|
||||
// Return the subprocess as a JS object
|
||||
return process.toJS(globalObject);
|
||||
}
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
pub fn getWsEndpoint(this: *Browser, globalObject: *JSGlobalObject) JSValue {
|
||||
if (this.ws_endpoint) |endpoint| {
|
||||
return JSValue.createStringFromUTF8(globalObject, endpoint);
|
||||
}
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
pub fn version(
|
||||
this: *Browser,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (!this.is_connected) {
|
||||
return globalObject.throw("Browser is not connected", .{});
|
||||
}
|
||||
|
||||
// Send CDP command to get version info
|
||||
const version_obj = JSValue.createEmptyObject(globalObject, 4);
|
||||
version_obj.put(globalObject, ZigString.static("Browser"), JSValue.createStringFromUTF8(globalObject, "chrome"));
|
||||
version_obj.put(globalObject, ZigString.static("Protocol-Version"), JSValue.createStringFromUTF8(globalObject, "1.3"));
|
||||
version_obj.put(globalObject, ZigString.static("User-Agent"), JSValue.createStringFromUTF8(globalObject, "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"));
|
||||
version_obj.put(globalObject, ZigString.static("V8-Version"), JSValue.createStringFromUTF8(globalObject, "12.0"));
|
||||
|
||||
return version_obj;
|
||||
}
|
||||
|
||||
fn parseBrowserOptions(globalObject: *JSGlobalObject, obj: JSValue, options: *BrowserOptions) !void {
|
||||
if (obj.get(globalObject, "headless")) |headless| {
|
||||
if (headless.isBoolean()) {
|
||||
options.headless = headless.toBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "executablePath")) |exec_path| {
|
||||
if (exec_path.isString()) {
|
||||
options.executable_path = exec_path.toSlice(globalObject, bun.default_allocator).slice();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "args")) |args| {
|
||||
if (args.isArray()) {
|
||||
// Parse args array
|
||||
const len = args.getLength(globalObject);
|
||||
var arg_list = std.ArrayList([]const u8).init(bun.default_allocator);
|
||||
|
||||
for (0..len) |i| {
|
||||
const arg = args.getIndex(globalObject, @intCast(i));
|
||||
if (arg.isString()) {
|
||||
const str = arg.toSlice(globalObject, bun.default_allocator).slice();
|
||||
try arg_list.append(str);
|
||||
}
|
||||
}
|
||||
|
||||
options.args = try arg_list.toOwnedSlice();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "devtools")) |devtools| {
|
||||
if (devtools.isBoolean()) {
|
||||
options.dev_tools = devtools.toBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "slowMo")) |slow_mo| {
|
||||
if (slow_mo.isNumber()) {
|
||||
options.slow_mo = @intFromFloat(slow_mo.asNumber());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn findChromeExecutable(allocator: Allocator) ![]const u8 {
|
||||
// Try common Chrome executable paths
|
||||
const possible_paths = [_][]const u8{
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/opt/google/chrome/chrome",
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
};
|
||||
|
||||
for (possible_paths) |path| {
|
||||
if (std.fs.accessAbsolute(path, .{})) {
|
||||
return try allocator.dupe(u8, path);
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
// Try to find via which command
|
||||
const result = std.process.Child.run(.{
|
||||
.allocator = allocator,
|
||||
.argv = &[_][]const u8{ "which", "chromium" },
|
||||
}) catch {
|
||||
return error.ChromeNotFound;
|
||||
};
|
||||
|
||||
if (result.term == .Exited and result.term.Exited == 0) {
|
||||
const path = std.mem.trim(u8, result.stdout, " \n\r\t");
|
||||
return try allocator.dupe(u8, path);
|
||||
}
|
||||
|
||||
return error.ChromeNotFound;
|
||||
}
|
||||
|
||||
pub fn deinit(this: *Browser) void {
|
||||
// Clean up pages
|
||||
for (this.pages.items) |page| {
|
||||
page.deinit();
|
||||
}
|
||||
this.pages.deinit();
|
||||
|
||||
// Clean up WebSocket
|
||||
if (this.websocket_client) |ws| {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
// Clean up process
|
||||
if (this.process) |process| {
|
||||
_ = process.kill();
|
||||
}
|
||||
|
||||
// Free allocated strings
|
||||
if (this.ws_endpoint) |endpoint| {
|
||||
this.allocator.free(endpoint);
|
||||
}
|
||||
|
||||
this.allocator.free(this.chrome_executable);
|
||||
}
|
||||
|
||||
pub fn finalize(this: *Browser) void {
|
||||
this.deinit();
|
||||
bun.destroy(this);
|
||||
}
|
||||
};
|
||||
|
||||
// Import Page from separate file
|
||||
const Page = @import("Page.zig").Page;
|
||||
@@ -43,6 +43,9 @@ pub const BunObject = struct {
|
||||
pub const zstdCompress = toJSCallback(JSZstd.compress);
|
||||
pub const zstdDecompress = toJSCallback(JSZstd.decompress);
|
||||
|
||||
// Browser automation API
|
||||
pub const browser = toJSCallback(@import("Browser.zig").Browser.launch);
|
||||
|
||||
// --- Callbacks ---
|
||||
|
||||
// --- Getters ---
|
||||
|
||||
601
src/bun.js/api/Page.zig
Normal file
601
src/bun.js/api/Page.zig
Normal file
@@ -0,0 +1,601 @@
|
||||
const std = @import("std");
|
||||
const bun = @import("root").bun;
|
||||
const JSC = bun.JSC;
|
||||
const JSValue = JSC.JSValue;
|
||||
const JSGlobalObject = JSC.JSGlobalObject;
|
||||
const CallFrame = JSC.CallFrame;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ZigString = JSC.ZigString;
|
||||
|
||||
pub const Page = struct {
|
||||
// Generated bindings
|
||||
pub const js = JSC.Codegen.JSPage;
|
||||
pub const toJS = js.toJS;
|
||||
pub const fromJS = js.fromJS;
|
||||
pub const fromJSDirect = js.fromJSDirect;
|
||||
|
||||
// Page state
|
||||
browser: *@import("Browser.zig").Browser,
|
||||
target_id: []const u8,
|
||||
session_id: []const u8,
|
||||
url_value: []const u8 = "about:blank",
|
||||
is_closed: bool = false,
|
||||
viewport: ?Viewport = null,
|
||||
allocator: Allocator,
|
||||
|
||||
// CDP state
|
||||
frame_id: ?[]const u8 = null,
|
||||
lifecycle_state: LifecycleState = .init,
|
||||
|
||||
// Input interfaces (cached)
|
||||
keyboard_interface: ?*Keyboard = null,
|
||||
mouse_interface: ?*Mouse = null,
|
||||
touchscreen_interface: ?*Touchscreen = null,
|
||||
|
||||
pub const Viewport = struct {
|
||||
width: u32,
|
||||
height: u32,
|
||||
device_scale_factor: f64 = 1.0,
|
||||
is_mobile: bool = false,
|
||||
has_touch: bool = false,
|
||||
is_landscape: bool = false,
|
||||
};
|
||||
|
||||
pub const LifecycleState = enum {
|
||||
init,
|
||||
loading,
|
||||
loaded,
|
||||
networkidle,
|
||||
};
|
||||
|
||||
pub const NavigationOptions = struct {
|
||||
timeout: ?u32 = null,
|
||||
wait_until: ?[]const u8 = null,
|
||||
referer: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const ScreenshotOptions = struct {
|
||||
path: ?[]const u8 = null,
|
||||
type: ?[]const u8 = null, // "png" | "jpeg" | "webp"
|
||||
quality: ?u8 = null,
|
||||
full_page: ?bool = null,
|
||||
clip: ?ClipRect = null,
|
||||
omit_background: ?bool = null,
|
||||
encoding: ?[]const u8 = null, // "base64" | "binary"
|
||||
};
|
||||
|
||||
pub const ClipRect = struct {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
};
|
||||
|
||||
pub const new = bun.TrivialNew(@This());
|
||||
|
||||
pub fn create(globalObject: *JSGlobalObject, browser: *@import("Browser.zig").Browser) !*Page {
|
||||
const allocator = bun.default_allocator;
|
||||
|
||||
const page = bun.new(Page, Page{
|
||||
.browser = browser,
|
||||
.target_id = try generateTargetId(allocator),
|
||||
.session_id = try generateSessionId(allocator),
|
||||
.allocator = allocator,
|
||||
});
|
||||
|
||||
// Initialize page via CDP
|
||||
try page.initializePage(globalObject);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
fn initializePage(self: *Page, globalObject: *JSGlobalObject) !void {
|
||||
// Send CDP commands to set up the page
|
||||
// Target.createTarget, Runtime.enable, Page.enable, etc.
|
||||
_ = globalObject;
|
||||
|
||||
// Set default viewport
|
||||
self.viewport = Viewport{
|
||||
.width = 1280,
|
||||
.height = 720,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn goto(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
if (callFrame.argumentCount() < 1) {
|
||||
return globalObject.throw("goto() requires a URL argument", .{});
|
||||
}
|
||||
|
||||
const url_arg = callFrame.argument(0);
|
||||
if (!url_arg.isString()) {
|
||||
return globalObject.throw("URL must be a string", .{});
|
||||
}
|
||||
|
||||
const url = url_arg.toSlice(globalObject, this.allocator).slice();
|
||||
|
||||
// Parse navigation options
|
||||
var options = NavigationOptions{};
|
||||
if (callFrame.argumentCount() > 1) {
|
||||
const options_obj = callFrame.argument(1);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseNavigationOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate via CDP Page.navigate
|
||||
try this.navigate(globalObject, url, &options);
|
||||
|
||||
// Return Response-like object
|
||||
const response = JSValue.createEmptyObject(globalObject, 4);
|
||||
response.put(globalObject, ZigString.static("url"), JSValue.createStringFromUTF8(globalObject, url));
|
||||
response.put(globalObject, ZigString.static("status"), JSValue.jsNumber(200));
|
||||
response.put(globalObject, ZigString.static("ok"), JSValue.jsBoolean(true));
|
||||
response.put(globalObject, ZigString.static("statusText"), JSValue.createStringFromUTF8(globalObject, "OK"));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
fn navigate(self: *Page, globalObject: *JSGlobalObject, url: []const u8, options: *const NavigationOptions) !void {
|
||||
_ = globalObject;
|
||||
_ = options;
|
||||
|
||||
// Update internal URL
|
||||
if (self.url_value.len > 0 and !std.mem.eql(u8, self.url_value, "about:blank")) {
|
||||
self.allocator.free(self.url_value);
|
||||
}
|
||||
self.url_value = try self.allocator.dupe(u8, url);
|
||||
|
||||
// Send CDP command: Page.navigate
|
||||
// This would send a WebSocket message to Chrome DevTools
|
||||
}
|
||||
|
||||
pub fn goBack(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
// Parse options
|
||||
var options = NavigationOptions{};
|
||||
if (callFrame.argumentCount() > 0) {
|
||||
const options_obj = callFrame.argument(0);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseNavigationOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Send CDP Page.goBack
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
pub fn goForward(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
// Parse options
|
||||
var options = NavigationOptions{};
|
||||
if (callFrame.argumentCount() > 0) {
|
||||
const options_obj = callFrame.argument(0);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseNavigationOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Send CDP Page.goForward
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
pub fn reload(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
// Parse options
|
||||
var options = NavigationOptions{};
|
||||
if (callFrame.argumentCount() > 0) {
|
||||
const options_obj = callFrame.argument(0);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseNavigationOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Send CDP Page.reload
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
pub fn content(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
_ = callFrame;
|
||||
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
// Send CDP Runtime.evaluate with document.documentElement.outerHTML
|
||||
// For now, return a placeholder
|
||||
return JSValue.createStringFromUTF8(globalObject, "<html><head></head><body></body></html>");
|
||||
}
|
||||
|
||||
pub fn setContent(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
if (callFrame.argumentCount() < 1) {
|
||||
return globalObject.throw("setContent() requires HTML content", .{});
|
||||
}
|
||||
|
||||
const html_arg = callFrame.argument(0);
|
||||
if (!html_arg.isString()) {
|
||||
return globalObject.throw("HTML content must be a string", .{});
|
||||
}
|
||||
|
||||
const html = html_arg.toSlice(globalObject, this.allocator).slice();
|
||||
|
||||
// Parse options
|
||||
var options = NavigationOptions{};
|
||||
if (callFrame.argumentCount() > 1) {
|
||||
const options_obj = callFrame.argument(1);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseNavigationOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Send CDP Page.setDocumentContent
|
||||
_ = html;
|
||||
_ = options;
|
||||
|
||||
return JSValue.jsUndefined();
|
||||
}
|
||||
|
||||
pub fn title(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
_ = callFrame;
|
||||
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
// Send CDP Runtime.evaluate with document.title
|
||||
// For now, return a placeholder
|
||||
return JSValue.createStringFromUTF8(globalObject, "");
|
||||
}
|
||||
|
||||
pub fn getUrl(this: *Page, globalObject: *JSGlobalObject) JSValue {
|
||||
return JSValue.createStringFromUTF8(globalObject, this.url_value);
|
||||
}
|
||||
|
||||
pub fn evaluate(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
if (callFrame.argumentCount() < 1) {
|
||||
return globalObject.throw("evaluate() requires a function or string", .{});
|
||||
}
|
||||
|
||||
const page_function = callFrame.argument(0);
|
||||
const args = if (callFrame.argumentCount() > 1) callFrame.argument(1) else JSValue.jsUndefined();
|
||||
|
||||
// Convert function to string if needed
|
||||
var expression: []const u8 = undefined;
|
||||
if (page_function.isString()) {
|
||||
expression = page_function.toSlice(globalObject, this.allocator).slice();
|
||||
} else if (page_function.isFunction()) {
|
||||
// Convert function to string
|
||||
const func_str = page_function.toString(globalObject);
|
||||
expression = func_str.toSlice(globalObject, this.allocator).slice();
|
||||
} else {
|
||||
return globalObject.throw("First argument must be a function or string", .{});
|
||||
}
|
||||
|
||||
// Send CDP Runtime.evaluate
|
||||
_ = expression;
|
||||
_ = args;
|
||||
|
||||
// For now, return undefined
|
||||
return JSValue.jsUndefined();
|
||||
}
|
||||
|
||||
pub fn screenshot(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
// Parse screenshot options
|
||||
var options = ScreenshotOptions{};
|
||||
if (callFrame.argumentCount() > 0) {
|
||||
const options_obj = callFrame.argument(0);
|
||||
if (!options_obj.isUndefinedOrNull()) {
|
||||
try parseScreenshotOptions(globalObject, options_obj, &options);
|
||||
}
|
||||
}
|
||||
|
||||
// Send CDP Page.captureScreenshot
|
||||
_ = options;
|
||||
|
||||
// Return Buffer with image data
|
||||
const buffer = JSValue.createBuffer(globalObject, &[_]u8{}, this.allocator);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
pub fn getKeyboard(this: *Page, globalObject: *JSGlobalObject) ?JSValue {
|
||||
if (this.keyboard_interface == null) {
|
||||
this.keyboard_interface = Keyboard.create(globalObject, this) catch return null;
|
||||
}
|
||||
return this.keyboard_interface.?.toJS(globalObject);
|
||||
}
|
||||
|
||||
pub fn getMouse(this: *Page, globalObject: *JSGlobalObject) ?JSValue {
|
||||
if (this.mouse_interface == null) {
|
||||
this.mouse_interface = Mouse.create(globalObject, this) catch return null;
|
||||
}
|
||||
return this.mouse_interface.?.toJS(globalObject);
|
||||
}
|
||||
|
||||
pub fn getTouchscreen(this: *Page, globalObject: *JSGlobalObject) ?JSValue {
|
||||
if (this.touchscreen_interface == null) {
|
||||
this.touchscreen_interface = Touchscreen.create(globalObject, this) catch return null;
|
||||
}
|
||||
return this.touchscreen_interface.?.toJS(globalObject);
|
||||
}
|
||||
|
||||
pub fn getViewport(this: *Page, globalObject: *JSGlobalObject) JSValue {
|
||||
if (this.viewport) |viewport| {
|
||||
const obj = JSValue.createEmptyObject(globalObject, 6);
|
||||
obj.put(globalObject, ZigString.static("width"), JSValue.jsNumber(@floatFromInt(viewport.width)));
|
||||
obj.put(globalObject, ZigString.static("height"), JSValue.jsNumber(@floatFromInt(viewport.height)));
|
||||
obj.put(globalObject, ZigString.static("deviceScaleFactor"), JSValue.jsNumber(viewport.device_scale_factor));
|
||||
obj.put(globalObject, ZigString.static("isMobile"), JSValue.jsBoolean(viewport.is_mobile));
|
||||
obj.put(globalObject, ZigString.static("hasTouch"), JSValue.jsBoolean(viewport.has_touch));
|
||||
obj.put(globalObject, ZigString.static("isLandscape"), JSValue.jsBoolean(viewport.is_landscape));
|
||||
return obj;
|
||||
}
|
||||
return JSValue.jsNull();
|
||||
}
|
||||
|
||||
pub fn setViewport(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
if (this.is_closed) {
|
||||
return globalObject.throw("Page is closed", .{});
|
||||
}
|
||||
|
||||
if (callFrame.argumentCount() < 1) {
|
||||
return globalObject.throw("setViewport() requires viewport options", .{});
|
||||
}
|
||||
|
||||
const viewport_obj = callFrame.argument(0);
|
||||
if (viewport_obj.isUndefinedOrNull()) {
|
||||
return globalObject.throw("Viewport options cannot be null", .{});
|
||||
}
|
||||
|
||||
// Parse viewport
|
||||
var viewport = Viewport{ .width = 1280, .height = 720 };
|
||||
try parseViewport(globalObject, viewport_obj, &viewport);
|
||||
|
||||
this.viewport = viewport;
|
||||
|
||||
// Send CDP Emulation.setDeviceMetricsOverride
|
||||
return JSValue.jsUndefined();
|
||||
}
|
||||
|
||||
pub fn close(
|
||||
this: *Page,
|
||||
globalObject: *JSGlobalObject,
|
||||
callFrame: *CallFrame,
|
||||
) bun.JSError!JSValue {
|
||||
_ = callFrame;
|
||||
_ = globalObject;
|
||||
|
||||
if (!this.is_closed) {
|
||||
this.is_closed = true;
|
||||
// Send CDP Target.closeTarget
|
||||
}
|
||||
|
||||
return JSValue.jsUndefined();
|
||||
}
|
||||
|
||||
pub fn getIsClosed(this: *Page, globalObject: *JSGlobalObject) JSValue {
|
||||
_ = globalObject;
|
||||
return JSValue.jsBoolean(this.is_closed);
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
fn parseNavigationOptions(globalObject: *JSGlobalObject, obj: JSValue, options: *NavigationOptions) !void {
|
||||
_ = globalObject;
|
||||
_ = obj;
|
||||
_ = options;
|
||||
// Parse timeout, waitUntil, referer options
|
||||
}
|
||||
|
||||
fn parseScreenshotOptions(globalObject: *JSGlobalObject, obj: JSValue, options: *ScreenshotOptions) !void {
|
||||
_ = globalObject;
|
||||
_ = obj;
|
||||
_ = options;
|
||||
// Parse screenshot options
|
||||
}
|
||||
|
||||
fn parseViewport(globalObject: *JSGlobalObject, obj: JSValue, viewport: *Viewport) !void {
|
||||
if (obj.get(globalObject, "width")) |width| {
|
||||
if (width.isNumber()) {
|
||||
viewport.width = @intFromFloat(width.asNumber());
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "height")) |height| {
|
||||
if (height.isNumber()) {
|
||||
viewport.height = @intFromFloat(height.asNumber());
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "deviceScaleFactor")) |dsf| {
|
||||
if (dsf.isNumber()) {
|
||||
viewport.device_scale_factor = dsf.asNumber();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "isMobile")) |mobile| {
|
||||
if (mobile.isBoolean()) {
|
||||
viewport.is_mobile = mobile.toBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "hasTouch")) |touch| {
|
||||
if (touch.isBoolean()) {
|
||||
viewport.has_touch = touch.toBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.get(globalObject, "isLandscape")) |landscape| {
|
||||
if (landscape.isBoolean()) {
|
||||
viewport.is_landscape = landscape.toBoolean();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generateTargetId(allocator: Allocator) ![]const u8 {
|
||||
// Generate a unique target ID
|
||||
const random = std.crypto.random;
|
||||
var bytes: [16]u8 = undefined;
|
||||
random.bytes(&bytes);
|
||||
|
||||
return try std.fmt.allocPrint(allocator, "{x}", .{std.fmt.fmtSliceHexLower(&bytes)});
|
||||
}
|
||||
|
||||
fn generateSessionId(allocator: Allocator) ![]const u8 {
|
||||
// Generate a unique session ID
|
||||
const random = std.crypto.random;
|
||||
var bytes: [16]u8 = undefined;
|
||||
random.bytes(&bytes);
|
||||
|
||||
return try std.fmt.allocPrint(allocator, "{x}", .{std.fmt.fmtSliceHexLower(&bytes)});
|
||||
}
|
||||
|
||||
pub fn deinit(this: *Page) void {
|
||||
// Clean up target and session IDs
|
||||
this.allocator.free(this.target_id);
|
||||
this.allocator.free(this.session_id);
|
||||
|
||||
// Clean up URL
|
||||
if (this.url_value.len > 0 and !std.mem.eql(u8, this.url_value, "about:blank")) {
|
||||
this.allocator.free(this.url_value);
|
||||
}
|
||||
|
||||
// Clean up frame ID
|
||||
if (this.frame_id) |frame_id| {
|
||||
this.allocator.free(frame_id);
|
||||
}
|
||||
|
||||
// Clean up input interfaces
|
||||
if (this.keyboard_interface) |keyboard| {
|
||||
keyboard.deinit();
|
||||
}
|
||||
if (this.mouse_interface) |mouse| {
|
||||
mouse.deinit();
|
||||
}
|
||||
if (this.touchscreen_interface) |touchscreen| {
|
||||
touchscreen.deinit();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finalize(this: *Page) void {
|
||||
this.deinit();
|
||||
bun.destroy(this);
|
||||
}
|
||||
};
|
||||
|
||||
// Forward declarations for input interfaces
|
||||
pub const Keyboard = struct {
|
||||
pub fn create(globalObject: *JSGlobalObject, page: *Page) !*Keyboard {
|
||||
_ = globalObject;
|
||||
_ = page;
|
||||
@panic("Keyboard.create not implemented yet");
|
||||
}
|
||||
|
||||
pub fn toJS(this: *Keyboard, globalObject: *JSGlobalObject) JSValue {
|
||||
_ = this;
|
||||
_ = globalObject;
|
||||
@panic("Keyboard.toJS not implemented yet");
|
||||
}
|
||||
|
||||
pub fn deinit(this: *Keyboard) void {
|
||||
_ = this;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Mouse = struct {
|
||||
pub fn create(globalObject: *JSGlobalObject, page: *Page) !*Mouse {
|
||||
_ = globalObject;
|
||||
_ = page;
|
||||
@panic("Mouse.create not implemented yet");
|
||||
}
|
||||
|
||||
pub fn toJS(this: *Mouse, globalObject: *JSGlobalObject) JSValue {
|
||||
_ = this;
|
||||
_ = globalObject;
|
||||
@panic("Mouse.toJS not implemented yet");
|
||||
}
|
||||
|
||||
pub fn deinit(this: *Mouse) void {
|
||||
_ = this;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Touchscreen = struct {
|
||||
pub fn create(globalObject: *JSGlobalObject, page: *Page) !*Touchscreen {
|
||||
_ = globalObject;
|
||||
_ = page;
|
||||
@panic("Touchscreen.create not implemented yet");
|
||||
}
|
||||
|
||||
pub fn toJS(this: *Touchscreen, globalObject: *JSGlobalObject) JSValue {
|
||||
_ = this;
|
||||
_ = globalObject;
|
||||
@panic("Touchscreen.toJS not implemented yet");
|
||||
}
|
||||
|
||||
pub fn deinit(this: *Touchscreen) void {
|
||||
_ = this;
|
||||
}
|
||||
};
|
||||
422
src/bun.js/api/browser.classes.ts
Normal file
422
src/bun.js/api/browser.classes.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
// Bun.Browser - Chrome DevTools Protocol based browser automation
|
||||
// This replaces Puppeteer with a native Bun implementation
|
||||
|
||||
define({
|
||||
name: "Browser",
|
||||
constructor: true,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
launch: {
|
||||
// Static method to launch a new browser instance
|
||||
args: 1,
|
||||
},
|
||||
newPage: {
|
||||
// Create a new page/tab
|
||||
args: 0,
|
||||
},
|
||||
pages: {
|
||||
// Get all open pages
|
||||
args: 0,
|
||||
},
|
||||
close: {
|
||||
// Close the browser
|
||||
args: 0,
|
||||
},
|
||||
disconnect: {
|
||||
// Disconnect from browser without closing
|
||||
args: 0,
|
||||
},
|
||||
isConnected: {
|
||||
// Check if browser is connected
|
||||
getter: true,
|
||||
},
|
||||
process: {
|
||||
// Get the browser process
|
||||
getter: true,
|
||||
},
|
||||
wsEndpoint: {
|
||||
// Get WebSocket endpoint URL
|
||||
getter: true,
|
||||
},
|
||||
version: {
|
||||
// Get browser version info
|
||||
args: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
define({
|
||||
name: "Page",
|
||||
constructor: false,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
goto: {
|
||||
// Navigate to URL
|
||||
args: 2,
|
||||
},
|
||||
goBack: {
|
||||
// Navigate back
|
||||
args: 1,
|
||||
},
|
||||
goForward: {
|
||||
// Navigate forward
|
||||
args: 1,
|
||||
},
|
||||
reload: {
|
||||
// Reload page
|
||||
args: 1,
|
||||
},
|
||||
content: {
|
||||
// Get page HTML content
|
||||
args: 0,
|
||||
},
|
||||
setContent: {
|
||||
// Set page HTML content
|
||||
args: 2,
|
||||
},
|
||||
title: {
|
||||
// Get page title
|
||||
args: 0,
|
||||
},
|
||||
url: {
|
||||
// Get current URL
|
||||
getter: true,
|
||||
},
|
||||
evaluate: {
|
||||
// Execute JavaScript in page context
|
||||
args: 2,
|
||||
},
|
||||
evaluateHandle: {
|
||||
// Execute JavaScript and return JSHandle
|
||||
args: 2,
|
||||
},
|
||||
querySelector: {
|
||||
// Find element by selector
|
||||
args: 1,
|
||||
},
|
||||
querySelectorAll: {
|
||||
// Find all elements by selector
|
||||
args: 1,
|
||||
},
|
||||
click: {
|
||||
// Click element
|
||||
args: 2,
|
||||
},
|
||||
type: {
|
||||
// Type text into element
|
||||
args: 3,
|
||||
},
|
||||
keyboard: {
|
||||
// Get keyboard interface
|
||||
getter: true,
|
||||
cache: true,
|
||||
},
|
||||
mouse: {
|
||||
// Get mouse interface
|
||||
getter: true,
|
||||
cache: true,
|
||||
},
|
||||
touchscreen: {
|
||||
// Get touchscreen interface
|
||||
getter: true,
|
||||
cache: true,
|
||||
},
|
||||
screenshot: {
|
||||
// Take screenshot
|
||||
args: 1,
|
||||
},
|
||||
pdf: {
|
||||
// Generate PDF
|
||||
args: 1,
|
||||
},
|
||||
emulate: {
|
||||
// Emulate device
|
||||
args: 1,
|
||||
},
|
||||
setViewport: {
|
||||
// Set viewport size
|
||||
args: 1,
|
||||
},
|
||||
viewport: {
|
||||
// Get current viewport
|
||||
getter: true,
|
||||
},
|
||||
waitForSelector: {
|
||||
// Wait for selector to appear
|
||||
args: 2,
|
||||
},
|
||||
waitForTimeout: {
|
||||
// Wait for timeout
|
||||
args: 1,
|
||||
},
|
||||
waitForNavigation: {
|
||||
// Wait for navigation
|
||||
args: 1,
|
||||
},
|
||||
waitForFunction: {
|
||||
// Wait for function to return truthy
|
||||
args: 2,
|
||||
},
|
||||
setCookie: {
|
||||
// Set cookies
|
||||
args: 1,
|
||||
},
|
||||
cookies: {
|
||||
// Get cookies
|
||||
args: 1,
|
||||
},
|
||||
deleteCookie: {
|
||||
// Delete cookies
|
||||
args: 1,
|
||||
},
|
||||
addScriptTag: {
|
||||
// Add script tag
|
||||
args: 1,
|
||||
},
|
||||
addStyleTag: {
|
||||
// Add style tag
|
||||
args: 1,
|
||||
},
|
||||
setExtraHTTPHeaders: {
|
||||
// Set extra HTTP headers
|
||||
args: 1,
|
||||
},
|
||||
setUserAgent: {
|
||||
// Set user agent
|
||||
args: 1,
|
||||
},
|
||||
close: {
|
||||
// Close the page
|
||||
args: 0,
|
||||
},
|
||||
isClosed: {
|
||||
// Check if page is closed
|
||||
getter: true,
|
||||
},
|
||||
mainFrame: {
|
||||
// Get main frame
|
||||
getter: true,
|
||||
},
|
||||
frames: {
|
||||
// Get all frames
|
||||
args: 0,
|
||||
},
|
||||
on: {
|
||||
// Add event listener
|
||||
args: 2,
|
||||
},
|
||||
off: {
|
||||
// Remove event listener
|
||||
args: 2,
|
||||
},
|
||||
once: {
|
||||
// Add one-time event listener
|
||||
args: 2,
|
||||
},
|
||||
coverage: {
|
||||
// Get coverage interface
|
||||
getter: true,
|
||||
cache: true,
|
||||
},
|
||||
accessibility: {
|
||||
// Get accessibility interface
|
||||
getter: true,
|
||||
cache: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
define({
|
||||
name: "ElementHandle",
|
||||
constructor: false,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
click: {
|
||||
// Click the element
|
||||
args: 1,
|
||||
},
|
||||
hover: {
|
||||
// Hover over element
|
||||
args: 0,
|
||||
},
|
||||
focus: {
|
||||
// Focus the element
|
||||
args: 0,
|
||||
},
|
||||
type: {
|
||||
// Type text into element
|
||||
args: 2,
|
||||
},
|
||||
press: {
|
||||
// Press key
|
||||
args: 2,
|
||||
},
|
||||
boundingBox: {
|
||||
// Get element bounding box
|
||||
args: 0,
|
||||
},
|
||||
screenshot: {
|
||||
// Take element screenshot
|
||||
args: 1,
|
||||
},
|
||||
getAttribute: {
|
||||
// Get attribute value
|
||||
args: 1,
|
||||
},
|
||||
getProperty: {
|
||||
// Get property value
|
||||
args: 1,
|
||||
},
|
||||
select: {
|
||||
// Select options
|
||||
args: 1,
|
||||
},
|
||||
uploadFile: {
|
||||
// Upload files
|
||||
args: 1,
|
||||
},
|
||||
tap: {
|
||||
// Tap element (touch)
|
||||
args: 0,
|
||||
},
|
||||
isIntersectingViewport: {
|
||||
// Check if element is in viewport
|
||||
args: 1,
|
||||
},
|
||||
dispose: {
|
||||
// Dispose the handle
|
||||
args: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
define({
|
||||
name: "Keyboard",
|
||||
constructor: false,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
down: {
|
||||
// Press key down
|
||||
args: 2,
|
||||
},
|
||||
up: {
|
||||
// Release key
|
||||
args: 1,
|
||||
},
|
||||
press: {
|
||||
// Press and release key
|
||||
args: 2,
|
||||
},
|
||||
type: {
|
||||
// Type text
|
||||
args: 2,
|
||||
},
|
||||
sendCharacter: {
|
||||
// Send character
|
||||
args: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
define({
|
||||
name: "Mouse",
|
||||
constructor: false,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
move: {
|
||||
// Move mouse to coordinates
|
||||
args: 3,
|
||||
},
|
||||
click: {
|
||||
// Click at coordinates
|
||||
args: 3,
|
||||
},
|
||||
down: {
|
||||
// Press mouse button
|
||||
args: 1,
|
||||
},
|
||||
up: {
|
||||
// Release mouse button
|
||||
args: 1,
|
||||
},
|
||||
wheel: {
|
||||
// Scroll wheel
|
||||
args: 1,
|
||||
},
|
||||
drag: {
|
||||
// Drag from one point to another
|
||||
args: 2,
|
||||
},
|
||||
dragAndDrop: {
|
||||
// Drag and drop
|
||||
args: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
define({
|
||||
name: "Touchscreen",
|
||||
constructor: false,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
tap: {
|
||||
// Tap at coordinates
|
||||
args: 2,
|
||||
},
|
||||
touchStart: {
|
||||
// Start touch
|
||||
args: 1,
|
||||
},
|
||||
touchMove: {
|
||||
// Move touch
|
||||
args: 1,
|
||||
},
|
||||
touchEnd: {
|
||||
// End touch
|
||||
args: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
define({
|
||||
name: "JSHandle",
|
||||
constructor: false,
|
||||
JSType: "object",
|
||||
finalize: true,
|
||||
proto: {
|
||||
evaluate: {
|
||||
// Evaluate function with handle
|
||||
args: 2,
|
||||
},
|
||||
evaluateHandle: {
|
||||
// Evaluate function and return handle
|
||||
args: 2,
|
||||
},
|
||||
getProperty: {
|
||||
// Get property
|
||||
args: 1,
|
||||
},
|
||||
getProperties: {
|
||||
// Get all properties
|
||||
args: 0,
|
||||
},
|
||||
jsonValue: {
|
||||
// Get JSON value
|
||||
args: 0,
|
||||
},
|
||||
asElement: {
|
||||
// Cast to ElementHandle if possible
|
||||
args: 0,
|
||||
},
|
||||
dispose: {
|
||||
// Dispose the handle
|
||||
args: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -90,4 +90,11 @@ pub const Classes = struct {
|
||||
pub const BlockList = api.BlockList;
|
||||
pub const NativeZstd = api.NativeZstd;
|
||||
pub const SourceMap = bun.sourcemap.JSSourceMap;
|
||||
pub const Browser = api.Browser;
|
||||
pub const Page = api.Page;
|
||||
pub const ElementHandle = api.ElementHandle;
|
||||
pub const Keyboard = api.Keyboard;
|
||||
pub const Mouse = api.Mouse;
|
||||
pub const Touchscreen = api.Touchscreen;
|
||||
pub const JSHandle = api.JSHandle;
|
||||
};
|
||||
|
||||
31
test/js/bun/browser/basic.test.ts
Normal file
31
test/js/bun/browser/basic.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect, describe } from "bun:test";
|
||||
|
||||
describe("Bun.Browser API", () => {
|
||||
test("should expose Bun.browser function", () => {
|
||||
expect(typeof Bun.browser).toBe("function");
|
||||
});
|
||||
|
||||
test("should be able to call Bun.browser with options", () => {
|
||||
// Test that the function exists and can be called
|
||||
expect(() => {
|
||||
// Don't actually launch for this basic test
|
||||
const options = { headless: true };
|
||||
expect(typeof options).toBe("object");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should have proper TypeScript types", () => {
|
||||
// This test ensures the types are available
|
||||
const options: any = {
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
executablePath: "/usr/bin/chromium",
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
expect(options.headless).toBe(true);
|
||||
expect(Array.isArray(options.args)).toBe(true);
|
||||
expect(typeof options.executablePath).toBe("string");
|
||||
expect(typeof options.timeout).toBe("number");
|
||||
});
|
||||
});
|
||||
482
test/js/bun/browser/browser.test.ts
Normal file
482
test/js/bun/browser/browser.test.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
||||
import { Browser, Page } from "bun:browser";
|
||||
|
||||
describe("Bun.Browser", () => {
|
||||
describe("Browser.launch()", () => {
|
||||
test("should launch a browser instance", async () => {
|
||||
const browser = await Bun.browser({ headless: true });
|
||||
expect(browser).toBeDefined();
|
||||
expect(browser.isConnected).toBe(true);
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
test("should launch with custom options", async () => {
|
||||
const browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
timeout: 30000,
|
||||
});
|
||||
expect(browser).toBeDefined();
|
||||
expect(browser.isConnected).toBe(true);
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
test("should have WebSocket endpoint", async () => {
|
||||
const browser = await Bun.browser({ headless: true });
|
||||
expect(browser.wsEndpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser$/);
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
test("should have process information", async () => {
|
||||
const browser = await Bun.browser({ headless: true });
|
||||
expect(browser.process).toBeDefined();
|
||||
await browser.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Browser methods", () => {
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Bun.browser({ headless: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
test("should create new page", async () => {
|
||||
const page = await browser.newPage();
|
||||
expect(page).toBeDefined();
|
||||
expect(page.url()).toBe("about:blank");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test("should list pages", async () => {
|
||||
const page1 = await browser.newPage();
|
||||
const page2 = await browser.newPage();
|
||||
|
||||
const pages = await browser.pages();
|
||||
expect(pages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await page1.close();
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test("should get version information", async () => {
|
||||
const version = await browser.version();
|
||||
expect(version).toBeDefined();
|
||||
expect(version.Browser).toBeDefined();
|
||||
expect(version["Protocol-Version"]).toBeDefined();
|
||||
expect(version["User-Agent"]).toBeDefined();
|
||||
});
|
||||
|
||||
test("should disconnect from browser", async () => {
|
||||
const tempBrowser = await Bun.browser({ headless: true });
|
||||
expect(tempBrowser.isConnected).toBe(true);
|
||||
|
||||
await tempBrowser.disconnect();
|
||||
expect(tempBrowser.isConnected).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Bun.browser({ headless: true });
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await page.close();
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
describe("Navigation", () => {
|
||||
test("should navigate to URL", async () => {
|
||||
const response = await page.goto("https://example.com");
|
||||
expect(response).toBeDefined();
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
test("should get current URL", async () => {
|
||||
await page.goto("https://example.com");
|
||||
expect(page.url()).toBe("https://example.com");
|
||||
});
|
||||
|
||||
test("should get page title", async () => {
|
||||
await page.goto("https://example.com");
|
||||
const title = await page.title();
|
||||
expect(typeof title).toBe("string");
|
||||
});
|
||||
|
||||
test("should reload page", async () => {
|
||||
await page.goto("https://example.com");
|
||||
const response = await page.reload();
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
|
||||
test("should go back and forward", async () => {
|
||||
await page.goto("https://example.com");
|
||||
await page.goto("https://httpbin.org");
|
||||
|
||||
const backResponse = await page.goBack();
|
||||
expect(backResponse).toBeDefined();
|
||||
|
||||
const forwardResponse = await page.goForward();
|
||||
expect(forwardResponse).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Content manipulation", () => {
|
||||
test("should get page content", async () => {
|
||||
await page.goto("data:text/html,<html><body><h1>Test</h1></body></html>");
|
||||
const content = await page.content();
|
||||
expect(content).toContain("<h1>Test</h1>");
|
||||
});
|
||||
|
||||
test("should set page content", async () => {
|
||||
const html = "<html><body><h1>Custom Content</h1></body></html>";
|
||||
await page.setContent(html);
|
||||
const content = await page.content();
|
||||
expect(content).toContain("Custom Content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JavaScript evaluation", () => {
|
||||
test("should evaluate expression", async () => {
|
||||
const result = await page.evaluate("1 + 2");
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
test("should evaluate function", async () => {
|
||||
const result = await page.evaluate(() => {
|
||||
return document.title;
|
||||
});
|
||||
expect(typeof result).toBe("string");
|
||||
});
|
||||
|
||||
test("should evaluate function with arguments", async () => {
|
||||
const result = await page.evaluate((a, b) => a + b, 5, 3);
|
||||
expect(result).toBe(8);
|
||||
});
|
||||
|
||||
test("should handle evaluation errors", async () => {
|
||||
try {
|
||||
await page.evaluate("throw new Error('Test error')");
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("Test error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Element interaction", () => {
|
||||
test("should find element with querySelector", async () => {
|
||||
await page.setContent("<html><body><button id='test-btn'>Click me</button></body></html>");
|
||||
const button = await page.querySelector("#test-btn");
|
||||
expect(button).toBeDefined();
|
||||
});
|
||||
|
||||
test("should find multiple elements with querySelectorAll", async () => {
|
||||
await page.setContent("<html><body><div class='item'>1</div><div class='item'>2</div></body></html>");
|
||||
const elements = await page.querySelectorAll(".item");
|
||||
expect(elements.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should click element", async () => {
|
||||
await page.setContent(`
|
||||
<html><body>
|
||||
<button id='test-btn' onclick='this.textContent = "Clicked"'>Click me</button>
|
||||
</body></html>
|
||||
`);
|
||||
|
||||
await page.click("#test-btn");
|
||||
const text = await page.evaluate(() => document.getElementById("test-btn").textContent);
|
||||
expect(text).toBe("Clicked");
|
||||
});
|
||||
|
||||
test("should type text", async () => {
|
||||
await page.setContent("<html><body><input id='test-input' /></body></html>");
|
||||
await page.type("#test-input", "Hello World");
|
||||
|
||||
const value = await page.evaluate(() => document.getElementById("test-input").value);
|
||||
expect(value).toBe("Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Waiting", () => {
|
||||
test("should wait for selector", async () => {
|
||||
await page.setContent("<html><body></body></html>");
|
||||
|
||||
// Add element after delay
|
||||
setTimeout(() => {
|
||||
page.evaluate(() => {
|
||||
const div = document.createElement("div");
|
||||
div.id = "delayed-element";
|
||||
document.body.appendChild(div);
|
||||
});
|
||||
}, 100);
|
||||
|
||||
const element = await page.waitForSelector("#delayed-element", { timeout: 1000 });
|
||||
expect(element).toBeDefined();
|
||||
});
|
||||
|
||||
test("should wait for timeout", async () => {
|
||||
const start = Date.now();
|
||||
await page.waitForTimeout(100);
|
||||
const end = Date.now();
|
||||
expect(end - start).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
test("should wait for function", async () => {
|
||||
await page.setContent("<html><body><div id='counter'>0</div></body></html>");
|
||||
|
||||
// Start a counter
|
||||
page.evaluate(() => {
|
||||
let count = 0;
|
||||
const interval = setInterval(() => {
|
||||
count++;
|
||||
document.getElementById("counter").textContent = count.toString();
|
||||
if (count >= 5) clearInterval(interval);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const result = await page.waitForFunction(() => {
|
||||
return parseInt(document.getElementById("counter").textContent) >= 5;
|
||||
}, { timeout: 1000 });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Viewport and device emulation", () => {
|
||||
test("should set viewport", async () => {
|
||||
await page.setViewport({ width: 1024, height: 768 });
|
||||
const viewport = page.viewport();
|
||||
expect(viewport.width).toBe(1024);
|
||||
expect(viewport.height).toBe(768);
|
||||
});
|
||||
|
||||
test("should emulate device", async () => {
|
||||
await page.emulate({
|
||||
viewport: { width: 375, height: 667, isMobile: true, hasTouch: true },
|
||||
userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)",
|
||||
});
|
||||
|
||||
const viewport = page.viewport();
|
||||
expect(viewport.isMobile).toBe(true);
|
||||
expect(viewport.hasTouch).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Screenshots", () => {
|
||||
test("should take screenshot", async () => {
|
||||
await page.goto("data:text/html,<html><body><h1>Screenshot Test</h1></body></html>");
|
||||
const screenshot = await page.screenshot({ type: "png" });
|
||||
expect(screenshot).toBeInstanceOf(Buffer);
|
||||
expect(screenshot.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("should take full page screenshot", async () => {
|
||||
await page.goto("data:text/html,<html><body style='height: 2000px;'><h1>Long Page</h1></body></html>");
|
||||
const screenshot = await page.screenshot({ fullPage: true });
|
||||
expect(screenshot).toBeInstanceOf(Buffer);
|
||||
});
|
||||
|
||||
test("should take element screenshot", async () => {
|
||||
await page.setContent("<html><body><div id='test' style='width: 100px; height: 100px; background: red;'></div></body></html>");
|
||||
const element = await page.querySelector("#test");
|
||||
const screenshot = await element.screenshot();
|
||||
expect(screenshot).toBeInstanceOf(Buffer);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cookies", () => {
|
||||
test("should set and get cookies", async () => {
|
||||
await page.goto("https://example.com");
|
||||
|
||||
await page.setCookie([
|
||||
{ name: "test-cookie", value: "test-value", domain: "example.com" }
|
||||
]);
|
||||
|
||||
const cookies = await page.cookies();
|
||||
const testCookie = cookies.find(c => c.name === "test-cookie");
|
||||
expect(testCookie).toBeDefined();
|
||||
expect(testCookie.value).toBe("test-value");
|
||||
});
|
||||
|
||||
test("should delete cookies", async () => {
|
||||
await page.goto("https://example.com");
|
||||
|
||||
await page.setCookie([
|
||||
{ name: "delete-me", value: "will-be-deleted", domain: "example.com" }
|
||||
]);
|
||||
|
||||
await page.deleteCookie([{ name: "delete-me" }]);
|
||||
|
||||
const cookies = await page.cookies();
|
||||
const deletedCookie = cookies.find(c => c.name === "delete-me");
|
||||
expect(deletedCookie).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input devices", () => {
|
||||
test("should use keyboard", async () => {
|
||||
await page.setContent("<html><body><input id='test-input' /></body></html>");
|
||||
await page.focus("#test-input");
|
||||
|
||||
await page.keyboard.type("Hello");
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.type("World");
|
||||
|
||||
const value = await page.evaluate(() => document.getElementById("test-input").value);
|
||||
expect(value).toBe("Hello World");
|
||||
});
|
||||
|
||||
test("should use mouse", async () => {
|
||||
await page.setContent(`
|
||||
<html><body>
|
||||
<button id='test-btn' style='position: absolute; top: 100px; left: 100px;'
|
||||
onclick='this.textContent = "Clicked"'>Click me</button>
|
||||
</body></html>
|
||||
`);
|
||||
|
||||
await page.mouse.click(100, 100);
|
||||
const text = await page.evaluate(() => document.getElementById("test-btn").textContent);
|
||||
expect(text).toBe("Clicked");
|
||||
});
|
||||
|
||||
test("should use touchscreen", async () => {
|
||||
await page.setViewport({ width: 375, height: 667, hasTouch: true });
|
||||
await page.setContent(`
|
||||
<html><body>
|
||||
<div id='touch-area' style='width: 200px; height: 200px; background: blue;'
|
||||
ontouchstart='this.style.background = "red"'>Touch me</div>
|
||||
</body></html>
|
||||
`);
|
||||
|
||||
await page.touchscreen.tap(100, 100);
|
||||
const color = await page.evaluate(() =>
|
||||
getComputedStyle(document.getElementById("touch-area")).backgroundColor
|
||||
);
|
||||
expect(color).toBe("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Page events", () => {
|
||||
test("should handle page events", async () => {
|
||||
let dialogMessage = "";
|
||||
|
||||
page.on("dialog", (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
dialog.accept();
|
||||
});
|
||||
|
||||
await page.evaluate(() => alert("Test alert"));
|
||||
expect(dialogMessage).toBe("Test alert");
|
||||
});
|
||||
|
||||
test("should handle console events", async () => {
|
||||
const logs: string[] = [];
|
||||
|
||||
page.on("console", (msg) => {
|
||||
logs.push(msg.text());
|
||||
});
|
||||
|
||||
await page.evaluate(() => console.log("Test message"));
|
||||
expect(logs).toContain("Test message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Network interception", () => {
|
||||
test("should set extra HTTP headers", async () => {
|
||||
await page.setExtraHTTPHeaders({
|
||||
"X-Custom-Header": "test-value"
|
||||
});
|
||||
|
||||
// This would require a server to verify the header was sent
|
||||
// For now, just verify the method doesn't throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("should set user agent", async () => {
|
||||
const customUA = "Custom User Agent 1.0";
|
||||
await page.setUserAgent(customUA);
|
||||
|
||||
const userAgent = await page.evaluate(() => navigator.userAgent);
|
||||
expect(userAgent).toBe(customUA);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Script and style injection", () => {
|
||||
test("should add script tag", async () => {
|
||||
await page.goto("data:text/html,<html><body></body></html>");
|
||||
|
||||
await page.addScriptTag({
|
||||
content: "window.testValue = 'injected';"
|
||||
});
|
||||
|
||||
const value = await page.evaluate(() => window.testValue);
|
||||
expect(value).toBe("injected");
|
||||
});
|
||||
|
||||
test("should add style tag", async () => {
|
||||
await page.goto("data:text/html,<html><body><div id='test'>Test</div></body></html>");
|
||||
|
||||
await page.addStyleTag({
|
||||
content: "#test { color: red; }"
|
||||
});
|
||||
|
||||
const color = await page.evaluate(() =>
|
||||
getComputedStyle(document.getElementById("test")).color
|
||||
);
|
||||
expect(color).toBe("rgb(255, 0, 0)");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
test("should handle browser launch failure", async () => {
|
||||
try {
|
||||
await Bun.browser({ executablePath: "/nonexistent/chrome" });
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("Failed to launch Chrome");
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle closed page operations", async () => {
|
||||
const browser = await Bun.browser({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
await page.close();
|
||||
|
||||
try {
|
||||
await page.goto("https://example.com");
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("Page is closed");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
test("should handle network timeouts", async () => {
|
||||
const browser = await Bun.browser({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("https://httpbin.org/delay/10", { timeout: 1000 });
|
||||
expect(true).toBe(false); // Should not reach here
|
||||
} catch (error) {
|
||||
expect(error.message).toContain("timeout");
|
||||
}
|
||||
|
||||
await page.close();
|
||||
await browser.close();
|
||||
});
|
||||
});
|
||||
294
test/js/bun/browser/integration.test.ts
Normal file
294
test/js/bun/browser/integration.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
||||
|
||||
describe("Bun.Browser Integration", () => {
|
||||
test("should launch browser and create page", async () => {
|
||||
let browser;
|
||||
let page;
|
||||
|
||||
try {
|
||||
// Launch browser with minimal options
|
||||
browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--disable-background-timer-throttling",
|
||||
"--disable-backgrounding-occluded-windows",
|
||||
"--disable-renderer-backgrounding",
|
||||
],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
expect(browser).toBeDefined();
|
||||
expect(browser.isConnected).toBe(true);
|
||||
|
||||
// Create a new page
|
||||
page = await browser.newPage();
|
||||
expect(page).toBeDefined();
|
||||
expect(page.url()).toBe("about:blank");
|
||||
|
||||
// Navigate to a simple data URL
|
||||
const response = await page.goto("data:text/html,<html><body><h1>Test Page</h1></body></html>");
|
||||
expect(response).toBeDefined();
|
||||
expect(response.ok).toBe(true);
|
||||
|
||||
// Get page content
|
||||
const content = await page.content();
|
||||
expect(content).toContain("<h1>Test Page</h1>");
|
||||
|
||||
// Get page title
|
||||
await page.setContent("<html><head><title>Test Title</title></head><body></body></html>");
|
||||
const title = await page.title();
|
||||
expect(title).toBe("Test Title");
|
||||
|
||||
} catch (error) {
|
||||
console.error("Browser test failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}, 30000); // 30 second timeout for this integration test
|
||||
|
||||
test("should handle simple JavaScript evaluation", async () => {
|
||||
let browser;
|
||||
let page;
|
||||
|
||||
try {
|
||||
browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
|
||||
// Evaluate simple expression
|
||||
const result1 = await page.evaluate("2 + 2");
|
||||
expect(result1).toBe(4);
|
||||
|
||||
// Evaluate function
|
||||
const result2 = await page.evaluate(() => {
|
||||
return "Hello from page context";
|
||||
});
|
||||
expect(result2).toBe("Hello from page context");
|
||||
|
||||
// Evaluate with arguments
|
||||
const result3 = await page.evaluate((a, b) => a * b, 6, 7);
|
||||
expect(result3).toBe(42);
|
||||
|
||||
} catch (error) {
|
||||
console.error("JavaScript evaluation test failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("should handle viewport manipulation", async () => {
|
||||
let browser;
|
||||
let page;
|
||||
|
||||
try {
|
||||
browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
|
||||
// Set custom viewport
|
||||
await page.setViewport({
|
||||
width: 800,
|
||||
height: 600,
|
||||
deviceScaleFactor: 2.0,
|
||||
});
|
||||
|
||||
const viewport = page.viewport();
|
||||
expect(viewport.width).toBe(800);
|
||||
expect(viewport.height).toBe(600);
|
||||
expect(viewport.deviceScaleFactor).toBe(2.0);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Viewport test failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("should handle basic DOM interaction", async () => {
|
||||
let browser;
|
||||
let page;
|
||||
|
||||
try {
|
||||
browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
|
||||
// Set up a test page
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<body>
|
||||
<button id="test-btn">Click Me</button>
|
||||
<input id="test-input" type="text" />
|
||||
<div id="result"></div>
|
||||
<script>
|
||||
document.getElementById('test-btn').onclick = function() {
|
||||
document.getElementById('result').textContent = 'Button clicked!';
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// Find button element
|
||||
const button = await page.querySelector("#test-btn");
|
||||
expect(button).toBeDefined();
|
||||
|
||||
// Click the button
|
||||
await page.click("#test-btn");
|
||||
|
||||
// Check the result
|
||||
const resultText = await page.evaluate(() => {
|
||||
return document.getElementById("result").textContent;
|
||||
});
|
||||
expect(resultText).toBe("Button clicked!");
|
||||
|
||||
// Type in input field
|
||||
await page.type("#test-input", "Hello World");
|
||||
const inputValue = await page.evaluate(() => {
|
||||
return document.getElementById("test-input").value;
|
||||
});
|
||||
expect(inputValue).toBe("Hello World");
|
||||
|
||||
} catch (error) {
|
||||
console.error("DOM interaction test failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("should take screenshots", async () => {
|
||||
let browser;
|
||||
let page;
|
||||
|
||||
try {
|
||||
browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
page = await browser.newPage();
|
||||
|
||||
// Navigate to a simple page
|
||||
await page.setContent(`
|
||||
<html>
|
||||
<body style="background: linear-gradient(45deg, #ff6b6b, #4ecdc4); height: 100vh; display: flex; align-items: center; justify-content: center;">
|
||||
<h1 style="color: white; font-family: Arial; font-size: 48px;">Screenshot Test</h1>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
// Take a screenshot
|
||||
const screenshot = await page.screenshot({
|
||||
type: "png",
|
||||
encoding: "binary",
|
||||
});
|
||||
|
||||
expect(screenshot).toBeInstanceOf(Buffer);
|
||||
expect(screenshot.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify it's a valid PNG by checking the header
|
||||
const pngHeader = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
expect(screenshot.subarray(0, 8)).toEqual(pngHeader);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Screenshot test failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (page) {
|
||||
await page.close();
|
||||
}
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("should handle multiple pages", async () => {
|
||||
let browser;
|
||||
const pages = [];
|
||||
|
||||
try {
|
||||
browser = await Bun.browser({
|
||||
headless: true,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Create multiple pages
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const page = await browser.newPage();
|
||||
await page.setContent(`<html><body><h1>Page ${i + 1}</h1></body></html>`);
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
// Get all pages
|
||||
const allPages = await browser.pages();
|
||||
expect(allPages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Verify each page has the correct content
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const content = await pages[i].content();
|
||||
expect(content).toContain(`Page ${i + 1}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Multiple pages test failed:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Close all pages
|
||||
for (const page of pages) {
|
||||
try {
|
||||
await page.close();
|
||||
} catch (e) {
|
||||
// Ignore close errors
|
||||
}
|
||||
}
|
||||
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
Reference in New Issue
Block a user