24 Commits

Author SHA1 Message Date
Pierre Tachoire
c04ccf5e87 URL: bound '@' search to authority section
The '@' delimiter for userinfo was searched across the full URL string,
including path, query, and fragment. A URL like `http://attacker.com/@bank.com/`
would incorrectly parse `bank.com` as the host, enabling a Same-Origin Policy bypass.

Restrict the '@' search to the authority section (before any `/?#`) in
getOrigin, getUserInfo, and getHost.
2026-03-26 16:44:58 +01:00
Pierre Tachoire
a0dd14aaad Merge pull request #1999 from lightpanda-io/wait_until_default
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Fix --wait-until default value.
2026-03-26 15:03:59 +01:00
Pierre Tachoire
7ea8f3f766 Merge pull request #2000 from lightpanda-io/add-pre-version
add a -Dpre_version build flag for custom pre version
2026-03-26 12:06:38 +01:00
Pierre Tachoire
28cc60adb0 add a -Dpre_version build flag for custom pre version 2026-03-26 11:52:16 +01:00
Karl Seguin
c14a9ad986 Merge pull request #1992 from navidemad/cdp-page-reload
CDP: implement Page.reload
2026-03-26 18:14:49 +08:00
Karl Seguin
679f2104f4 Fix --wait-until default value.
This was `load`, but it should have been (and was documented as `done`). This
is my fault. Sorry.

Should help with: https://github.com/lightpanda-io/browser/issues/1947#issuecomment-4120597764
2026-03-26 18:06:14 +08:00
Navid EMAD
c6b0c75106 Address review: use arena.dupeZ for URL copy, add try to testing.context()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Navid EMAD
93485c1ef3 CDP: implement Page.reload
Add `Page.reload` to the CDP Page domain dispatch. Reuses the existing
`page.navigate()` path with `NavigationKind.reload`, matching what
`Location.reload` already does for the JS `location.reload()` API.

Accepts the standard CDP params (`ignoreCache`, `scriptToEvaluateOnLoad`)
per the Chrome DevTools Protocol spec.

The current page URL is copied to the stack before `replacePage()` to
avoid a use-after-free when the old page's arena is freed.

This unblocks CDP clients (Puppeteer, capybara-lightpanda, etc.) that
call `Page.reload` and currently get `UnknownMethod`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:09:48 +01:00
Karl Seguin
0324d5c232 Merge pull request #1997 from lightpanda-io/update-zig-v8
build: bump zig-v8 to v0.3.7
2026-03-26 16:01:40 +08:00
Adrià Arrufat
a75c0cf08d build: bump zig-v8 to v0.3.7 2026-03-26 12:34:10 +09:00
Karl Seguin
2812b8f07c Merge pull request #1991 from lightpanda-io/v8_signature
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / wba-demo-scripts (push) Has been cancelled
e2e-test / wba-test (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
e2e-test / browser fetch (push) Has been cancelled
zig-test / zig fmt (push) Has been cancelled
zig-test / zig test using v8 in debug mode (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
Set v8::Signature on FunctionTemplates
2026-03-26 09:27:22 +08:00
Karl Seguin
e2afbec29d update v8 dep 2026-03-26 09:17:32 +08:00
Karl Seguin
a45f9cb810 Set v8::Signature on FunctionTemplates
This causes v8 to verify the receiver of a function, and prevents calling an
accessor or function with the wrong receiver, e.g.:

```
const g = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
g.call(null);
```

A few other cleanups in this commit:
1 - Define any accessor with a getter as ReadOnly
2 - Ability to define an accessor with the DontDelete attribute
    (window.document and window.location)
3 - Replace v8__ObjectTemplate__SetAccessorProperty__DEFAULTX overloads with
    new v8__ObjectTemplate__SetAccessorProperty__Config
4 - Remove unnecessary @constCast for FunctionTemplate which can be const
    everywhere.
2026-03-26 09:15:33 +08:00
Karl Seguin
cf641ed458 Merge pull request #1990 from lightpanda-io/remove_cdp_generic
Remove cdp generic
2026-03-26 07:49:13 +08:00
Karl Seguin
0fc959dcc5 re-anble unreachable 2026-03-26 07:42:45 +08:00
Karl Seguin
077376ea04 Merge pull request #1985 from lightpanda-io/intersection_observer_root_document
Allow Document to be the root of an intersection observer
2026-03-26 07:41:40 +08:00
Karl Seguin
6ed8d1d201 Merge pull request #1981 from lightpanda-io/window_cross_origin
Window cross origin
2026-03-26 07:41:22 +08:00
Karl Seguin
5207bd4202 Merge pull request #1980 from lightpanda-io/frames_test
Improve async tests
2026-03-26 07:41:05 +08:00
Karl Seguin
11ed95290b Improve async tests
testing.async(...) is pretty lame. It works for simple cases, where the
microtask is very quickly resolved, but otherwise can't block the test from
exiting.

This adds an overload to testing.async and leverages the new Runner
https://github.com/lightpanda-io/browser/pull/1958 to "tick" until completion
(or timeout).

The overloaded version of testing.async() (called without a callback) will
increment a counter which is only decremented with the promise is resolved. The
test runner will now `tick` until the counter == 0.
2026-03-26 07:35:05 +08:00
Karl Seguin
ca41bb5fa2 fix import casing 2026-03-25 17:54:24 +08:00
Karl Seguin
0dd0495ab8 Removes CDPT (generic CDP)
CDPT used to be a generic so that we could inject Browser, Session, Page and
Client. At some point, it [thankfully] became a generic only to inject Client.

This commit removes the generic and bakes the *Server.Client instance in CDP.
It uses a socketpair for testing.

BrowserContext is still generic, but that's generic for a very different reason
and, while I'd like to remove that generic too, it belongs in a different PR.
2026-03-25 17:43:30 +08:00
Karl Seguin
ae080f32eb Allow Document to be the root of an intersection observer
We previously only supported an Element. null == viewport, but document means
the entire (scrollable) area, since we don't render anything, treating
document  as null seems ok?
2026-03-24 21:48:38 +08:00
Karl Seguin
b19f30d865 Start allowing some cross-origin scripting.
There are a few things allowed in cross origin scripting, the most important
being window.postMessage and window.parent.

This commit changes window-returning functions (e.g. window.top, window.parent
iframe.contentWindow) from always returning a *Window, to conditionally
returning a *Window or a *CrossOriginWindow. The CrossOriginWindow only allows
a few methods (e.g. postMessage).
2026-03-24 19:27:55 +08:00
Karl Seguin
35be9f897f Improve async tests
testing.async(...) is pretty lame. It works for simple cases, where the
microtask is very quickly resolved, but otherwise can't block the test from
exiting.

This adds an overload to testing.async and leverages the new Runner
https://github.com/lightpanda-io/browser/pull/1958 to "tick" until completion
(or timeout).

The overloaded version of testing.async() (called without a callback) will
increment a counter which is only decremented with the promise is resolved. The
test runner will now `tick` until the counter == 0.
2026-03-24 17:21:39 +08:00
36 changed files with 930 additions and 481 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8: zig-v8:
description: 'zig v8 version to install' description: 'zig v8 version to install'
required: false required: false
default: 'v0.3.4' default: 'v0.3.7'
v8: v8:
description: 'v8 version to install' description: 'v8 version to install'
required: false required: false

View File

@@ -7,7 +7,7 @@ env:
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }} AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }} RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
VERSION: ${{ github.ref_type == 'tag' && github.ref_name || '1.0.0-nightly' }} VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dversion_string={0}', github.ref_name) || format('-Dpre_version={0}', 'nightly') }}
on: on:
push: push:
@@ -45,7 +45,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -85,7 +85,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -127,7 +127,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -167,7 +167,7 @@ jobs:
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build - name: zig build
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dversion_string=${{ env.VERSION }} run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }}
- name: Rename binary - name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12 ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4 ARG V8=14.0.365.4
ARG ZIG_V8=v0.3.4 ARG ZIG_V8=v0.3.7
ARG TARGETPLATFORM ARG TARGETPLATFORM
RUN apt-get update -yq && \ RUN apt-get update -yq && \

View File

@@ -728,8 +728,17 @@ fn resolveVersion(b: *std.Build) std.SemanticVersion {
}; };
} }
const pre_version = b.option([]const u8, "pre_version", "Override the pre version of this build");
const pre = blk: {
if (pre_version) |pre| {
break :blk pre;
}
break :blk lightpanda_version.pre;
};
// If it's a stable release (no pre or build metadata in build.zig.zon), use it as is // If it's a stable release (no pre or build metadata in build.zig.zon), use it as is
if (lightpanda_version.pre == null and lightpanda_version.build == null) return lightpanda_version; if (pre == null and lightpanda_version.build == null) return lightpanda_version;
// For dev/nightly versions, calculate the commit count and hash // For dev/nightly versions, calculate the commit count and hash
const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return lightpanda_version; const git_hash_raw = runGit(b, &.{ "rev-parse", "--short", "HEAD" }) catch return lightpanda_version;
@@ -742,7 +751,7 @@ fn resolveVersion(b: *std.Build) std.SemanticVersion {
.major = lightpanda_version.major, .major = lightpanda_version.major,
.minor = lightpanda_version.minor, .minor = lightpanda_version.minor,
.patch = lightpanda_version.patch, .patch = lightpanda_version.patch,
.pre = b.fmt("{s}.{s}", .{ lightpanda_version.pre.?, commit_count }), .pre = b.fmt("{s}.{s}", .{ pre.?, commit_count }),
.build = commit_hash, .build = commit_hash,
}; };
} }

View File

