diff --git a/src/Config.zig b/src/Config.zig index 20730fa3..cb392f39 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -249,7 +249,9 @@ pub const Fetch = struct { with_frames: bool = false, strip: dump.Opts.Strip = .{}, wait_ms: u32 = 5000, - wait_until: WaitUntil = .done, + wait_until: ?WaitUntil = null, + wait_script: ?[:0]const u8 = null, + wait_selector: ?[:0]const u8 = null, }; pub const Common = struct { @@ -413,12 +415,24 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ \\--with-frames Includes the contents of iframes. Defaults to false. \\ - \\--wait-ms Wait time in milliseconds. + \\--wait-ms Wait time in milliseconds. Supersedes all other --wait + \\ parameters. \\ Defaults to 5000. \\ - \\--wait-until Wait until the specified event. - \\ Supported events: load, domcontentloaded, networkidle, done. - \\ Defaults to 'done'. + \\--wait-until Wait until the specified event. Checked before the other + \\ --wait- options. Supported events: load, domcontentloaded, + \\ networkidle, done. + \\ Defaults to 'done'. If --wait-selector, --wait-script or + \\ --wait-script-file are specified, defaults to none. + \\ + \\--wait-selector Wait for an element matching the CSS selector to appear. + \\ Checked after --wait-until condition is met. + \\ + \\--wait-script Wait for a JavaScript expression to return truthy. + \\ Checked after --wait-until condition is met. + \\ + \\--wait-script-file + \\ Like --wait-script, but reads the script from a file. \\ ++ common_options ++ \\ @@ -685,7 +699,9 @@ fn parseFetchArgs( var common: Common = .{}; var strip: dump.Opts.Strip = .{}; var wait_ms: u32 = 5000; - var wait_until: WaitUntil = .done; + var wait_until: ?WaitUntil = null; + var wait_script: ?[:0]const u8 = null; + var wait_selector: ?[:0]const u8 = null; while (args.next()) |opt| { if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) { @@ -712,6 +728,36 @@ fn parseFetchArgs( continue; } + if (std.mem.eql(u8, "--wait-selector", opt) or std.mem.eql(u8, "--wait_selector", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = opt }); + return error.InvalidArgument; + }; + wait_selector = try allocator.dupeZ(u8, str); + continue; + } + + if (std.mem.eql(u8, "--wait-script", opt) or std.mem.eql(u8, "--wait_script", opt)) { + const str = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = opt }); + return error.InvalidArgument; + }; + wait_script = try allocator.dupeZ(u8, str); + continue; + } + + if (std.mem.eql(u8, "--wait-script-file", opt) or std.mem.eql(u8, "--wait_script_file", opt)) { + const path = args.next() orelse { + log.fatal(.app, "missing argument value", .{ .arg = opt }); + return error.InvalidArgument; + }; + wait_script = std.fs.cwd().readFileAllocOptions(allocator, path, 1024 * 1024, null, .of(u8), 0) catch |err| { + log.fatal(.app, "failed to read file", .{ .arg = opt, .path = path, .err = err }); + return error.InvalidArgument; + }; + continue; + } + if (std.mem.eql(u8, "--dump", opt)) { var peek_args = args.*; if (peek_args.next()) |next_arg| { @@ -802,6 +848,8 @@ fn parseFetchArgs( .with_frames = with_frames, .wait_ms = wait_ms, .wait_until = wait_until, + .wait_selector = wait_selector, + .wait_script = wait_script, }; } diff --git a/src/SemanticTree.zig b/src/SemanticTree.zig index 6366890f..0eb30ecf 100644 --- a/src/SemanticTree.zig +++ b/src/SemanticTree.zig @@ -515,7 +515,7 @@ test "SemanticTree backendDOMNodeId" { var registry: CDPNode.Registry = .init(testing.allocator); defer registry.deinit(); - var page = try testing.pageTest("cdp/registry1.html"); + var page = try testing.pageTest("cdp/registry1.html", .{}); defer testing.reset(); defer page._session.removePage(); @@ -539,7 +539,7 @@ test "SemanticTree max_depth" { var registry: CDPNode.Registry = .init(testing.allocator); defer registry.deinit(); - var page = try testing.pageTest("cdp/registry1.html"); + var page = try testing.pageTest("cdp/registry1.html", .{}); defer testing.reset(); defer page._session.removePage(); diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index d3161a91..8f56aacc 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -22,10 +22,14 @@ const builtin = @import("builtin"); const log = @import("../log.zig"); +const js = @import("js/js.zig"); const Page = @import("Page.zig"); const Session = @import("Session.zig"); const HttpClient = @import("HttpClient.zig"); +const Node = @import("webapi/Node.zig"); +const Selector = @import("webapi/selector/Selector.zig"); + const IS_DEBUG = builtin.mode == .Debug; const Runner = @This(); @@ -236,3 +240,111 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult { .raw_done => return .done, } } + +pub fn waitForSelector(self: *Runner, selector: [:0]const u8, timeout_ms: u32) !*Node.Element { + const arena = try self.session.getArena(.{ .debug = "Runner.waitForSelector" }); + defer self.session.releaseArena(arena); + + var timer = try std.time.Timer.start(); + const parsed_selector = try Selector.parseLeaky(arena, selector); + + while (true) { + // self.page can change between ticks + const page = self.page; + if (try parsed_selector.query(page.document.asNode(), page)) |el| { + return el; + } + + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + if (elapsed >= timeout_ms) { + return error.Timeout; + } + switch (try self.tick(.{ .ms = timeout_ms - elapsed })) { + .done => return error.Timeout, + .ok => |recommended_sleep_ms| { + if (recommended_sleep_ms > 0) { + std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms); + } + }, + } + } +} + +pub fn waitForScript(runner: *Runner, script: [:0]const u8, timeout_ms: u32) !void { + var timer = try std.time.Timer.start(); + + while (true) { + const page = runner.page; + + // Execute the script and check if it returns truthy + var ls: js.Local.Scope = undefined; + page.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const value = ls.local.exec(script, "wait_script") catch |err| { + const caught = try_catch.caughtOrError(page.call_arena, err); + log.err(.app, "wait script error", .{ .err = caught }); + return error.ScriptError; + }; + + if (value.toBool()) { + return; + } + + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + if (elapsed >= timeout_ms) { + return error.Timeout; + } + switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) { + .done => return error.Timeout, + .ok => |recommended_sleep_ms| { + if (recommended_sleep_ms > 0) { + std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms); + } + }, + } + } +} + +const testing = @import("../testing.zig"); +test "Runner: no page" { + try testing.expectError(error.NoPage, Runner.init(testing.test_session, .{})); +} + +test "Runner: waitForSelector timeout" { + const page = try testing.pageTest("runner/runner1.html", .{}); + defer page._session.removePage(); + + var runner = try page._session.runner(.{}); + try testing.expectError(error.Timeout, runner.waitForSelector("#nope", 10)); +} + +test "Runner: waitForSelector" { + defer testing.reset(); + const page = try testing.pageTest("runner/runner1.html", .{}); + defer page._session.removePage(); + + var runner = try page._session.runner(.{}); + const el = try runner.waitForSelector("#sel1", 10); + try testing.expectEqual("selector-1-content", try el.asNode().getTextContentAlloc(testing.arena_allocator)); +} + +test "Runner: waitForScript timeout" { + const page = try testing.pageTest("runner/runner1.html", .{}); + defer page._session.removePage(); + + var runner = try page._session.runner(.{}); + try testing.expectError(error.Timeout, runner.waitForScript("document.querySelector('#nope')", 10)); +} + +test "Runner: waitForScript" { + const page = try testing.pageTest("runner/runner1.html", .{}); + defer page._session.removePage(); + + var runner = try page._session.runner(.{}); + try runner.waitForScript("document.querySelector('#sel1')", 10); +} diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index a9fc50cf..f7a69672 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -114,7 +114,7 @@ fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []cons if (!props.isRelevant()) return; - const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return; + const selectors = SelectorParser.parseList(self.arena, selector_text) catch return; for (selectors) |selector| { const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first; const bucket_key = getBucketKey(rightmost) orelse continue; @@ -484,7 +484,7 @@ fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void { } // Parse the selector list - const selectors = SelectorParser.parseList(self.arena, selector_text, self.page) catch return; + const selectors = SelectorParser.parseList(self.arena, selector_text) catch return; if (selectors.len == 0) { return; } diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 7facfc81..7bd8f48a 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -110,28 +110,10 @@ pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Sessio var runner = try session.runner(.{}); try runner.wait(.{ .ms = timeout_ms, .until = .load }); - while (true) { - const page = runner.page; - const element = Selector.querySelector(page.document.asNode(), selector, page) catch { - return error.InvalidSelector; - }; + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + const remaining = timeout_ms -| elapsed; + if (remaining == 0) return error.Timeout; - if (element) |el| { - return el.asNode(); - } - - const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); - if (elapsed >= timeout_ms) { - return error.Timeout; - } - switch (try runner.tick(.{ .ms = timeout_ms - elapsed })) { - .done => return error.Timeout, - .ok => |recommended_sleep_ms| { - if (recommended_sleep_ms > 0) { - // guanrateed to be <= 20ms - std.Thread.sleep(std.time.ns_per_ms * recommended_sleep_ms); - } - }, - } - } + const el = try runner.waitForSelector(selector, timeout_ms); + return el.asNode(); } diff --git a/src/browser/tests/runner/runner1.html b/src/browser/tests/runner/runner1.html new file mode 100644 index 00000000..1f64de73 --- /dev/null +++ b/src/browser/tests/runner/runner1.html @@ -0,0 +1,4 @@ + + +
selector-0-content
+
selector-1-content
diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index dc28f8b7..e96c77cb 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -26,6 +26,8 @@ const GenericIterator = @import("../collections/iterator.zig").Entry; const Page = @import("../../Page.zig"); const String = @import("../../../string.zig").String; +const Allocator = std.mem.Allocator; + const IS_DEBUG = @import("builtin").mode == .Debug; pub fn registerTypes() []const type { @@ -419,7 +421,7 @@ pub fn validateAttributeName(name: String) !void { } } -pub fn normalizeNameForLookup(name: String, page: *Page) !String { +fn normalizeNameForLookup(name: String, page: *Page) !String { if (!needsLowerCasing(name.str())) { return name; } @@ -431,6 +433,14 @@ pub fn normalizeNameForLookup(name: String, page: *Page) !String { return .wrap(normalized); } +pub fn normalizeNameForLookupAlloc(allocator: Allocator, name: String) !String { + if (!needsLowerCasing(name.str())) { + return name.dupe(allocator); + } + const normalized = try std.ascii.allocLowerString(allocator, name.str()); + return .wrap(normalized); +} + fn needsLowerCasing(name: []const u8) bool { var remaining = name; if (comptime std.simd.suggestVectorLength(u8)) |vector_len| { diff --git a/src/browser/webapi/selector/Parser.zig b/src/browser/webapi/selector/Parser.zig index f9b1bb23..4b47988f 100644 --- a/src/browser/webapi/selector/Parser.zig +++ b/src/browser/webapi/selector/Parser.zig @@ -74,7 +74,7 @@ fn preprocessInput(arena: Allocator, input: []const u8) ![]const u8 { return result.items; } -pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![]const Selector.Selector { +pub fn parseList(arena: Allocator, input: []const u8) ParseError![]const Selector.Selector { // Preprocess input to normalize line endings const preprocessed = try preprocessInput(arena, input); @@ -140,7 +140,7 @@ pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![] const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace); if (selector_input.len > 0) { - const selector = try parse(arena, selector_input, page); + const selector = try parse(arena, selector_input); try selectors.append(arena, selector); } @@ -155,7 +155,7 @@ pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![] return selectors.items; } -pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Selector.Selector { +pub fn parse(arena: Allocator, input: []const u8) ParseError!Selector.Selector { var parser = Parser{ .input = input }; var segments: std.ArrayList(Segment) = .empty; var current_compound: std.ArrayList(Part) = .empty; @@ -164,7 +164,7 @@ pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Select while (parser.skipSpaces()) { if (parser.peek() == 0) break; - const part = try parser.parsePart(arena, page); + const part = try parser.parsePart(arena); try current_compound.append(arena, part); // Check what comes after this part @@ -238,7 +238,7 @@ pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Select while (parser.skipSpaces()) { if (parser.peek() == 0) break; - const part = try parser.parsePart(arena, page); + const part = try parser.parsePart(arena); try current_compound.append(arena, part); // Check what comes after this part @@ -289,7 +289,7 @@ pub fn parse(arena: Allocator, input: []const u8, page: *Page) ParseError!Select }; } -fn parsePart(self: *Parser, arena: Allocator, page: *Page) !Part { +fn parsePart(self: *Parser, arena: Allocator) !Part { return switch (self.peek()) { '#' => .{ .id = try self.id(arena) }, '.' => .{ .class = try self.class(arena) }, @@ -297,16 +297,17 @@ fn parsePart(self: *Parser, arena: Allocator, page: *Page) !Part { self.input = self.input[1..]; break :blk .universal; }, - '[' => .{ .attribute = try self.attribute(arena, page) }, - ':' => .{ .pseudo_class = try self.pseudoClass(arena, page) }, + '[' => .{ .attribute = try self.attribute(arena) }, + ':' => .{ .pseudo_class = try self.pseudoClass(arena) }, 'a'...'z', 'A'...'Z', '_', '\\', 0x80...0xFF => blk: { // Use parseIdentifier for full escape support const tag_name = try self.parseIdentifier(arena, error.InvalidTagSelector); if (tag_name.len > 256) { return error.InvalidTagSelector; } + var buf: [256]u8 = undefined; // Try to match as a known tag enum for optimization - const lower = std.ascii.lowerString(&page.buf, tag_name); + const lower = std.ascii.lowerString(&buf, tag_name); if (Node.Element.Tag.parseForMatch(lower)) |known_tag| { break :blk .{ .tag = known_tag }; } @@ -373,7 +374,7 @@ fn consumeUntilCommaOrParen(self: *Parser) []const u8 { return result; } -fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoClass { +fn pseudoClass(self: *Parser, arena: Allocator) !Selector.PseudoClass { if (comptime IS_DEBUG) { // Should have been verified by caller std.debug.assert(self.peek() == ':'); @@ -445,7 +446,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() == 0) return error.InvalidPseudoClass; // Parse a full selector (with potential combinators and compounds) - const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + const selector = try parse(arena, self.consumeUntilCommaOrParen()); try selectors.append(arena, selector); _ = self.skipSpaces(); @@ -472,7 +473,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; - const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + const selector = try parse(arena, self.consumeUntilCommaOrParen()); try selectors.append(arena, selector); _ = self.skipSpaces(); @@ -499,7 +500,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; - const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + const selector = try parse(arena, self.consumeUntilCommaOrParen()); try selectors.append(arena, selector); _ = self.skipSpaces(); @@ -526,7 +527,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla if (self.peek() == ')') break; if (self.peek() == 0) return error.InvalidPseudoClass; - const selector = try parse(arena, self.consumeUntilCommaOrParen(), page); + const selector = try parse(arena, self.consumeUntilCommaOrParen()); try selectors.append(arena, selector); _ = self.skipSpaces(); @@ -898,7 +899,7 @@ fn tag(self: *Parser) ![]const u8 { return input[0..i]; } -fn attribute(self: *Parser, arena: Allocator, page: *Page) !Selector.Attribute { +fn attribute(self: *Parser, arena: Allocator) !Selector.Attribute { if (comptime IS_DEBUG) { // should have been verified by caller std.debug.assert(self.peek() == '['); @@ -910,8 +911,7 @@ fn attribute(self: *Parser, arena: Allocator, page: *Page) !Selector.Attribute { const attr_name = try self.attributeName(); // Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup) - const normalized = try Attribute.normalizeNameForLookup(.wrap(attr_name), page); - const name = try normalized.dupe(arena); + const name = try Attribute.normalizeNameForLookupAlloc(arena, .wrap(attr_name)); var case_insensitive = false; _ = self.skipSpaces(); diff --git a/src/browser/webapi/selector/Selector.zig b/src/browser/webapi/selector/Selector.zig index 3e987a92..a3d5d894 100644 --- a/src/browser/webapi/selector/Selector.zig +++ b/src/browser/webapi/selector/Selector.zig @@ -20,41 +20,24 @@ const std = @import("std"); const String = @import("../../../string.zig").String; -const Parser = @import("Parser.zig"); const Node = @import("../Node.zig"); const Page = @import("../../Page.zig"); + +const Parser = @import("Parser.zig"); pub const List = @import("List.zig"); -pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Element { +const Allocator = std.mem.Allocator; + +pub fn parseLeaky(arena: Allocator, input: []const u8) !Parsed { if (input.len == 0) { return error.SyntaxError; } + return .{ .selectors = try Parser.parseList(arena, input) }; +} - const arena = page.call_arena; - const selectors = try Parser.parseList(arena, input, page); - - for (selectors) |selector| { - // Fast path: single compound with only an ID selector - if (selector.segments.len == 0 and selector.first.parts.len == 1) { - const first = selector.first.parts[0]; - if (first == .id) { - const el = page.getElementByIdFromNode(root, first.id) orelse continue; - // Check if the element is within the root subtree - const node = el.asNode(); - if (node != root and root.contains(node)) { - return el; - } - continue; - } - } - - if (List.initOne(root, selector, page)) |node| { - if (node.is(Node.Element)) |el| { - return el; - } - } - } - return null; +pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Element { + const parsed = try parseLeaky(page.call_arena, input); + return parsed.query(root, page); } pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { @@ -67,7 +50,7 @@ pub fn querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List { var nodes: std.AutoArrayHashMapUnmanaged(*Node, void) = .empty; - const selectors = try Parser.parseList(arena, input, page); + const selectors = try Parser.parseList(arena, input); for (selectors) |selector| { try List.collect(arena, root, selector, &nodes, page); } @@ -86,7 +69,7 @@ pub fn matches(el: *Node.Element, input: []const u8, page: *Page) !bool { } const arena = page.call_arena; - const selectors = try Parser.parseList(arena, input, page); + const selectors = try Parser.parseList(arena, input); for (selectors) |selector| { if (List.matches(el.asNode(), selector, el.asNode(), page)) { @@ -104,7 +87,7 @@ pub fn matchesWithScope(el: *Node.Element, input: []const u8, scope: *Node.Eleme } const arena = page.call_arena; - const selectors = try Parser.parseList(arena, input, page); + const selectors = try Parser.parseList(arena, input); for (selectors) |selector| { if (List.matches(el.asNode(), selector, scope.asNode(), page)) { @@ -292,3 +275,32 @@ pub const Selector = struct { } } }; + +pub const Parsed = struct { + selectors: []const Selector, + + pub fn query(self: Parsed, root: *Node, page: *Page) !?*Node.Element { + for (self.selectors) |selector| { + // Fast path: single compound with only an ID selector + if (selector.segments.len == 0 and selector.first.parts.len == 1) { + const first = selector.first.parts[0]; + if (first == .id) { + const el = page.getElementByIdFromNode(root, first.id) orelse continue; + // Check if the element is within the root subtree + const node = el.asNode(); + if (node != root and root.contains(node)) { + return el; + } + continue; + } + } + + if (List.initOne(root, selector, page)) |node| { + if (node.is(Node.Element)) |el| { + return el; + } + } + } + return null; + } +}; diff --git a/src/cdp/AXNode.zig b/src/cdp/AXNode.zig index 7e10c562..7a7f1cce 100644 --- a/src/cdp/AXNode.zig +++ b/src/cdp/AXNode.zig @@ -1179,7 +1179,7 @@ test "AXNode: writer" { var registry = Node.Registry.init(testing.allocator); defer registry.deinit(); - var page = try testing.pageTest("cdp/dom3.html"); + var page = try testing.pageTest("cdp/dom3.html", .{}); defer page._session.removePage(); var doc = page.window._document; diff --git a/src/cdp/Node.zig b/src/cdp/Node.zig index 1260217b..a1a15aa3 100644 --- a/src/cdp/Node.zig +++ b/src/cdp/Node.zig @@ -343,7 +343,7 @@ test "cdp Node: Registry register" { try testing.expectEqual(0, registry.lookup_by_id.count()); try testing.expectEqual(0, registry.lookup_by_node.count()); - var page = try testing.pageTest("cdp/registry1.html"); + var page = try testing.pageTest("cdp/registry1.html", .{}); defer page._session.removePage(); var doc = page.window._document; @@ -400,7 +400,7 @@ test "cdp Node: search list" { } { - var page = try testing.pageTest("cdp/registry2.html"); + var page = try testing.pageTest("cdp/registry2.html", .{}); defer page._session.removePage(); var doc = page.window._document; @@ -440,7 +440,7 @@ test "cdp Node: Writer" { var registry = Registry.init(testing.allocator); defer registry.deinit(); - var page = try testing.pageTest("cdp/registry3.html"); + var page = try testing.pageTest("cdp/registry3.html", .{}); defer page._session.removePage(); var doc = page.window._document; diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 36213624..d2d924ad 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -48,7 +48,9 @@ const IS_DEBUG = @import("builtin").mode == .Debug; pub const FetchOpts = struct { wait_ms: u32 = 5000, - wait_until: Config.WaitUntil = .load, + wait_until: ?Config.WaitUntil = null, + wait_script: ?[:0]const u8 = null, + wait_selector: ?[:0]const u8 = null, dump: dump.Opts, dump_mode: ?Config.DumpFormat = null, writer: ?*std.Io.Writer = null, @@ -111,7 +113,31 @@ pub fn fetch(app: *App, url: [:0]const u8, opts: FetchOpts) !void { .kind = .{ .push = null }, }); var runner = try session.runner(.{}); - try runner.wait(.{ .ms = opts.wait_ms, .until = opts.wait_until }); + + var timer = try std.time.Timer.start(); + + if (opts.wait_until) |wu| { + try runner.wait(.{ .ms = opts.wait_ms, .until = wu }); + } else if (opts.wait_selector == null and opts.wait_script == null) { + // We default to .done if both wait_selector and wait_script are null + // This allows the caller to ONLY --wait-selector or ONLY --wait-script + // or combine --wait-until WITH --wait-selector/script + try runner.wait(.{ .ms = opts.wait_ms, .until = .done }); + } + + if (opts.wait_selector) |selector| { + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + const remaining = opts.wait_ms -| elapsed; + if (remaining == 0) return error.Timeout; + _ = try runner.waitForSelector(selector, opts.wait_ms); + } + + if (opts.wait_script) |script| { + const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms); + const remaining = opts.wait_ms -| elapsed; + if (remaining == 0) return error.Timeout; + try runner.waitForScript(script, opts.wait_ms); + } const writer = opts.writer orelse return; if (opts.dump_mode) |mode| { diff --git a/src/main.zig b/src/main.zig index aa26173c..bc82e39f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -123,6 +123,8 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { var fetch_opts = lp.FetchOpts{ .wait_ms = opts.wait_ms, .wait_until = opts.wait_until, + .wait_script = opts.wait_script, + .wait_selector = opts.wait_selector, .dump_mode = opts.dump_mode, .dump = .{ .strip = opts.strip, diff --git a/src/testing.zig b/src/testing.zig index ed7c3747..8ff59751 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -452,8 +452,10 @@ fn runWebApiTest(test_file: [:0]const u8) !void { } } -// Used by a few CDP tests - wouldn't be sad to see this go. -pub fn pageTest(comptime test_file: []const u8) !*Page { +const PageTestOpts = struct { + wait_until_done: bool = true, +}; +pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Page { const page = try test_session.createPage(); errdefer test_session.removePage(); @@ -466,7 +468,9 @@ pub fn pageTest(comptime test_file: []const u8) !*Page { try page.navigate(url, .{}); var runner = try test_session.runner(.{}); - try runner.wait(.{ .ms = 2000 }); + if (opts.wait_until_done) { + try runner.wait(.{ .ms = 2000 }); + } return page; }