mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-31 09:29:42 +00:00
Add --wait-selector, --wait-script and --wait-script-file options to fetch
These new optional parameter run AFTER --wait-until, allowing the (imo) useful combination of `--wait-until load --wait-script "report.complete === true"`. However, if `--wait-until` IS NOT specified but `--wait-selector/script` IS, then there is no default wait and it'll just check the selector/script. If neither `--wait-selector` or `--wait-script/--wait-script-file` are specified then `--wait-until` continues to default to `done`. These waiters were added to the Runner, and the existing Action.waitForSelector now uses the runner's version. Selector querying has been split into distinct parse and query functions, so that we can parse once, and query on every tick. We could potentially optimize --wait-script to compile the script once and call it on each tick, but we'd have to detect page navigation to recompile the script in the new context. Something I'd rather optimize separately.
This commit is contained in:
@@ -249,7 +249,9 @@ pub const Fetch = struct {
|
|||||||
with_frames: bool = false,
|
with_frames: bool = false,
|
||||||
strip: dump.Opts.Strip = .{},
|
strip: dump.Opts.Strip = .{},
|
||||||
wait_ms: u32 = 5000,
|
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 {
|
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.
|
\\--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.
|
\\ Defaults to 5000.
|
||||||
\\
|
\\
|
||||||
\\--wait-until Wait until the specified event.
|
\\--wait-until Wait until the specified event. Checked before the other
|
||||||
\\ Supported events: load, domcontentloaded, networkidle, done.
|
\\ --wait- options. Supported events: load, domcontentloaded,
|
||||||
\\ Defaults to 'done'.
|
\\ 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 ++
|
++ common_options ++
|
||||||
\\
|
\\
|
||||||
@@ -685,7 +699,9 @@ fn parseFetchArgs(
|
|||||||
var common: Common = .{};
|
var common: Common = .{};
|
||||||
var strip: dump.Opts.Strip = .{};
|
var strip: dump.Opts.Strip = .{};
|
||||||
var wait_ms: u32 = 5000;
|
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| {
|
while (args.next()) |opt| {
|
||||||
if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) {
|
if (std.mem.eql(u8, "--wait-ms", opt) or std.mem.eql(u8, "--wait_ms", opt)) {
|
||||||
@@ -712,6 +728,36 @@ fn parseFetchArgs(
|
|||||||
continue;
|
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)) {
|
if (std.mem.eql(u8, "--dump", opt)) {
|
||||||
var peek_args = args.*;
|
var peek_args = args.*;
|
||||||
if (peek_args.next()) |next_arg| {
|
if (peek_args.next()) |next_arg| {
|
||||||
@@ -802,6 +848,8 @@ fn parseFetchArgs(
|
|||||||
.with_frames = with_frames,
|
.with_frames = with_frames,
|
||||||
.wait_ms = wait_ms,
|
.wait_ms = wait_ms,
|
||||||
.wait_until = wait_until,
|
.wait_until = wait_until,
|
||||||
|
.wait_selector = wait_selector,
|
||||||
|
.wait_script = wait_script,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ test "SemanticTree backendDOMNodeId" {
|
|||||||
var registry: CDPNode.Registry = .init(testing.allocator);
|
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||||
defer registry.deinit();
|
defer registry.deinit();
|
||||||
|
|
||||||
var page = try testing.pageTest("cdp/registry1.html");
|
var page = try testing.pageTest("cdp/registry1.html", .{});
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
defer page._session.removePage();
|
defer page._session.removePage();
|
||||||
|
|
||||||
@@ -539,7 +539,7 @@ test "SemanticTree max_depth" {
|
|||||||
var registry: CDPNode.Registry = .init(testing.allocator);
|
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||||
defer registry.deinit();
|
defer registry.deinit();
|
||||||
|
|
||||||
var page = try testing.pageTest("cdp/registry1.html");
|
var page = try testing.pageTest("cdp/registry1.html", .{});
|
||||||
defer testing.reset();
|
defer testing.reset();
|
||||||
defer page._session.removePage();
|
defer page._session.removePage();
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ const builtin = @import("builtin");
|
|||||||
|
|
||||||
const log = @import("../log.zig");
|
const log = @import("../log.zig");
|
||||||
|
|
||||||
|
const js = @import("js/js.zig");
|
||||||
const Page = @import("Page.zig");
|
const Page = @import("Page.zig");
|
||||||
const Session = @import("Session.zig");
|
const Session = @import("Session.zig");
|
||||||
const HttpClient = @import("HttpClient.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 IS_DEBUG = builtin.mode == .Debug;
|
||||||
|
|
||||||
const Runner = @This();
|
const Runner = @This();
|
||||||
@@ -236,3 +240,111 @@ fn _tick(self: *Runner, comptime is_cdp: bool, opts: TickOpts) !CDPTickResult {
|
|||||||
.raw_done => return .done,
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ fn addRawRule(self: *StyleManager, selector_text: []const u8, block_text: []cons
|
|||||||
|
|
||||||
if (!props.isRelevant()) return;
|
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| {
|
for (selectors) |selector| {
|
||||||
const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first;
|
const rightmost = if (selector.segments.len > 0) selector.segments[selector.segments.len - 1].compound else selector.first;
|
||||||
const bucket_key = getBucketKey(rightmost) orelse continue;
|
const bucket_key = getBucketKey(rightmost) orelse continue;
|
||||||
@@ -484,7 +484,7 @@ fn addRule(self: *StyleManager, style_rule: *CSSStyleRule) !void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse the selector list
|
// 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) {
|
if (selectors.len == 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,28 +110,10 @@ pub fn waitForSelector(selector: [:0]const u8, timeout_ms: u32, session: *Sessio
|
|||||||
var runner = try session.runner(.{});
|
var runner = try session.runner(.{});
|
||||||
try runner.wait(.{ .ms = timeout_ms, .until = .load });
|
try runner.wait(.{ .ms = timeout_ms, .until = .load });
|
||||||
|
|
||||||
while (true) {
|
const elapsed: u32 = @intCast(timer.read() / std.time.ns_per_ms);
|
||||||
const page = runner.page;
|
const remaining = timeout_ms -| elapsed;
|
||||||
const element = Selector.querySelector(page.document.asNode(), selector, page) catch {
|
if (remaining == 0) return error.Timeout;
|
||||||
return error.InvalidSelector;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (element) |el| {
|
const el = try runner.waitForSelector(selector, timeout_ms);
|
||||||
return el.asNode();
|
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
4
src/browser/tests/runner/runner1.html
Normal file
4
src/browser/tests/runner/runner1.html
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<div id=sel0>selector-0-content</div>
|
||||||
|
<div id=sel1>selector-1-content</div>
|
||||||
@@ -26,6 +26,8 @@ const GenericIterator = @import("../collections/iterator.zig").Entry;
|
|||||||
const Page = @import("../../Page.zig");
|
const Page = @import("../../Page.zig");
|
||||||
const String = @import("../../../string.zig").String;
|
const String = @import("../../../string.zig").String;
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||||
|
|
||||||
pub fn registerTypes() []const type {
|
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())) {
|
if (!needsLowerCasing(name.str())) {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
@@ -431,6 +433,14 @@ pub fn normalizeNameForLookup(name: String, page: *Page) !String {
|
|||||||
return .wrap(normalized);
|
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 {
|
fn needsLowerCasing(name: []const u8) bool {
|
||||||
var remaining = name;
|
var remaining = name;
|
||||||
if (comptime std.simd.suggestVectorLength(u8)) |vector_len| {
|
if (comptime std.simd.suggestVectorLength(u8)) |vector_len| {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ fn preprocessInput(arena: Allocator, input: []const u8) ![]const u8 {
|
|||||||
return result.items;
|
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
|
// Preprocess input to normalize line endings
|
||||||
const preprocessed = try preprocessInput(arena, input);
|
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);
|
const selector_input = std.mem.trimRight(u8, trimmed[0..comma_pos], &std.ascii.whitespace);
|
||||||
|
|
||||||
if (selector_input.len > 0) {
|
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);
|
try selectors.append(arena, selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ pub fn parseList(arena: Allocator, input: []const u8, page: *Page) ParseError![]
|
|||||||
return selectors.items;
|
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 parser = Parser{ .input = input };
|
||||||
var segments: std.ArrayList(Segment) = .empty;
|
var segments: std.ArrayList(Segment) = .empty;
|
||||||
var current_compound: std.ArrayList(Part) = .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()) {
|
while (parser.skipSpaces()) {
|
||||||
if (parser.peek() == 0) break;
|
if (parser.peek() == 0) break;
|
||||||
|
|
||||||
const part = try parser.parsePart(arena, page);
|
const part = try parser.parsePart(arena);
|
||||||
try current_compound.append(arena, part);
|
try current_compound.append(arena, part);
|
||||||
|
|
||||||
// Check what comes after this 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()) {
|
while (parser.skipSpaces()) {
|
||||||
if (parser.peek() == 0) break;
|
if (parser.peek() == 0) break;
|
||||||
|
|
||||||
const part = try parser.parsePart(arena, page);
|
const part = try parser.parsePart(arena);
|
||||||
try current_compound.append(arena, part);
|
try current_compound.append(arena, part);
|
||||||
|
|
||||||
// Check what comes after this 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()) {
|
return switch (self.peek()) {
|
||||||
'#' => .{ .id = try self.id(arena) },
|
'#' => .{ .id = try self.id(arena) },
|
||||||
'.' => .{ .class = try self.class(arena) },
|
'.' => .{ .class = try self.class(arena) },
|
||||||
@@ -297,16 +297,17 @@ fn parsePart(self: *Parser, arena: Allocator, page: *Page) !Part {
|
|||||||
self.input = self.input[1..];
|
self.input = self.input[1..];
|
||||||
break :blk .universal;
|
break :blk .universal;
|
||||||
},
|
},
|
||||||
'[' => .{ .attribute = try self.attribute(arena, page) },
|
'[' => .{ .attribute = try self.attribute(arena) },
|
||||||
':' => .{ .pseudo_class = try self.pseudoClass(arena, page) },
|
':' => .{ .pseudo_class = try self.pseudoClass(arena) },
|
||||||
'a'...'z', 'A'...'Z', '_', '\\', 0x80...0xFF => blk: {
|
'a'...'z', 'A'...'Z', '_', '\\', 0x80...0xFF => blk: {
|
||||||
// Use parseIdentifier for full escape support
|
// Use parseIdentifier for full escape support
|
||||||
const tag_name = try self.parseIdentifier(arena, error.InvalidTagSelector);
|
const tag_name = try self.parseIdentifier(arena, error.InvalidTagSelector);
|
||||||
if (tag_name.len > 256) {
|
if (tag_name.len > 256) {
|
||||||
return error.InvalidTagSelector;
|
return error.InvalidTagSelector;
|
||||||
}
|
}
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
// Try to match as a known tag enum for optimization
|
// 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| {
|
if (Node.Element.Tag.parseForMatch(lower)) |known_tag| {
|
||||||
break :blk .{ .tag = known_tag };
|
break :blk .{ .tag = known_tag };
|
||||||
}
|
}
|
||||||
@@ -373,7 +374,7 @@ fn consumeUntilCommaOrParen(self: *Parser) []const u8 {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoClass {
|
fn pseudoClass(self: *Parser, arena: Allocator) !Selector.PseudoClass {
|
||||||
if (comptime IS_DEBUG) {
|
if (comptime IS_DEBUG) {
|
||||||
// Should have been verified by caller
|
// Should have been verified by caller
|
||||||
std.debug.assert(self.peek() == ':');
|
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;
|
if (self.peek() == 0) return error.InvalidPseudoClass;
|
||||||
|
|
||||||
// Parse a full selector (with potential combinators and compounds)
|
// 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);
|
try selectors.append(arena, selector);
|
||||||
|
|
||||||
_ = self.skipSpaces();
|
_ = self.skipSpaces();
|
||||||
@@ -472,7 +473,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
|
|||||||
if (self.peek() == ')') break;
|
if (self.peek() == ')') break;
|
||||||
if (self.peek() == 0) return error.InvalidPseudoClass;
|
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);
|
try selectors.append(arena, selector);
|
||||||
|
|
||||||
_ = self.skipSpaces();
|
_ = self.skipSpaces();
|
||||||
@@ -499,7 +500,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
|
|||||||
if (self.peek() == ')') break;
|
if (self.peek() == ')') break;
|
||||||
if (self.peek() == 0) return error.InvalidPseudoClass;
|
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);
|
try selectors.append(arena, selector);
|
||||||
|
|
||||||
_ = self.skipSpaces();
|
_ = self.skipSpaces();
|
||||||
@@ -526,7 +527,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
|
|||||||
if (self.peek() == ')') break;
|
if (self.peek() == ')') break;
|
||||||
if (self.peek() == 0) return error.InvalidPseudoClass;
|
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);
|
try selectors.append(arena, selector);
|
||||||
|
|
||||||
_ = self.skipSpaces();
|
_ = self.skipSpaces();
|
||||||
@@ -898,7 +899,7 @@ fn tag(self: *Parser) ![]const u8 {
|
|||||||
return input[0..i];
|
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) {
|
if (comptime IS_DEBUG) {
|
||||||
// should have been verified by caller
|
// should have been verified by caller
|
||||||
std.debug.assert(self.peek() == '[');
|
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();
|
const attr_name = try self.attributeName();
|
||||||
|
|
||||||
// Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup)
|
// Normalize the name to lowercase for fast matching (consistent with Attribute.normalizeNameForLookup)
|
||||||
const normalized = try Attribute.normalizeNameForLookup(.wrap(attr_name), page);
|
const name = try Attribute.normalizeNameForLookupAlloc(arena, .wrap(attr_name));
|
||||||
const name = try normalized.dupe(arena);
|
|
||||||
var case_insensitive = false;
|
var case_insensitive = false;
|
||||||
_ = self.skipSpaces();
|
_ = self.skipSpaces();
|
||||||
|
|
||||||
|
|||||||
@@ -20,41 +20,24 @@ const std = @import("std");
|
|||||||
|
|
||||||
const String = @import("../../../string.zig").String;
|
const String = @import("../../../string.zig").String;
|
||||||
|
|
||||||
const Parser = @import("Parser.zig");
|
|
||||||
const Node = @import("../Node.zig");
|
const Node = @import("../Node.zig");
|
||||||
const Page = @import("../../Page.zig");
|
const Page = @import("../../Page.zig");
|
||||||
|
|
||||||
|
const Parser = @import("Parser.zig");
|
||||||
pub const List = @import("List.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) {
|
if (input.len == 0) {
|
||||||
return error.SyntaxError;
|
return error.SyntaxError;
|
||||||
}
|
}
|
||||||
|
return .{ .selectors = try Parser.parseList(arena, input) };
|
||||||
|
}
|
||||||
|
|
||||||
const arena = page.call_arena;
|
pub fn querySelector(root: *Node, input: []const u8, page: *Page) !?*Node.Element {
|
||||||
const selectors = try Parser.parseList(arena, input, page);
|
const parsed = try parseLeaky(page.call_arena, input);
|
||||||
|
return parsed.query(root, 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 querySelectorAll(root: *Node, input: []const u8, page: *Page) !*List {
|
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;
|
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| {
|
for (selectors) |selector| {
|
||||||
try List.collect(arena, root, selector, &nodes, page);
|
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 arena = page.call_arena;
|
||||||
const selectors = try Parser.parseList(arena, input, page);
|
const selectors = try Parser.parseList(arena, input);
|
||||||
|
|
||||||
for (selectors) |selector| {
|
for (selectors) |selector| {
|
||||||
if (List.matches(el.asNode(), selector, el.asNode(), page)) {
|
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 arena = page.call_arena;
|
||||||
const selectors = try Parser.parseList(arena, input, page);
|
const selectors = try Parser.parseList(arena, input);
|
||||||
|
|
||||||
for (selectors) |selector| {
|
for (selectors) |selector| {
|
||||||
if (List.matches(el.asNode(), selector, scope.asNode(), page)) {
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1179,7 +1179,7 @@ test "AXNode: writer" {
|
|||||||
var registry = Node.Registry.init(testing.allocator);
|
var registry = Node.Registry.init(testing.allocator);
|
||||||
defer registry.deinit();
|
defer registry.deinit();
|
||||||
|
|
||||||
var page = try testing.pageTest("cdp/dom3.html");
|
var page = try testing.pageTest("cdp/dom3.html", .{});
|
||||||
defer page._session.removePage();
|
defer page._session.removePage();
|
||||||
var doc = page.window._document;
|
var doc = page.window._document;
|
||||||
|
|
||||||
|
|||||||
@@ -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_id.count());
|
||||||
try testing.expectEqual(0, registry.lookup_by_node.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();
|
defer page._session.removePage();
|
||||||
var doc = page.window._document;
|
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();
|
defer page._session.removePage();
|
||||||
var doc = page.window._document;
|
var doc = page.window._document;
|
||||||
|
|
||||||
@@ -440,7 +440,7 @@ test "cdp Node: Writer" {
|
|||||||
var registry = Registry.init(testing.allocator);
|
var registry = Registry.init(testing.allocator);
|
||||||
defer registry.deinit();
|
defer registry.deinit();
|
||||||
|
|
||||||
var page = try testing.pageTest("cdp/registry3.html");
|
var page = try testing.pageTest("cdp/registry3.html", .{});
|
||||||
defer page._session.removePage();
|
defer page._session.removePage();
|
||||||
var doc = page.window._document;
|
var doc = page.window._document;
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ const IS_DEBUG = @import("builtin").mode == .Debug;
|
|||||||
|
|
||||||
pub const FetchOpts = struct {
|
pub const FetchOpts = struct {
|
||||||
wait_ms: u32 = 5000,
|
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: dump.Opts,
|
||||||
dump_mode: ?Config.DumpFormat = null,
|
dump_mode: ?Config.DumpFormat = null,
|
||||||
writer: ?*std.Io.Writer = 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 },
|
.kind = .{ .push = null },
|
||||||
});
|
});
|
||||||
var runner = try session.runner(.{});
|
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;
|
const writer = opts.writer orelse return;
|
||||||
if (opts.dump_mode) |mode| {
|
if (opts.dump_mode) |mode| {
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ fn run(allocator: Allocator, main_arena: Allocator) !void {
|
|||||||
var fetch_opts = lp.FetchOpts{
|
var fetch_opts = lp.FetchOpts{
|
||||||
.wait_ms = opts.wait_ms,
|
.wait_ms = opts.wait_ms,
|
||||||
.wait_until = opts.wait_until,
|
.wait_until = opts.wait_until,
|
||||||
|
.wait_script = opts.wait_script,
|
||||||
|
.wait_selector = opts.wait_selector,
|
||||||
.dump_mode = opts.dump_mode,
|
.dump_mode = opts.dump_mode,
|
||||||
.dump = .{
|
.dump = .{
|
||||||
.strip = opts.strip,
|
.strip = opts.strip,
|
||||||
|
|||||||
@@ -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.
|
const PageTestOpts = struct {
|
||||||
pub fn pageTest(comptime test_file: []const u8) !*Page {
|
wait_until_done: bool = true,
|
||||||
|
};
|
||||||
|
pub fn pageTest(comptime test_file: []const u8, opts: PageTestOpts) !*Page {
|
||||||
const page = try test_session.createPage();
|
const page = try test_session.createPage();
|
||||||
errdefer test_session.removePage();
|
errdefer test_session.removePage();
|
||||||
|
|
||||||
@@ -466,7 +468,9 @@ pub fn pageTest(comptime test_file: []const u8) !*Page {
|
|||||||
|
|
||||||
try page.navigate(url, .{});
|
try page.navigate(url, .{});
|
||||||
var runner = try test_session.runner(.{});
|
var runner = try test_session.runner(.{});
|
||||||
try runner.wait(.{ .ms = 2000 });
|
if (opts.wait_until_done) {
|
||||||
|
try runner.wait(.{ .ms = 2000 });
|
||||||
|
}
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user