@@ -5,8 +5,8 @@
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.15.2",
.dependencies = .{ .dependencies = .{
.v8 = .{ .v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.4.tar.gz", .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.3.7.tar.gz",
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup", .hash = "v8-0.0.0-xddH67uBBAD95hWsPQz3Ni1PlZjdywtPXrGUAp8rSKco",
}, },
// .v8 = .{ .path = "../zig-v8-fork" }, // .v8 = .{ .path = "../zig-v8-fork" },
.brotli = .{ .brotli = .{

View File

@@ -247,7 +247,7 @@ 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 = .load, wait_until: WaitUntil = .done,
}; };
pub const Common = struct { pub const Common = struct {
@@ -665,7 +665,7 @@ 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 = .load; var wait_until: WaitUntil = .done;
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)) {

View File

@@ -27,7 +27,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig"); const log = @import("log.zig");
const App = @import("App.zig"); const App = @import("App.zig");
const Config = @import("Config.zig"); const Config = @import("Config.zig");
const CDP = @import("cdp/cdp.zig").CDP; const CDP = @import("cdp/CDP.zig");
const Net = @import("network/websocket.zig"); const Net = @import("network/websocket.zig");
const HttpClient = @import("browser/HttpClient.zig"); const HttpClient = @import("browser/HttpClient.zig");
@@ -212,7 +212,7 @@ pub const Client = struct {
http: *HttpClient, http: *HttpClient,
ws: Net.WsConnection, ws: Net.WsConnection,
fn init( pub fn init(
socket: posix.socket_t, socket: posix.socket_t,
allocator: Allocator, allocator: Allocator,
app: *App, app: *App,
@@ -250,7 +250,7 @@ pub const Client = struct {
self.ws.shutdown(); self.ws.shutdown();
} }
fn deinit(self: *Client) void { pub fn deinit(self: *Client) void {
switch (self.mode) { switch (self.mode) {
.cdp => |*cdp| cdp.deinit(), .cdp => |*cdp| cdp.deinit(),
.http => {}, .http => {},
@@ -461,7 +461,7 @@ pub const Client = struct {
fn upgradeConnection(self: *Client, request: []u8) !void { fn upgradeConnection(self: *Client, request: []u8) !void {
try self.ws.upgrade(request); try self.ws.upgrade(request);
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) }; self.mode = .{ .cdp = try CDP.init(self) };
} }
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void { fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {

View File

@@ -300,7 +300,9 @@ pub fn init(self: *Page, frame_id: u32, session: *Session, parent: ?*Page) !void
._performance = Performance.init(), ._performance = Performance.init(),
._screen = screen, ._screen = screen,
._visual_viewport = visual_viewport, ._visual_viewport = visual_viewport,
._cross_origin_wrapper = undefined,
}); });
self.window._cross_origin_wrapper = .{ .window = self.window };
self._style_manager = try StyleManager.init(self); self._style_manager = try StyleManager.init(self);
errdefer self._style_manager.deinit(); errdefer self._style_manager.deinit();
@@ -3587,12 +3589,7 @@ test "WebApi: Page" {
} }
test "WebApi: Frames" { test "WebApi: Frames" {
// TOO FLAKY, disabled for now try testing.htmlRunner("frames", .{});
// const filter: testing.LogFilter = .init(&.{.js});
// defer filter.deinit();
// try testing.htmlRunner("frames", .{});
} }
test "WebApi: Integration" { test "WebApi: Integration" {

View File

@@ -404,10 +404,6 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
} }
var authority_start = scheme_end + 3; var authority_start = scheme_end + 3;
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| blk: {
authority_start += pos + 1;
break :blk true;
} else false;
// Find end of authority (start of path/query/fragment or end of string) // Find end of authority (start of path/query/fragment or end of string)
const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#"); const authority_end_relative = std.mem.indexOfAny(u8, raw[authority_start..], "/?#");
@@ -416,6 +412,12 @@ pub fn getOrigin(allocator: Allocator, raw: [:0]const u8) !?[]const u8 {
else else
raw.len; raw.len;
// We mustn't search the `@` after the first path separator.
const has_user_info = if (std.mem.indexOf(u8, raw[authority_start..authority_end], "@")) |pos| blk: {
authority_start += pos + 1;
break :blk true;
} else false;
// Check for port in the host:port section // Check for port in the host:port section
const host_part = raw[authority_start..authority_end]; const host_part = raw[authority_start..authority_end];
if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| { if (std.mem.lastIndexOfScalar(u8, host_part, ':')) |colon_pos_in_host| {
@@ -461,8 +463,15 @@ fn getUserInfo(raw: [:0]const u8) ?[]const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null; const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return null;
const authority_start = scheme_end + 3; const authority_start = scheme_end + 3;
const pos = std.mem.indexOfScalar(u8, raw[authority_start..], '@') orelse return null; // We mustn't search the `@` after the first path separator.
const path_start = std.mem.indexOfScalarPos(u8, raw, authority_start, '/') orelse raw.len; const path_start = blk: {
if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |idx| {
break :blk authority_start + idx;
}
break :blk raw.len;
};
const pos = std.mem.indexOfScalar(u8, raw[authority_start..path_start], '@') orelse return null;
const full_pos = authority_start + pos; const full_pos = authority_start + pos;
if (full_pos < path_start) { if (full_pos < path_start) {
@@ -476,13 +485,20 @@ pub fn getHost(raw: [:0]const u8) []const u8 {
const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return ""; const scheme_end = std.mem.indexOf(u8, raw, "://") orelse return "";
var authority_start = scheme_end + 3; var authority_start = scheme_end + 3;
if (std.mem.indexOf(u8, raw[authority_start..], "@")) |pos| {
// We mustn't search the `@` after the first path separator.
const path_start = blk: {
if (std.mem.indexOfAny(u8, raw[authority_start..], "/?#")) |idx| {
break :blk authority_start + idx;
}
break :blk raw.len;
};
if (std.mem.indexOf(u8, raw[authority_start..path_start], "@")) |pos| {
authority_start += pos + 1; authority_start += pos + 1;
} }
const authority = raw[authority_start..]; return raw[authority_start..path_start];
const path_start = std.mem.indexOfAny(u8, authority, "/?#") orelse return authority;
return authority[0..path_start];
} }
// Returns true if these two URLs point to the same document. // Returns true if these two URLs point to the same document.
@@ -1449,3 +1465,36 @@ test "URL: setPathname percent-encodes" {
const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator); const result3 = try setPathname("https://example.com/path?a=b#hash", "/new path", allocator);
try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3); try testing.expectEqualSlices(u8, "https://example.com/new%20path?a=b#hash", result3);
} }
test "URL: getOrigin" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
try testing.expectEqualSlices(u8, "http://example.com:8080", try getOrigin(allocator, "http://example.com:8080/path") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com:8080", try getOrigin(allocator, "https://example.com:8080/path") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://example.com/path") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://example.com:443/") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com", try getOrigin(allocator, "https://user:pass@example.com/page") orelse unreachable);
try testing.expectEqualSlices(u8, "https://example.com:8080", try getOrigin(allocator, "https://user:pass@example.com:8080/page") orelse unreachable);
try testing.expectEqual(null, try getOrigin(allocator, "not-a-url"));
}
test "URL: SOP bypass" {
// SOP Bypass
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHost("https://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com?@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHost("http://attacker.com#@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("https://attacker.com/@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com?@bank.com/"));
try testing.expectEqualSlices(u8, "attacker.com", getHostname("http://attacker.com#@bank.com/"));
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const allocator = arena.allocator();
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com/@bank.com/") orelse unreachable);
try testing.expectEqualSlices(u8, "https://attacker.com", try getOrigin(allocator, "https://attacker.com/@bank.com/") orelse unreachable);
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com?bank.com/") orelse unreachable);
try testing.expectEqualSlices(u8, "http://attacker.com", try getOrigin(allocator, "http://attacker.com#bank.com/") orelse unreachable);
}

View File

@@ -505,6 +505,7 @@ pub const Function = struct {
pub const Opts = struct { pub const Opts = struct {
noop: bool = false, noop: bool = false,
static: bool = false, static: bool = false,
deletable: bool = true,
dom_exception: bool = false, dom_exception: bool = false,
as_typed_array: bool = false, as_typed_array: bool = false,
null_as_undefined: bool = false, null_as_undefined: bool = false,

View File

@@ -137,7 +137,7 @@ pub fn create() !Snapshot {
defer v8.v8__HandleScope__DESTRUCT(&handle_scope); defer v8.v8__HandleScope__DESTRUCT(&handle_scope);
// Create templates (constructors only) FIRST // Create templates (constructors only) FIRST
var templates: [JsApis.len]*v8.FunctionTemplate = undefined; var templates: [JsApis.len]*const v8.FunctionTemplate = undefined;
inline for (JsApis, 0..) |JsApi, i| { inline for (JsApis, 0..) |JsApi, i| {
@setEvalBranchQuota(10_000); @setEvalBranchQuota(10_000);
templates[i] = generateConstructor(JsApi, isolate); templates[i] = generateConstructor(JsApi, isolate);
@@ -419,7 +419,7 @@ fn collectExternalReferences() [countExternalReferences()]isize {
// via `new ClassName()` - but they could, for example, be created in // via `new ClassName()` - but they could, for example, be created in
// Zig and returned from a function call, which is why we need the // Zig and returned from a function call, which is why we need the
// FunctionTemplate. // FunctionTemplate.
fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionTemplate { fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *const v8.FunctionTemplate {
const callback = blk: { const callback = blk: {
if (@hasDecl(JsApi, "constructor")) { if (@hasDecl(JsApi, "constructor")) {
break :blk JsApi.constructor.func; break :blk JsApi.constructor.func;
@@ -429,7 +429,7 @@ fn generateConstructor(comptime JsApi: type, isolate: *v8.Isolate) *v8.FunctionT
break :blk illegalConstructorCallback; break :blk illegalConstructorCallback;
}; };
const template = @constCast(v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?); const template = v8.v8__FunctionTemplate__New__DEFAULT2(isolate, callback).?;
{ {
const internal_field_count = comptime countInternalFields(JsApi); const internal_field_count = comptime countInternalFields(JsApi);
if (internal_field_count > 0) { if (internal_field_count > 0) {
@@ -482,10 +482,15 @@ pub fn countInternalFields(comptime JsApi: type) u8 {
} }
// Attaches JsApi members to the prototype template (normal case) // Attaches JsApi members to the prototype template (normal case)
fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.FunctionTemplate) void { fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.FunctionTemplate) void {
const instance = v8.v8__FunctionTemplate__InstanceTemplate(template); const instance = v8.v8__FunctionTemplate__InstanceTemplate(template);
const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template); const prototype = v8.v8__FunctionTemplate__PrototypeTemplate(template);
// Create a signature that validates the receiver is an instance of this template.
// This prevents crashes when JavaScript extracts a getter/method and calls it
// with the wrong `this` (e.g., documentGetter.call(null)).
const signature = v8.v8__Signature__New(isolate, template);
const declarations = @typeInfo(JsApi).@"struct".decls; const declarations = @typeInfo(JsApi).@"struct".decls;
var has_named_index_getter = false; var has_named_index_getter = false;
@@ -497,23 +502,47 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
switch (definition) { switch (definition) {
bridge.Accessor => { bridge.Accessor => {
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
const getter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.getter }).?); const getter_signature = if (value.static) null else signature;
const getter_callback = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.getter,
.signature = getter_signature,
}).?;
const setter_callback = if (value.setter) |setter|
v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
}).?
else
null;
var attribute: v8.PropertyAttribute = 0;
if (value.setter == null) { if (value.setter == null) {
if (value.static) { attribute |= v8.ReadOnly;
v8.v8__Template__SetAccessorProperty__DEFAULT(@ptrCast(template), js_name, getter_callback); }
} else { if (value.deletable == false) {
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT(prototype, js_name, getter_callback); attribute |= v8.DontDelete;
} }
if (value.static) {
// Static accessors: use Template's SetAccessorProperty
v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute);
} else { } else {
if (comptime IS_DEBUG) { v8.v8__ObjectTemplate__SetAccessorProperty__Config(prototype, &.{
std.debug.assert(value.static == false); .key = js_name,
} .getter = getter_callback,
const setter_callback = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.setter.? }).?); .setter = setter_callback,
v8.v8__ObjectTemplate__SetAccessorProperty__DEFAULT2(prototype, js_name, getter_callback, setter_callback); .attribute = attribute,
});
} }
}, },
bridge.Function => { bridge.Function => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func, .length = value.arity }).?); // For non-static functions, use the signature to validate the receiver
const func_signature = if (value.static) null else signature;
const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = value.func,
.length = value.arity,
.signature = func_signature,
}).?;
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
if (value.static) { if (value.static) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
@@ -551,7 +580,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *v8.Functio
has_named_index_getter = true; has_named_index_getter = true;
}, },
bridge.Iterator => { bridge.Iterator => {
const function_template = @constCast(v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?); const function_template = v8.v8__FunctionTemplate__New__Config(isolate, &.{ .callback = value.func }).?;
const js_name = if (value.async) const js_name = if (value.async)
v8.v8__Symbol__GetAsyncIterator(isolate) v8.v8__Symbol__GetAsyncIterator(isolate)
else else

View File

@@ -198,6 +198,7 @@ pub const Function = struct {
pub const Accessor = struct { pub const Accessor = struct {
static: bool = false, static: bool = false,
deletable: bool = true,
cache: ?Caller.Function.Opts.Caching = null, cache: ?Caller.Function.Opts.Caching = null,
getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, getter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null, setter: ?*const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void = null,
@@ -206,6 +207,7 @@ pub const Accessor = struct {
var accessor = Accessor{ var accessor = Accessor{
.cache = opts.cache, .cache = opts.cache,
.static = opts.static, .static = opts.static,
.deletable = opts.deletable,
}; };
if (@typeInfo(@TypeOf(getter)) != .null) { if (@typeInfo(@TypeOf(getter)) != .null) {

View File

@@ -118,24 +118,24 @@
} }
</script> </script>
<script id=link_click> <script id=link_click type=module>
testing.async(async (restore) => { const state = await testing.async();
let f6;
await new Promise((resolve) => { let count = 0;
let count = 0; let f6 = document.createElement('iframe');
f6 = document.createElement('iframe'); f6.id = 'f6';
f6.id = 'f6'; f6.addEventListener('load', () => {
f6.addEventListener('load', () => { if (++count == 2) {
if (++count == 2) { state.resolve();
resolve(); return;
return; }
} f6.contentDocument.querySelector('#link').click();
f6.contentDocument.querySelector('#link').click(); });
});
f6.src = "support/with_link.html"; f6.src = 'support/with_link.html';
document.documentElement.appendChild(f6); document.documentElement.appendChild(f6);
});
restore(); await state.done(() => {
testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML); testing.expectEqual("<html><head></head><body>It was clicked!\n</body></html>", f6.contentDocument.documentElement.outerHTML);
}); });
</script> </script>

View File

@@ -7,7 +7,6 @@
{ {
let reply = null; let reply = null;
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
console.warn('reply')
reply = e.data; reply = e.data;
}); });

View File

@@ -1,7 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<script> <script>
window.addEventListener('message', (e) => { window.addEventListener('message', (e) => {
console.warn('Frame Message', e.data);
if (e.data === 'ping') { if (e.data === 'ping') {
window.top.postMessage({data: 'pong', origin: e.origin}, '*'); window.top.postMessage({data: 'pong', origin: e.origin}, '*');
} }

View File

@@ -4,6 +4,7 @@
let eventuallies = []; let eventuallies = [];
let async_capture = null; let async_capture = null;
let current_script_id = null; let current_script_id = null;
let async_pending = 0;
function expectTrue(actual) { function expectTrue(actual) {
expectEqual(true, actual); expectEqual(true, actual);
@@ -64,6 +65,25 @@
} }
async function async(cb) { async function async(cb) {
if (cb == undefined) {
let resolve = null
const promise = new Promise((r) => { resolve = r});
async_pending += 1;
return {
promise: promise,
resolve: resolve,
capture: {script_id: document.currentScript.id, stack: new Error().stack},
done: async function(cb) {
await this.promise;
async_pending -= 1;
async_capture = this.capture;
cb();
async_capture = false;
}
};
}
let capture = {script_id: document.currentScript.id, stack: new Error().stack}; let capture = {script_id: document.currentScript.id, stack: new Error().stack};
await cb(() => { async_capture = capture; }); await cb(() => { async_capture = capture; });
async_capture = null; async_capture = null;
@@ -74,6 +94,10 @@
throw new Error('Failed'); throw new Error('Failed');
} }
if (async_pending > 0) {
return false;
}
for (let e of eventuallies) { for (let e of eventuallies) {
current_script_id = e.script_id; current_script_id = e.script_id;
e.callback(); e.callback();
@@ -97,6 +121,8 @@
throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`); throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`);
} }
} }
return true;
} }
const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/"); const IS_TEST_RUNNER = window.navigator.userAgent.startsWith("Lightpanda/");

View File

@@ -871,3 +871,33 @@
testing.expectEqual('', url.search); testing.expectEqual('', url.search);
} }
</script> </script>
<script id="SOP Bypass">
{
const url = new URL('http://example.com/@bank.com');
testing.expectEqual('http:', url.protocol);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('http://example.com', url.origin);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
}
{
const url = new URL('http://example.com?@bank.com');
testing.expectEqual('http:', url.protocol);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('http://example.com', url.origin);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
}
{
const url = new URL('http://example.com#@bank.com');
testing.expectEqual('http:', url.protocol);
testing.expectEqual('example.com', url.hostname);
testing.expectEqual('', url.port);
testing.expectEqual('http://example.com', url.origin);
testing.expectEqual('', url.username);
testing.expectEqual('', url.password);
}
</script>

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<iframe src=support/frame1.html></iframe>
<script id=post_message type=module>
const state = await testing.async();
{
const ALT_BASE = testing.BASE_URL.replace('127.0.0.1', 'localhost');
{
let iframe2 = document.createElement('iframe');
iframe2.src = ALT_BASE + 'window/support/frame1.html';
document.documentElement.appendChild(iframe2);
}
{
let iframe3 = document.createElement('iframe');
iframe3.src = ALT_BASE + 'window/support/frame2.html';
document.documentElement.appendChild(iframe3);
}
let captures = [];
window.addEventListener('message', (e) => {
captures.push(e.data);
if (captures.length == 3) {
state.resolve();
}
});
await state.done(() => {
const expected_urls = [
testing.BASE_URL + 'window/support/frame1.html',
ALT_BASE + 'window/support/frame1.html',
ALT_BASE + 'window/support/frame2.html',
];
// No strong order guarantee for messaages, and we don't care about the order
// so long as it's the correct data.
testing.expectEqual(expected_urls.sort(), captures.map((c) => {return c.url}).sort());
captures.forEach((c) => {
if (c.url.includes(testing.BASE_URL)) {
testing.expectEqual(false, c.document_is_undefined);
} else {
testing.expectEqual(true, c.document_is_undefined);
}
});
});
}
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<script>
window.parent.postMessage({
url: location.toString(),
document_is_undefined: window.parent.document === undefined,
}, '*')
</script>

View File

@@ -0,0 +1,7 @@
<!DOCTYPE html>
<script>
window.top.postMessage({
url: location.toString(),
document_is_undefined: window.top.document === undefined,
}, '*')
</script>

View File

@@ -262,6 +262,31 @@
} }
</script> </script>
<script id=cached_getter_wrong_this>
// Test that extracting a cached property getter and calling it with wrong `this`
// doesn't crash (V8 internal field out of bounds). V8's Signature validation
// should throw "Illegal invocation" for wrong receiver types.
const documentGetter = Object.getOwnPropertyDescriptor(Window.prototype, 'document').get;
// Verify we get an error with wrong this values
let errorCount = 0;
const testValues = [{}, null, undefined, 42, 'string', [], () => {}];
for (const val of testValues) {
try {
documentGetter.call(val);
} catch (e) {
if (e.message.includes('Illegal invocation')) {
errorCount++;
}
}
}
// At least some should throw (null/undefined/primitives get coerced in sloppy mode)
testing.expectEqual(true, errorCount > 0);
// Calling with correct this should still work
testing.expectEqual(document, documentGetter.call(window));
</script>
<script id=unhandled_rejection> <script id=unhandled_rejection>
{ {
let unhandledCalled = 0; let unhandledCalled = 0;

View File

@@ -25,6 +25,8 @@ const Allocator = std.mem.Allocator;
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Session = @import("../Session.zig"); const Session = @import("../Session.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig"); const Element = @import("Element.zig");
const DOMRect = @import("DOMRect.zig"); const DOMRect = @import("DOMRect.zig");
@@ -55,7 +57,7 @@ var zero_rect: DOMRect = .{
}; };
pub const ObserverInit = struct { pub const ObserverInit = struct {
root: ?*Element = null, root: ?*Node = null,
rootMargin: ?[]const u8 = null, rootMargin: ?[]const u8 = null,
threshold: Threshold = .{ .scalar = 0.0 }, threshold: Threshold = .{ .scalar = 0.0 },
@@ -81,11 +83,25 @@ pub fn init(callback: js.Function.Temp, options: ?ObserverInit, page: *Page) !*I
.array => |arr| try arena.dupe(f64, arr), .array => |arr| try arena.dupe(f64, arr),
}; };
const root: ?*Element = blk: {
const root_opt = opts.root orelse break :blk null;
switch (root_opt._type) {
.element => |el| break :blk el,
.document => {
// not strictly correct, `null` means the viewport, not the
// entire document, but since we don't render anything, this
// should be fine.
break :blk null;
},
else => return error.TypeError,
}
};
const self = try arena.create(IntersectionObserver); const self = try arena.create(IntersectionObserver);
self.* = .{ self.* = .{
._arena = arena, ._arena = arena,
._callback = callback, ._callback = callback,
._root = opts.root, ._root = root,
._root_margin = root_margin, ._root_margin = root_margin,
._threshold = threshold, ._threshold = threshold,
}; };

View File

@@ -49,6 +49,10 @@ const IS_DEBUG = builtin.mode == .Debug;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
pub fn registerTypes() []const type {
return &.{ Window, CrossOriginWindow };
}
const Window = @This(); const Window = @This();
_proto: *EventTarget, _proto: *EventTarget,
@@ -87,6 +91,8 @@ _scroll_pos: struct {
.y = 0, .y = 0,
.state = .done, .state = .done,
}, },
// A cross origin wrapper for this window
_cross_origin_wrapper: CrossOriginWindow,
pub fn asEventTarget(self: *Window) *EventTarget { pub fn asEventTarget(self: *Window) *EventTarget {
return self._proto; return self._proto;
@@ -104,19 +110,19 @@ pub fn getWindow(self: *Window) *Window {
return self; return self;
} }
pub fn getTop(self: *Window) *Window { pub fn getTop(self: *Window, page: *Page) Access {
var p = self._page; var p = self._page;
while (p.parent) |parent| { while (p.parent) |parent| {
p = parent; p = parent;
} }
return p.window; return Access.init(page.window, p.window);
} }
pub fn getParent(self: *Window) *Window { pub fn getParent(self: *Window, page: *Page) Access {
if (self._page.parent) |p| { if (self._page.parent) |p| {
return p.window; return Access.init(page.window, p.window);
} }
return self; return .{ .window = self };
} }
pub fn getDocument(self: *Window) *Document { pub fn getDocument(self: *Window) *Document {
@@ -606,6 +612,25 @@ pub fn unhandledPromiseRejection(self: *Window, no_handler: bool, rejection: js.
} }
} }
pub const Access = union(enum) {
window: *Window,
cross_origin: *CrossOriginWindow,
pub fn init(callee: *Window, accessing: *Window) Access {
if (callee == accessing) {
// common enough that it's worth the check
return .{ .window = accessing };
}
if (callee._page.js.origin == accessing._page.js.origin) {
// two different windows, but same origin, return the full window
return .{ .window = accessing };
}
return .{ .cross_origin = &accessing._cross_origin_wrapper };
}
};
const ScheduleOpts = struct { const ScheduleOpts = struct {
repeat: bool, repeat: bool,
params: []js.Value.Temp, params: []js.Value.Temp,
@@ -804,7 +829,7 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined; pub var class_id: bridge.ClassId = undefined;
}; };
pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 } }); pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = .{ .internal = 1 }, .deletable = false });
pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } }); pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = .{ .internal = 2 } });
pub const top = bridge.accessor(Window.getTop, null, .{}); pub const top = bridge.accessor(Window.getTop, null, .{});
@@ -817,7 +842,7 @@ pub const JsApi = struct {
pub const performance = bridge.accessor(Window.getPerformance, null, .{}); pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{}); pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{}); pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{}); pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false });
pub const history = bridge.accessor(Window.getHistory, null, .{}); pub const history = bridge.accessor(Window.getHistory, null, .{});
pub const navigation = bridge.accessor(Window.getNavigation, null, .{}); pub const navigation = bridge.accessor(Window.getNavigation, null, .{});
pub const crypto = bridge.accessor(Window.getCrypto, null, .{}); pub const crypto = bridge.accessor(Window.getCrypto, null, .{});
@@ -892,6 +917,41 @@ pub const JsApi = struct {
}.prompt, .{}); }.prompt, .{});
}; };
const CrossOriginWindow = struct {
window: *Window,
pub fn postMessage(self: *CrossOriginWindow, message: js.Value.Temp, target_origin: ?[]const u8, page: *Page) !void {
return self.window.postMessage(message, target_origin, page);
}
pub fn getTop(self: *CrossOriginWindow, page: *Page) Access {
return self.window.getParent(page);
}
pub fn getParent(self: *CrossOriginWindow, page: *Page) Access {
return self.window.getParent(page);
}
pub fn getFramesLength(self: *const CrossOriginWindow) u32 {
return self.window.getFramesLength();
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CrossOriginWindow);
pub const Meta = struct {
pub const name = "CrossOriginWindow";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const postMessage = bridge.function(CrossOriginWindow.postMessage, .{});
pub const top = bridge.accessor(CrossOriginWindow.getTop, null, .{});
pub const parent = bridge.accessor(CrossOriginWindow.getParent, null, .{});
pub const length = bridge.accessor(CrossOriginWindow.getFramesLength, null, .{});
};
};
const testing = @import("../../testing.zig"); const testing = @import("../../testing.zig");
test "WebApi: Window" { test "WebApi: Window" {
try testing.htmlRunner("window", .{}); try testing.htmlRunner("window", .{});

View File

@@ -39,8 +39,9 @@ pub fn asNode(self: *IFrame) *Node {
return self.asElement().asNode(); return self.asElement().asNode();
} }
pub fn getContentWindow(self: *const IFrame) ?*Window { pub fn getContentWindow(self: *const IFrame, page: *Page) ?Window.Access {
return self._window; const frame_window = self._window orelse return null;
return Window.Access.init(page.window, frame_window);
} }
pub fn getContentDocument(self: *const IFrame) ?*Document { pub fn getContentDocument(self: *const IFrame) ?*Document {

View File

@@ -22,297 +22,294 @@ const lp = @import("lightpanda");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const json = std.json; const json = std.json;
const log = @import("../log.zig"); const Incrementing = @import("id.zig").Incrementing;
const js = @import("../browser/js/js.zig");
const log = @import("../log.zig");
const App = @import("../App.zig"); const App = @import("../App.zig");
const Notification = @import("../Notification.zig");
const Client = @import("../Server.zig").Client;
const js = @import("../browser/js/js.zig");
const Browser = @import("../browser/Browser.zig"); const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig"); const Session = @import("../browser/Session.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const Page = @import("../browser/Page.zig"); const Page = @import("../browser/Page.zig");
const Incrementing = @import("id.zig").Incrementing;
const Notification = @import("../Notification.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
const Mime = @import("../browser/Mime.zig"); const Mime = @import("../browser/Mime.zig");
const HttpClient = @import("../browser/HttpClient.zig");
const InterceptState = @import("domains/fetch.zig").InterceptState;
pub const URL_BASE = "chrome://newtab/"; pub const URL_BASE = "chrome://newtab/";
const IS_DEBUG = @import("builtin").mode == .Debug; const IS_DEBUG = @import("builtin").mode == .Debug;
pub const CDP = CDPT(struct {
const Client = *@import("../Server.zig").Client;
});
const SessionIdGen = Incrementing(u32, "SID");
const TargetIdGen = Incrementing(u32, "TID"); const TargetIdGen = Incrementing(u32, "TID");
const SessionIdGen = Incrementing(u32, "SID");
const BrowserContextIdGen = Incrementing(u32, "BID"); const BrowserContextIdGen = Incrementing(u32, "BID");
// Generic so that we can inject mocks into it. // Generic so that we can inject mocks into it.
pub fn CDPT(comptime TypeProvider: type) type { const CDP = @This();
return struct {
// Used for sending message to the client and closing on error
client: TypeProvider.Client,
allocator: Allocator, // Used for sending message to the client and closing on error
client: *Client,
// The active browser allocator: Allocator,
browser: Browser,
// when true, any target creation must be attached. // The active browser
target_auto_attach: bool = false, browser: Browser,
target_id_gen: TargetIdGen = .{}, // when true, any target creation must be attached.
session_id_gen: SessionIdGen = .{}, target_auto_attach: bool = false,
browser_context_id_gen: BrowserContextIdGen = .{},
browser_context: ?BrowserContext(Self), target_id_gen: TargetIdGen = .{},
session_id_gen: SessionIdGen = .{},
browser_context_id_gen: BrowserContextIdGen = .{},
// Re-used arena for processing a message. We're assuming that we're getting browser_context: ?BrowserContext(CDP),
// 1 message at a time.
message_arena: std.heap.ArenaAllocator,
// Used for processing notifications within a browser context. // Re-used arena for processing a message. We're assuming that we're getting
notification_arena: std.heap.ArenaAllocator, // 1 message at a time.
message_arena: std.heap.ArenaAllocator,
// Valid for 1 page navigation (what CDP calls a "renderer") // Used for processing notifications within a browser context.
page_arena: std.heap.ArenaAllocator, notification_arena: std.heap.ArenaAllocator,
// Valid for the entire lifetime of the BrowserContext. Should minimize // Valid for 1 page navigation (what CDP calls a "renderer")
// (or altogether elimiate) our use of this. page_arena: std.heap.ArenaAllocator,
browser_context_arena: std.heap.ArenaAllocator,
const Self = @This(); // Valid for the entire lifetime of the BrowserContext. Should minimize
// (or altogether elimiate) our use of this.
browser_context_arena: std.heap.ArenaAllocator,
pub fn init(app: *App, http_client: *HttpClient, client: TypeProvider.Client) !Self { pub fn init(client: *Client) !CDP {
const allocator = app.allocator; const app = client.app;
const browser = try Browser.init(app, .{ const allocator = app.allocator;
.env = .{ .with_inspector = true }, const browser = try Browser.init(app, .{
.http_client = http_client, .env = .{ .with_inspector = true },
}); .http_client = client.http,
errdefer browser.deinit(); });
errdefer browser.deinit();
return .{ return .{
.client = client, .client = client,
.browser = browser, .browser = browser,
.allocator = allocator, .allocator = allocator,
.browser_context = null, .browser_context = null,
.page_arena = std.heap.ArenaAllocator.init(allocator), .page_arena = std.heap.ArenaAllocator.init(allocator),
.message_arena = std.heap.ArenaAllocator.init(allocator), .message_arena = std.heap.ArenaAllocator.init(allocator),
.notification_arena = std.heap.ArenaAllocator.init(allocator), .notification_arena = std.heap.ArenaAllocator.init(allocator),
.browser_context_arena = std.heap.ArenaAllocator.init(allocator), .browser_context_arena = std.heap.ArenaAllocator.init(allocator),
};
}
pub fn deinit(self: *Self) void {
if (self.browser_context) |*bc| {
bc.deinit();
}
self.browser.deinit();
self.page_arena.deinit();
self.message_arena.deinit();
self.notification_arena.deinit();
self.browser_context_arena.deinit();
}
pub fn handleMessage(self: *Self, msg: []const u8) bool {
// if there's an error, it's already been logged
self.processMessage(msg) catch return false;
return true;
}
pub fn processMessage(self: *Self, msg: []const u8) !void {
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatch(arena.allocator(), self, msg);
}
// @newhttp
// A bit hacky right now. The main server loop doesn't unblock for
// scheduled task. So we run this directly in order to process any
// timeouts (or http events) which are ready to be processed.
pub fn pageWait(self: *Self, ms: u32) !Session.Runner.CDPWaitResult {
const session = &(self.browser.session orelse return error.NoPage);
var runner = try session.runner(.{});
return runner.waitCDP(.{ .ms = ms });
}
// Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget
// calls back into dispatch to capture the response.
pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true,
}) catch return error.InvalidJSON;
var command = Command(Self, @TypeOf(sender)){
.input = .{
.json = str,
.id = input.id,
.action = "",
.params = input.params,
.session_id = input.sessionId,
},
.cdp = self,
.arena = arena,
.sender = sender,
.browser_context = if (self.browser_context) |*bc| bc else null,
};
// See dispatchStartupCommand for more info on this.
var is_startup = false;
if (input.sessionId) |input_session_id| {
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
is_startup = true;
} else if (self.isValidSessionId(input_session_id) == false) {
return command.sendError(-32001, "Unknown sessionId", .{});
}
}
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err), .{}) catch return err;
};
}
}
// A CDP session isn't 100% fully driven by the driver. There's are
// independent actions that the browser is expected to take. For example
// Puppeteer expects the browser to startup a tab and thus have existing
// targets.
// To this end, we create a [very] dummy BrowserContext, Target and
// Session. There isn't actually a BrowserContext, just a special id.
// When messages are received with the "STARTUP" sessionId, we do
// "special" handling - the bare minimum we need to do until the driver
// switches to a real BrowserContext.
// (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
// Stagehand parses the response and error if we don't return a
// correct one for Page.getFrameTree on startup call.
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
// The Page.getFrameTree handles startup response gracefully.
return dispatchCommand(command, method);
}
return command.sendResult(null, .{});
}
fn dispatchCommand(command: anytype, method: []const u8) !void {
const domain = blk: {
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod;
};
command.input.action = method[i + 1 ..];
break :blk method[0..i];
};
switch (domain.len) {
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
else => {},
},
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
else => {},
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
else => {},
},
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
else => {},
},
else => {},
}
return error.UnknownDomain;
}
fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool {
const browser_context = &(self.browser_context orelse return false);
const session_id = browser_context.session_id orelse return false;
return std.mem.eql(u8, session_id, input_session_id);
}
pub fn createBrowserContext(self: *Self) ![]const u8 {
if (self.browser_context != null) {
return error.AlreadyExists;
}
const id = self.browser_context_id_gen.next();
self.browser_context = @as(BrowserContext(Self), undefined);
const browser_context = &self.browser_context.?;
try BrowserContext(Self).init(browser_context, id, self);
return id;
}
pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool {
const bc = &(self.browser_context orelse return false);
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;
return true;
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void {
return self.sendJSON(.{
.method = method,
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
.sessionId = opts.session_id,
});
}
pub fn sendJSON(self: *Self, message: anytype) !void {
return self.client.sendJSON(message, .{
.emit_null_optional_fields = false,
});
}
}; };
} }
pub fn deinit(self: *CDP) void {
if (self.browser_context) |*bc| {
bc.deinit();
}
self.browser.deinit();
self.page_arena.deinit();
self.message_arena.deinit();
self.notification_arena.deinit();
self.browser_context_arena.deinit();
}
pub fn handleMessage(self: *CDP, msg: []const u8) bool {
// if there's an error, it's already been logged
self.processMessage(msg) catch return false;
return true;
}
pub fn processMessage(self: *CDP, msg: []const u8) !void {
const arena = &self.message_arena;
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
return self.dispatch(arena.allocator(), self, msg);
}
// @newhttp
// A bit hacky right now. The main server loop doesn't unblock for
// scheduled task. So we run this directly in order to process any
// timeouts (or http events) which are ready to be processed.
pub fn pageWait(self: *CDP, ms: u32) !Session.Runner.CDPWaitResult {
const session = &(self.browser.session orelse return error.NoPage);
var runner = try session.runner(.{});
return runner.waitCDP(.{ .ms = ms });
}
// Called from above, in processMessage which handles client messages
// but can also be called internally. For example, Target.sendMessageToTarget
// calls back into dispatch to capture the response.
pub fn dispatch(self: *CDP, arena: Allocator, sender: anytype, str: []const u8) !void {
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
.ignore_unknown_fields = true,
}) catch return error.InvalidJSON;
var command = Command(CDP, @TypeOf(sender)){
.input = .{
.json = str,
.id = input.id,
.action = "",
.params = input.params,
.session_id = input.sessionId,
},
.cdp = self,
.arena = arena,
.sender = sender,
.browser_context = if (self.browser_context) |*bc| bc else null,
};
// See dispatchStartupCommand for more info on this.
var is_startup = false;
if (input.sessionId) |input_session_id| {
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
is_startup = true;
} else if (self.isValidSessionId(input_session_id) == false) {
return command.sendError(-32001, "Unknown sessionId", .{});
}
}
if (is_startup) {
dispatchStartupCommand(&command, input.method) catch |err| {
command.sendError(-31999, @errorName(err), .{}) catch return err;
};
} else {
dispatchCommand(&command, input.method) catch |err| {
command.sendError(-31998, @errorName(err), .{}) catch return err;
};
}
}
// A CDP session isn't 100% fully driven by the driver. There's are
// independent actions that the browser is expected to take. For example
// Puppeteer expects the browser to startup a tab and thus have existing
// targets.
// To this end, we create a [very] dummy BrowserContext, Target and
// Session. There isn't actually a BrowserContext, just a special id.
// When messages are received with the "STARTUP" sessionId, we do
// "special" handling - the bare minimum we need to do until the driver
// switches to a real BrowserContext.
// (I can imagine this logic will become driver-specific)
fn dispatchStartupCommand(command: anytype, method: []const u8) !void {
// Stagehand parses the response and error if we don't return a
// correct one for Page.getFrameTree on startup call.
if (std.mem.eql(u8, method, "Page.getFrameTree")) {
// The Page.getFrameTree handles startup response gracefully.
return dispatchCommand(command, method);
}
return command.sendResult(null, .{});
}
fn dispatchCommand(command: anytype, method: []const u8) !void {
const domain = blk: {
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
return error.InvalidMethod;
};
command.input.action = method[i + 1 ..];
break :blk method[0..i];
};
switch (domain.len) {
2 => switch (@as(u16, @bitCast(domain[0..2].*))) {
asUint(u16, "LP") => return @import("domains/lp.zig").processMessage(command),
else => {},
},
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
else => {},
},
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
else => {},
},
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
else => {},
},
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
else => {},
},
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
else => {},
},
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
else => {},
},
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
else => {},
},
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
else => {},
},
13 => switch (@as(u104, @bitCast(domain[0..13].*))) {
asUint(u104, "Accessibility") => return @import("domains/accessibility.zig").processMessage(command),
else => {},
},
else => {},
}
return error.UnknownDomain;
}
fn isValidSessionId(self: *const CDP, input_session_id: []const u8) bool {
const browser_context = &(self.browser_context orelse return false);
const session_id = browser_context.session_id orelse return false;
return std.mem.eql(u8, session_id, input_session_id);
}
pub fn createBrowserContext(self: *CDP) ![]const u8 {
if (self.browser_context != null) {
return error.AlreadyExists;
}
const id = self.browser_context_id_gen.next();
self.browser_context = @as(BrowserContext(CDP), undefined);
const browser_context = &self.browser_context.?;
try BrowserContext(CDP).init(browser_context, id, self);
return id;
}
pub fn disposeBrowserContext(self: *CDP, browser_context_id: []const u8) bool {
const bc = &(self.browser_context orelse return false);
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
return false;
}
bc.deinit();
self.browser.closeSession();
self.browser_context = null;
return true;
}
const SendEventOpts = struct {
session_id: ?[]const u8 = null,
};
pub fn sendEvent(self: *CDP, method: []const u8, p: anytype, opts: SendEventOpts) !void {
return self.sendJSON(.{
.method = method,
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
.sessionId = opts.session_id,
});
}
pub fn sendJSON(self: *CDP, message: anytype) !void {
return self.client.sendJSON(message, .{
.emit_null_optional_fields = false,
});
}
pub fn BrowserContext(comptime CDP_T: type) type { pub fn BrowserContext(comptime CDP_T: type) type {
const Node = @import("Node.zig"); const Node = @import("Node.zig");
const AXNode = @import("AXNode.zig"); const AXNode = @import("AXNode.zig");
@@ -958,7 +955,7 @@ fn asUint(comptime T: type, comptime string: []const u8) T {
const testing = @import("testing.zig"); const testing = @import("testing.zig");
test "cdp: invalid json" { test "cdp: invalid json" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid")); try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
@@ -983,7 +980,7 @@ test "cdp: invalid json" {
} }
test "cdp: invalid sessionId" { test "cdp: invalid sessionId" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -1008,7 +1005,7 @@ test "cdp: invalid sessionId" {
} }
test "cdp: STARTUP sessionId" { test "cdp: STARTUP sessionId" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -1021,13 +1018,13 @@ test "cdp: STARTUP sessionId" {
// we have a brower context but no session_id // we have a brower context but no session_id
_ = try ctx.loadBrowserContext(.{}); _ = try ctx.loadBrowserContext(.{});
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" }); try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" }); try ctx.expectSentResult(null, .{ .id = 3, .index = 1, .session_id = "STARTUP" });
} }
{ {
// we have a brower context with a different session_id // we have a brower context with a different session_id
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" }); _ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" }); try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" }); try ctx.expectSentResult(null, .{ .id = 4, .index = 2, .session_id = "STARTUP" });
} }
} }

View File

@@ -112,7 +112,7 @@ fn resetPermissions(cmd: anytype) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.browser: getVersion" { test "cdp.browser: getVersion" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
try ctx.processMessage(.{ try ctx.processMessage(.{
@@ -131,7 +131,7 @@ test "cdp.browser: getVersion" {
} }
test "cdp.browser: getWindowForTarget" { test "cdp.browser: getWindowForTarget" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
try ctx.processMessage(.{ try ctx.processMessage(.{

View File

@@ -547,7 +547,7 @@ fn requestNode(cmd: anytype) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.dom: getSearchResults unknown search id" { test "cdp.dom: getSearchResults unknown search id" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
try ctx.processMessage(.{ try ctx.processMessage(.{
@@ -559,7 +559,7 @@ test "cdp.dom: getSearchResults unknown search id" {
} }
test "cdp.dom: search flow" { test "cdp.dom: search flow" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
@@ -614,7 +614,7 @@ test "cdp.dom: search flow" {
} }
test "cdp.dom: querySelector unknown search id" { test "cdp.dom: querySelector unknown search id" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
@@ -635,7 +635,7 @@ test "cdp.dom: querySelector unknown search id" {
} }
test "cdp.dom: querySelector Node not found" { test "cdp.dom: querySelector Node not found" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom1.html" });
@@ -663,7 +663,7 @@ test "cdp.dom: querySelector Node not found" {
} }
test "cdp.dom: querySelector Nodes found" { test "cdp.dom: querySelector Nodes found" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" });
@@ -693,7 +693,7 @@ test "cdp.dom: querySelector Nodes found" {
} }
test "cdp.dom: getBoxModel" { test "cdp.dom: getBoxModel" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-A", .url = "cdp/dom2.html" });

View File

@@ -279,7 +279,7 @@ fn waitForSelector(cmd: anytype) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" { test "cdp.lp: getMarkdown" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{}); const bc = try ctx.loadBrowserContext(.{});
@@ -290,12 +290,12 @@ test "cdp.lp: getMarkdown" {
.method = "LP.getMarkdown", .method = "LP.getMarkdown",
}); });
const result = ctx.client.?.sent.items[0].object.get("result").?.object; const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("markdown") != null); try testing.expect(result.get("markdown") != null);
} }
test "cdp.lp: getInteractiveElements" { test "cdp.lp: getInteractiveElements" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{}); const bc = try ctx.loadBrowserContext(.{});
@@ -306,13 +306,13 @@ test "cdp.lp: getInteractiveElements" {
.method = "LP.getInteractiveElements", .method = "LP.getInteractiveElements",
}); });
const result = ctx.client.?.sent.items[0].object.get("result").?.object; const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("elements") != null); try testing.expect(result.get("elements") != null);
try testing.expect(result.get("nodeIds") != null); try testing.expect(result.get("nodeIds") != null);
} }
test "cdp.lp: getStructuredData" { test "cdp.lp: getStructuredData" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{}); const bc = try ctx.loadBrowserContext(.{});
@@ -323,12 +323,12 @@ test "cdp.lp: getStructuredData" {
.method = "LP.getStructuredData", .method = "LP.getStructuredData",
}); });
const result = ctx.client.?.sent.items[0].object.get("result").?.object; const result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("structuredData") != null); try testing.expect(result.get("structuredData") != null);
} }
test "cdp.lp: action tools" { test "cdp.lp: action tools" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{}); const bc = try ctx.loadBrowserContext(.{});
@@ -389,7 +389,7 @@ test "cdp.lp: action tools" {
} }
test "cdp.lp: waitForSelector" { test "cdp.lp: waitForSelector" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{}); const bc = try ctx.loadBrowserContext(.{});
@@ -405,9 +405,8 @@ test "cdp.lp: waitForSelector" {
.method = "LP.waitForSelector", .method = "LP.waitForSelector",
.params = .{ .selector = "#existing", .timeout = 2000 }, .params = .{ .selector = "#existing", .timeout = 2000 },
}); });
var result = ctx.client.?.sent.items[0].object.get("result").?.object; var result = (try ctx.getSentMessage(0)).?.object.get("result").?.object;
try testing.expect(result.get("backendNodeId") != null); try testing.expect(result.get("backendNodeId") != null);
ctx.client.?.sent.clearRetainingCapacity();
// 2. Delayed element // 2. Delayed element
try ctx.processMessage(.{ try ctx.processMessage(.{
@@ -415,9 +414,8 @@ test "cdp.lp: waitForSelector" {
.method = "LP.waitForSelector", .method = "LP.waitForSelector",
.params = .{ .selector = "#delayed", .timeout = 5000 }, .params = .{ .selector = "#delayed", .timeout = 5000 },
}); });
result = ctx.client.?.sent.items[0].object.get("result").?.object; result = (try ctx.getSentMessage(1)).?.object.get("result").?.object;
try testing.expect(result.get("backendNodeId") != null); try testing.expect(result.get("backendNodeId") != null);
ctx.client.?.sent.clearRetainingCapacity();
// 3. Timeout error // 3. Timeout error
try ctx.processMessage(.{ try ctx.processMessage(.{
@@ -425,6 +423,6 @@ test "cdp.lp: waitForSelector" {
.method = "LP.waitForSelector", .method = "LP.waitForSelector",
.params = .{ .selector = "#nonexistent", .timeout = 100 }, .params = .{ .selector = "#nonexistent", .timeout = 100 },
}); });
const err_obj = ctx.client.?.sent.items[0].object.get("error").?.object; const err_obj = (try ctx.getSentMessage(2)).?.object.get("error").?.object;
try testing.expect(err_obj.get("code") != null); try testing.expect(err_obj.get("code") != null);
} }

View File

@@ -439,7 +439,7 @@ fn idFromRequestId(request_id: []const u8) !u64 {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.network setExtraHTTPHeaders" { test "cdp.network setExtraHTTPHeaders" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "NID-A", .session_id = "NESI-A" }); _ = try ctx.loadBrowserContext(.{ .id = "NID-A", .session_id = "NESI-A" });
@@ -465,7 +465,7 @@ test "cdp.Network: cookies" {
const ResCookie = CdpStorage.ResCookie; const ResCookie = CdpStorage.ResCookie;
const CdpCookie = CdpStorage.CdpCookie; const CdpCookie = CdpStorage.CdpCookie;
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-S" });

View File

@@ -39,6 +39,7 @@ pub fn processMessage(cmd: anytype) !void {
addScriptToEvaluateOnNewDocument, addScriptToEvaluateOnNewDocument,
createIsolatedWorld, createIsolatedWorld,
navigate, navigate,
reload,
stopLoading, stopLoading,
close, close,
captureScreenshot, captureScreenshot,
@@ -52,6 +53,7 @@ pub fn processMessage(cmd: anytype) !void {
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd), .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
.createIsolatedWorld => return createIsolatedWorld(cmd), .createIsolatedWorld => return createIsolatedWorld(cmd),
.navigate => return navigate(cmd), .navigate => return navigate(cmd),
.reload => return doReload(cmd),
.stopLoading => return cmd.sendResult(null, .{}), .stopLoading => return cmd.sendResult(null, .{}),
.close => return close(cmd), .close => return close(cmd),
.captureScreenshot => return captureScreenshot(cmd), .captureScreenshot => return captureScreenshot(cmd),
@@ -82,7 +84,7 @@ fn getFrameTree(cmd: anytype) !void {
.frame = .{ .frame = .{
.id = "TID-STARTUP", .id = "TID-STARTUP",
.loaderId = "LID-STARTUP", .loaderId = "LID-STARTUP",
.securityOrigin = @import("../cdp.zig").URL_BASE, .securityOrigin = @import("../CDP.zig").URL_BASE,
.url = "about:blank", .url = "about:blank",
.secureContextType = "Secure", .secureContextType = "Secure",
}, },
@@ -252,6 +254,36 @@ fn navigate(cmd: anytype) !void {
}); });
} }
fn doReload(cmd: anytype) !void {
const params = try cmd.params(struct {
ignoreCache: ?bool = null,
scriptToEvaluateOnLoad: ?[]const u8 = null,
});
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
if (bc.session_id == null) {
return error.SessionIdNotLoaded;
}
const session = bc.session;
var page = session.currentPage() orelse return error.PageNotLoaded;
// Dupe URL before replacePage() frees the old page's arena.
const reload_url = try cmd.arena.dupeZ(u8, page.url);
if (page._load_state != .waiting) {
page = try session.replacePage();
}
try page.navigate(reload_url, .{
.reason = .address_bar,
.cdp_id = cmd.input.id,
.kind = .reload,
.force = if (params) |p| p.ignoreCache orelse false else false,
});
}
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void { pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
// detachTarget could be called, in which case, we still have a page doing // detachTarget could be called, in which case, we still have a page doing
// things, but no session. // things, but no session.
@@ -642,7 +674,7 @@ fn getLayoutMetrics(cmd: anytype) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.page: getFrameTree" { test "cdp.page: getFrameTree" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -712,7 +744,7 @@ test "cdp.page: captureScreenshot" {
const filter: LogFilter = .init(&.{.not_implemented}); const filter: LogFilter = .init(&.{.not_implemented});
defer filter.deinit(); defer filter.deinit();
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } }); try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } });
@@ -728,7 +760,7 @@ test "cdp.page: captureScreenshot" {
} }
test "cdp.page: getLayoutMetrics" { test "cdp.page: getLayoutMetrics" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* }); _ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
@@ -784,3 +816,27 @@ test "cdp.page: getLayoutMetrics" {
}, },
}, .{ .id = 12 }); }, .{ .id = 12 });
} }
test "cdp.page: reload" {
var ctx = try testing.context();
defer ctx.deinit();
{
// reload without browser context — should error
try ctx.processMessage(.{ .id = 30, .method = "Page.reload" });
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 });
}
_ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
{
// reload with no params — should not error (navigation is async,
// so no result is sent synchronously; we just verify no error)
try ctx.processMessage(.{ .id = 31, .method = "Page.reload" });
}
{
// reload with ignoreCache param
try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
}
}

View File

@@ -44,7 +44,7 @@ fn setIgnoreCertificateErrors(cmd: anytype) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.Security: setIgnoreCertificateErrors" { test "cdp.Security: setIgnoreCertificateErrors" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-9" });

View File

@@ -243,7 +243,7 @@ pub fn writeCookie(cookie: *const Cookie, w: anytype) !void {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.Storage: cookies" { test "cdp.Storage: cookies" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-S" });

View File

@@ -512,7 +512,7 @@ const TargetInfo = struct {
const testing = @import("../testing.zig"); const testing = @import("../testing.zig");
test "cdp.target: getBrowserContexts" { test "cdp.target: getBrowserContexts" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
// { // {
@@ -536,7 +536,7 @@ test "cdp.target: getBrowserContexts" {
} }
test "cdp.target: createBrowserContext" { test "cdp.target: createBrowserContext" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -554,7 +554,7 @@ test "cdp.target: createBrowserContext" {
} }
test "cdp.target: disposeBrowserContext" { test "cdp.target: disposeBrowserContext" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -585,7 +585,7 @@ test "cdp.target: disposeBrowserContext" {
test "cdp.target: createTarget" { test "cdp.target: createTarget" {
{ {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about:blank" } }); try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about:blank" } });
@@ -595,7 +595,7 @@ test "cdp.target: createTarget" {
} }
{ {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
// active auto attach to get the Target.attachedToTarget event. // active auto attach to get the Target.attachedToTarget event.
try ctx.processMessage(.{ .id = 9, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } }); try ctx.processMessage(.{ .id = 9, .method = "Target.setAutoAttach", .params = .{ .autoAttach = true, .waitForDebuggerOnStart = false } });
@@ -607,7 +607,7 @@ test "cdp.target: createTarget" {
try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{}); try ctx.expectSentEvent("Target.attachedToTarget", .{ .sessionId = bc.session_id.?, .targetInfo = .{ .url = "about:blank", .title = "", .attached = true, .type = "page", .canAccessOpener = false, .browserContextId = bc.id, .targetId = bc.target_id.? } }, .{});
} }
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{ {
@@ -624,7 +624,7 @@ test "cdp.target: createTarget" {
} }
test "cdp.target: closeTarget" { test "cdp.target: closeTarget" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -655,7 +655,7 @@ test "cdp.target: closeTarget" {
} }
test "cdp.target: attachToTarget" { test "cdp.target: attachToTarget" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -686,7 +686,7 @@ test "cdp.target: attachToTarget" {
} }
test "cdp.target: getTargetInfo" { test "cdp.target: getTargetInfo" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
{ {
@@ -737,7 +737,7 @@ test "cdp.target: getTargetInfo" {
} }
test "cdp.target: issue#474: attach to just created target" { test "cdp.target: issue#474: attach to just created target" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{ {
@@ -752,7 +752,7 @@ test "cdp.target: issue#474: attach to just created target" {
} }
test "cdp.target: detachFromTarget" { test "cdp.target: detachFromTarget" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{ {
@@ -775,19 +775,19 @@ test "cdp.target: detachFromTarget" {
} }
test "cdp.target: detachFromTarget without session" { test "cdp.target: detachFromTarget without session" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" }); _ = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{ {
// detach when no session is attached should not send event // detach when no session is attached should not send event
try ctx.processMessage(.{ .id = 10, .method = "Target.detachFromTarget" }); try ctx.processMessage(.{ .id = 10, .method = "Target.detachFromTarget" });
try ctx.expectSentResult(null, .{ .id = 10 }); try ctx.expectSentResult(null, .{ .id = 10 });
try ctx.expectSentCount(0); try ctx.expectSentCount(1);
} }
} }
test "cdp.target: setAutoAttach false sends detachedFromTarget" { test "cdp.target: setAutoAttach false sends detachedFromTarget" {
var ctx = testing.context(); var ctx = try testing.context();
defer ctx.deinit(); defer ctx.deinit();
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" }); const bc = try ctx.loadBrowserContext(.{ .id = "BID-9" });
{ {

View File

@@ -18,12 +18,14 @@
const std = @import("std"); const std = @import("std");
const json = std.json; const json = std.json;
const posix = std.posix;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator; const ArenaAllocator = std.heap.ArenaAllocator;
const Testing = @This(); const Testing = @This();
const main = @import("cdp.zig"); const CDP = @import("CDP.zig");
const Server = @import("../Server.zig");
const base = @import("../testing.zig"); const base = @import("../testing.zig");
pub const allocator = base.allocator; pub const allocator = base.allocator;
@@ -35,61 +37,27 @@ pub const expectEqualSlices = base.expectEqualSlices;
pub const pageTest = base.pageTest; pub const pageTest = base.pageTest;
pub const newString = base.newString; pub const newString = base.newString;
const Client = struct {
allocator: Allocator,
send_arena: ArenaAllocator,
sent: std.ArrayList(json.Value) = .{},
serialized: std.ArrayList([]const u8) = .{},
fn init(alloc: Allocator) Client {
return .{
.allocator = alloc,
.send_arena = ArenaAllocator.init(alloc),
};
}
pub fn sendAllocator(self: *Client) Allocator {
return self.send_arena.allocator();
}
pub fn sendJSON(self: *Client, message: anytype, opts: json.Stringify.Options) !void {
var opts_copy = opts;
opts_copy.whitespace = .indent_2;
const serialized = try json.Stringify.valueAlloc(self.allocator, message, opts_copy);
try self.serialized.append(self.allocator, serialized);
const value = try json.parseFromSliceLeaky(json.Value, self.allocator, serialized, .{});
try self.sent.append(self.allocator, value);
}
pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
const value = try json.parseFromSliceLeaky(json.Value, self.allocator, buf.items, .{});
try self.sent.append(self.allocator, value);
}
};
const TestCDP = main.CDPT(struct {
pub const Client = *Testing.Client;
});
const TestContext = struct { const TestContext = struct {
client: ?Client = null, read_at: usize = 0,
cdp_: ?TestCDP = null, read_buf: [1024 * 32]u8 = undefined,
arena: ArenaAllocator, cdp_: ?CDP = null,
client: Server.Client,
socket: posix.socket_t,
received: std.ArrayList(json.Value) = .empty,
received_raw: std.ArrayList([]const u8) = .empty,
pub fn deinit(self: *TestContext) void { pub fn deinit(self: *TestContext) void {
if (self.cdp_) |*c| { if (self.cdp_) |*c| {
c.deinit(); c.deinit();
} }
self.arena.deinit(); self.client.deinit();
posix.close(self.socket);
base.reset();
} }
pub fn cdp(self: *TestContext) *TestCDP { pub fn cdp(self: *TestContext) *CDP {
if (self.cdp_ == null) { if (self.cdp_ == null) {
self.client = Client.init(self.arena.allocator()); self.cdp_ = CDP.init(&self.client) catch |err| @panic(@errorName(err));
// Don't use the arena here. We want to detect leaks in CDP.
// The arena is only for test-specific stuff
self.cdp_ = TestCDP.init(base.test_app, base.test_http, &self.client.?) catch unreachable;
} }
return &self.cdp_.?; return &self.cdp_.?;
} }
@@ -100,7 +68,7 @@ const TestContext = struct {
session_id: ?[]const u8 = null, session_id: ?[]const u8 = null,
url: ?[:0]const u8 = null, url: ?[:0]const u8 = null,
}; };
pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*main.BrowserContext(TestCDP) { pub fn loadBrowserContext(self: *TestContext, opts: BrowserContextOpts) !*CDP.BrowserContext(CDP) {
var c = self.cdp(); var c = self.cdp();
if (c.browser_context) |bc| { if (c.browser_context) |bc| {
_ = c.disposeBrowserContext(bc.id); _ = c.disposeBrowserContext(bc.id);
@@ -130,7 +98,7 @@ const TestContext = struct {
} }
const page = try bc.session.createPage(); const page = try bc.session.createPage();
const full_url = try std.fmt.allocPrintSentinel( const full_url = try std.fmt.allocPrintSentinel(
self.arena.allocator(), base.arena_allocator,
"http://127.0.0.1:9582/src/browser/tests/{s}", "http://127.0.0.1:9582/src/browser/tests/{s}",
.{url}, .{url},
0, 0,
@@ -143,19 +111,20 @@ const TestContext = struct {
} }
pub fn processMessage(self: *TestContext, msg: anytype) !void { pub fn processMessage(self: *TestContext, msg: anytype) !void {
var json_message: []const u8 = undefined; const json_message: []const u8 = blk: {
if (@typeInfo(@TypeOf(msg)) != .pointer) { if (@typeInfo(@TypeOf(msg)) != .pointer) {
json_message = try std.json.Stringify.valueAlloc(self.arena.allocator(), msg, .{}); break :blk try std.json.Stringify.valueAlloc(base.arena_allocator, msg, .{});
} else { }
// assume this is a string we want to send as-is, if it isn't, we'll // assume this is a string we want to send as-is, if it isn't, we'll
// get a compile error, so no big deal. // get a compile error, so no big deal.
json_message = msg; break :blk msg;
} };
return self.cdp().processMessage(json_message); return self.cdp().processMessage(json_message);
} }
pub fn expectSentCount(self: *TestContext, expected: usize) !void { pub fn expectSentCount(self: *TestContext, expected: usize) !void {
try expectEqual(expected, self.client.?.sent.items.len); try self.read();
try expectEqual(expected, self.received.items.len);
} }
const ExpectResultOpts = struct { const ExpectResultOpts = struct {
@@ -203,37 +172,135 @@ const TestContext = struct {
index: ?usize = null, index: ?usize = null,
}; };
pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void { pub fn expectSent(self: *TestContext, expected: anytype, opts: SentOpts) !void {
const serialized = try json.Stringify.valueAlloc(self.arena.allocator(), expected, .{ const serialized = try json.Stringify.valueAlloc(base.arena_allocator, expected, .{
.whitespace = .indent_2, .whitespace = .indent_2,
.emit_null_optional_fields = false, .emit_null_optional_fields = false,
}); });
for (0..5) |_| {
for (self.client.?.sent.items, 0..) |sent, i| { for (self.received.items, 0..) |received, i| {
if (try compareExpectedToSent(serialized, sent) == false) { if (try compareExpectedToSent(serialized, received) == false) {
continue; continue;
}
if (opts.index) |expected_index| {
if (expected_index != i) {
return error.ErrorAtWrongIndex;
} }
}
_ = self.client.?.sent.orderedRemove(i);
_ = self.client.?.serialized.orderedRemove(i);
return;
}
std.debug.print("Error not found. Expecting:\n{s}\n\nGot:\n", .{serialized}); if (opts.index) |expected_index| {
for (self.client.?.serialized.items, 0..) |sent, i| { if (expected_index != i) {
std.debug.print("#{d}\n{s}\n\n", .{ i, sent }); std.debug.print("Expected message at index: {d}, was at index: {d}\n", .{ expected_index, i });
self.dumpReceived();
return error.ErrorAtWrongIndex;
}
}
return;
}
std.Thread.sleep(5 * std.time.ns_per_ms);
try self.read();
} }
self.dumpReceived();
return error.ErrorNotFound; return error.ErrorNotFound;
} }
fn dumpReceived(self: *const TestContext) void {
std.debug.print("CDP Message Received ({d})\n", .{self.received_raw.items.len});
for (self.received_raw.items, 0..) |received, i| {
std.debug.print("===Message: {d}===\n{s}\n\n", .{ i, received });
}
}
pub fn getSentMessage(self: *TestContext, index: usize) !?json.Value {
for (0..5) |_| {
if (index < self.received.items.len) {
return self.received.items[index];
}
std.Thread.sleep(5 * std.time.ns_per_ms);
try self.read();
}
return null;
}
fn read(self: *TestContext) !void {
while (true) {
const n = posix.read(self.socket, self.read_buf[self.read_at..]) catch |err| switch (err) {
error.WouldBlock => return,
else => return err,
};
if (n == 0) {
return;
}
self.read_at += n;
// Try to parse complete WebSocket frames
var pos: usize = 0;
while (pos < self.read_at) {
// Need at least 2 bytes for header
if (self.read_at - pos < 2) break;
const opcode = self.read_buf[pos] & 0x0F;
const payload_len_byte = self.read_buf[pos + 1] & 0x7F;
var header_size: usize = 2;
var payload_len: usize = payload_len_byte;
if (payload_len_byte == 126) {
if (self.read_at - pos < 4) break;
payload_len = std.mem.readInt(u16, self.read_buf[pos + 2 ..][0..2], .big);
header_size = 4;
}
// Skip 8-byte length case (127) - not needed
const frame_size = header_size + payload_len;
if (self.read_at - pos < frame_size) break;
// We have a complete frame - process text (1) or binary (2), skip others
if (opcode == 1 or opcode == 2) {
const payload = self.read_buf[pos + header_size ..][0..payload_len];
const parsed = try std.json.parseFromSliceLeaky(json.Value, base.arena_allocator, payload, .{});
try self.received.append(base.arena_allocator, parsed);
try self.received_raw.append(base.arena_allocator, try base.arena_allocator.dupe(u8, payload));
}
pos += frame_size;
}
// Move remaining partial data to beginning of buffer
if (pos > 0 and pos < self.read_at) {
std.mem.copyForwards(u8, &self.read_buf, self.read_buf[pos..self.read_at]);
self.read_at -= pos;
} else if (pos == self.read_at) {
self.read_at = 0;
}
}
}
}; };
pub fn context() TestContext { pub fn context() !TestContext {
var pair: [2]posix.socket_t = undefined;
const rc = std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair);
if (rc != 0) {
return error.SocketPairFailed;
}
errdefer {
posix.close(pair[0]);
posix.close(pair[1]);
}
const timeout = std.mem.toBytes(posix.timeval{ .sec = 0, .usec = 5_000 });
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768)));
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768)));
const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version", 2000);
return .{ return .{
.arena = ArenaAllocator.init(std.testing.allocator), .client = client,
.socket = pair[0],
}; };
} }

View File

@@ -324,7 +324,9 @@ pub const WsConnection = struct {
pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection { pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection {
const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0); const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0);
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{}); if (builtin.is_test == false) {
assert(socket_flags & nonblocking == nonblocking, "WsConnection.init blocking", .{});
}
var reader = try Reader(true).init(allocator); var reader = try Reader(true).init(allocator);
errdefer reader.deinit(); errdefer reader.deinit();

View File

@@ -410,21 +410,46 @@ fn runWebApiTest(test_file: [:0]const u8) !void {
page.js.localScope(&ls); page.js.localScope(&ls);
defer ls.deinit(); defer ls.deinit();
var try_catch: js.TryCatch = undefined; {
try_catch.init(&ls.local); var try_catch: js.TryCatch = undefined;
defer try_catch.deinit(); try_catch.init(&ls.local);
defer try_catch.deinit();
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 }); try runner.wait(.{ .ms = 2000 });
test_browser.runMicrotasks(); var wait_ms: u32 = 2000;
var timer = try std.time.Timer.start();
while (true) {
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
ls.local.eval("testing.assertOk()", "testing.assertOk()") catch |err| { const js_val = ls.local.exec("testing.assertOk()", "testing.assertOk()") catch |err| {
const caught = try_catch.caughtOrError(arena_allocator, err); const caught = try_catch.caughtOrError(arena_allocator, err);
std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught }); std.debug.print("{s}: test failure\nError: {f}\n", .{ test_file, caught });
return err; return err;
}; };
if (js_val.isTrue()) {
return;
}
switch (try runner.tick(.{ .ms = 20 })) {
.done => return error.TestNeverSignaledCompletion,
.ok => |next_ms| {
const ms_elapsed = timer.lap() / 1_000_000;
if (ms_elapsed >= wait_ms) {
return error.TestTimedOut;
}
wait_ms -= @intCast(ms_elapsed);
if (next_ms > 0) {
std.Thread.sleep(std.time.ns_per_ms * next_ms);
}
},
}
}
} }
// Used by a few CDP tests - wouldn't be sad to see this go. // Used by a few CDP tests - wouldn't be sad to see this go.
@@ -445,10 +470,6 @@ pub fn pageTest(comptime test_file: []const u8) !*Page {
return page; return page;
} }
test {
std.testing.refAllDecls(@This());
}
const log = @import("log.zig"); const log = @import("log.zig");
const TestHTTPServer = @import("TestHTTPServer.zig"); const TestHTTPServer = @import("TestHTTPServer.zig");