51 Commits

Author SHA1 Message Date
Muki Kiboigo
55e9d8d166 use toEventTarget in NavigationEventTarget 2025-10-21 19:28:18 -07:00
Muki Kiboigo
d7d2f84794 not implemented on Navigation traverseTo 2025-10-21 19:26:41 -07:00
Muki Kiboigo
b40e7ece91 no nullable url on Navigation pushEntry 2025-10-21 19:25:24 -07:00
Muki Kiboigo
9c4367b26e check query on eqlDocument 2025-10-21 19:23:19 -07:00
Muki Kiboigo
0eb639ac76 fix navigation shortcut URL stitching 2025-10-21 18:31:41 -07:00
Muki Kiboigo
9778eed1ed clean up Navigation test names 2025-10-21 18:31:41 -07:00
Muki Kiboigo
8b4ffeb911 fix NavigationCurrentEntryChange Constructor 2025-10-21 18:31:41 -07:00
Muki Kiboigo
b55b9bba0a functional NavigationCurrentEntryChangeEvent 2025-10-21 18:31:39 -07:00
Muki Kiboigo
82a45253de add direct event handlers 2025-10-21 18:31:09 -07:00
Muki Kiboigo
4c957041e2 add tests for eqlDocument 2025-10-21 18:31:08 -07:00
Muki Kiboigo
b8f9598de3 add NavigationCurrentEntryChangeEvent 2025-10-21 18:31:07 -07:00
Muki Kiboigo
907bd33d87 split NavigationType and NavigationKind 2025-10-21 18:29:28 -07:00
Muki Kiboigo
e9b08f19cf fix navigation and related tests 2025-10-21 18:29:28 -07:00
Muki Kiboigo
f97697535f History as compat layer over Navigation 2025-10-21 18:29:28 -07:00
Muki Kiboigo
e80c8d5bff add functional Navigation 2025-10-21 18:29:28 -07:00
Muki Kiboigo
70a009a52b add eqlDocument comparison 2025-10-21 18:29:28 -07:00
Muki Kiboigo
8ab9364f19 add ENUM_JS_USE_TAG for enums 2025-10-21 18:29:27 -07:00
Muki Kiboigo
186655e614 initial Navigation scaffolding 2025-10-21 18:29:27 -07:00
Pierre Tachoire
fb6fbffe3f Merge pull request #1169 from lightpanda-io/cdp-security-ignore-cert-err
Some checks failed
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
cdp: implement Security.setIgnoreCertificateErrors
2025-10-21 15:15:51 +02:00
Pierre Tachoire
510c61cc20 cdp: add test for setIgnoreCertificateErrors 2025-10-21 14:08:26 +02:00
Pierre Tachoire
6915738e02 cdp: ensure no inflight conns is running before set TLS verify 2025-10-21 14:07:59 +02:00
Pierre Tachoire
4f62cc833b http: fix VERIFY_HOST value 2025-10-21 13:47:09 +02:00
Karl Seguin
46ffb801db Merge pull request #1168 from lightpanda-io/dom_range_fixes
Reverses 2 incorrect comparions
2025-10-21 19:45:32 +08:00
Pierre Tachoire
d2065f713f cdp: implement Security.setIgnoreCertificateErrors 2025-10-21 13:44:29 +02:00
Karl Seguin
6f8c3abb55 Merge pull request #1167 from lightpanda-io/typos
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
correct typos (all in comments)
2025-10-21 18:09:27 +08:00
Karl Seguin
163a0e8b70 Reverses 2 incorrect comparions
A bit obvious when you see the "expected -1 but got 1".

Goal is to bring us over 40K passing :)
2025-10-21 18:08:05 +08:00
Karl Seguin
ca3efb3ad9 correct typos (all in comments) 2025-10-21 16:17:38 +08:00
Karl Seguin
4468932346 Merge pull request #1166 from gootik/patch-1
Fix typo in 'input' selector check
2025-10-21 16:04:03 +08:00
Sasan Hezarkhani
9a03ba61c5 Fix typo in 'input' selector check
Fix a small typo in selector check
2025-10-20 21:30:35 -07:00
Karl Seguin
fe3777041d Merge pull request #1164 from lightpanda-io/nix_0.15.2
Update `flake.lock` for Zig 0.15.2
2025-10-21 08:18:46 +08:00
Muki Kiboigo
1c579a98b4 update flake.lock 2025-10-20 07:20:46 -07:00
Karl Seguin
3e10cf0a64 Merge pull request #1163 from lightpanda-io/zig_0_15_2
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
Upgrade to Zig 0.15.2 - no code changes
2025-10-20 17:32:59 +08:00
Karl Seguin
ef9784a7d4 Upgrade to Zig 0.15.2 - no code changes 2025-10-20 16:44:45 +08:00
Karl Seguin
6f1c3c8fd2 Merge pull request #1162 from lightpanda-io/fix_node_iterator_regression
Fixes the regression to node iterator
2025-10-20 16:44:11 +08:00
Karl Seguin
e12c650ea5 Fixes the regression to node iterator
Caused by: https://github.com/lightpanda-io/browser/pull/1149/

WPT go from 727/766 (the pre-regression value) to 744/766.
2025-10-20 16:28:07 +08:00
Karl Seguin
9373cbb440 Merge pull request #1159 from lightpanda-io/make_test_filter_compiler
Filter out the huge compile command when using `make test`
2025-10-20 15:36:16 +08:00
Pierre Tachoire
fd6d038956 Merge pull request #1152 from lightpanda-io/cdp-inserttext
Some checks failed
e2e-test / zig build release (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
cdp: add input.insertText
2025-10-19 18:16:00 +02:00
Karl Seguin
9845392b71 Simplify filter and try to make it work with progressive build info 2025-10-18 11:18:50 +08:00
Karl Seguin
0795b7a583 Filter out the huge compile command when using make test
I couldn't figure out how (or if it's possible) to do this with build.zig
2025-10-18 08:14:07 +08:00
Karl Seguin
29f0e71f10 Merge pull request #1158 from lightpanda-io/concurrent-waitformodule
Some checks failed
nightly build / build-linux-x86_64 (push) Has been cancelled
nightly build / build-linux-aarch64 (push) Has been cancelled
nightly build / build-macos-aarch64 (push) Has been cancelled
nightly build / build-macos-x86_64 (push) Has been cancelled
wpt / web platform tests json output (push) Has been cancelled
wpt / perf-fmt (push) Has been cancelled
e2e-test / zig build release (push) Has been cancelled
zig-test / zig build dev (push) Has been cancelled
zig-test / zig test (push) Has been cancelled
e2e-test / demo-scripts (push) Has been cancelled
e2e-test / cdp-and-hyperfine-bench (push) Has been cancelled
e2e-test / perf-fmt (push) Has been cancelled
zig-test / browser fetch (push) Has been cancelled
zig-test / perf-fmt (push) Has been cancelled
handle multiple waiters for the same module
2025-10-18 07:46:30 +08:00
Karl Seguin
1a47f7b5a8 Merge pull request #1157 from lightpanda-io/rootNode_composed
support the composed option of getRootNode()
2025-10-18 07:44:38 +08:00
Karl Seguin
6a30ab7a57 Merge pull request #1156 from lightpanda-io/report_error
add window.reportError
2025-10-18 07:44:24 +08:00
Karl Seguin
758f7deb93 Merge pull request #1155 from lightpanda-io/composition_event
add CompositionEvent
2025-10-18 07:44:13 +08:00
Pierre Tachoire
9f4e3bf792 add a shared boolean to GetResult to avoid deinit 2025-10-17 18:02:21 +02:00
Pierre Tachoire
a5dfe8ab28 handle multiple waiters for the same module 2025-10-17 17:49:56 +02:00
Karl Seguin
288379aa7d support the composed option of getRootNode() 2025-10-16 19:08:33 +08:00
Karl Seguin
a9739bf361 add window.reportError 2025-10-16 18:33:18 +08:00
Karl Seguin
c69adcb163 add CompositionEvent 2025-10-16 15:57:37 +08:00
Pierre Tachoire
14a23123c0 add Document.hasFocus placeholder 2025-10-15 15:34:06 +02:00
Pierre Tachoire
09be5e23f1 add input.select placeholder 2025-10-15 15:32:27 +02:00
Pierre Tachoire
0aaed08c1e cdp: add input.insertText 2025-10-15 13:52:21 +02:00
54 changed files with 1301 additions and 214 deletions

View File

@@ -5,7 +5,7 @@ inputs:
zig:
description: 'Zig version to install'
required: false
default: '0.15.1'
default: '0.15.2'
arch:
description: 'CPU arch used to select the v8 lib'
required: false

View File

@@ -1,7 +1,7 @@
name: zig-fmt
env:
ZIG_VERSION: 0.15.1
ZIG_VERSION: 0.15.2
on:
pull_request:

View File

@@ -1,7 +1,7 @@
FROM debian:stable
ARG MINISIG=0.12
ARG ZIG=0.15.1
ARG ZIG=0.15.2
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.1.33

View File

@@ -96,9 +96,16 @@ wpt-summary:
@printf "\e[36mBuilding wpt...\e[0m\n"
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
## Test
## Test - `grep` is used to filter out the huge compile command on build
ifeq ($(OS), macos)
test:
@TEST_FILTER='${F}' $(ZIG) build test -freference-trace --summary all
@script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace --summary all' 2>&1 \
| grep --line-buffered -v "^/.*zig test -freference-trace"
else
test:
@script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace --summary all' /dev/null 2>&1 \
| grep --line-buffered -v "^/.*zig test -freference-trace"
endif
## Run demo/runner end to end tests
end2end:

View File

@@ -164,7 +164,7 @@ You can also follow the progress of our Javascript support in our dedicated [zig
### Prerequisites
Lightpanda is written with [Zig](https://ziglang.org/) `0.15.1`. You have to
Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
install it with the right version in order to build the project.
Lightpanda also depends on

View File

@@ -23,7 +23,7 @@ const Build = std.Build;
/// Do not rename this constant. It is scanned by some scripts to determine
/// which zig version to install.
const recommended_zig_version = "0.15.1";
const recommended_zig_version = "0.15.2";
pub fn build(b: *Build) !void {
switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) {

12
flake.lock generated
View File

@@ -75,11 +75,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1756822655,
"narHash": "sha256-xQAk8xLy7srAkR5NMZFsQFioL02iTHuuEIs3ohGpgdk=",
"lastModified": 1760968520,
"narHash": "sha256-EjGslHDzCBKOVr+dnDB1CAD7wiQSHfUt3suOpFj9O1Q=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "4bdac60bfe32c41103ae500ddf894c258291dd61",
"rev": "e755547441a0413942a37692f7bf7fc6315bb7f6",
"type": "github"
},
"original": {
@@ -136,11 +136,11 @@
]
},
"locked": {
"lastModified": 1756555914,
"narHash": "sha256-7yoSPIVEuL+3Wzf6e7NHuW3zmruHizRrYhGerjRHTLI=",
"lastModified": 1760747435,
"narHash": "sha256-wNB/W3x+or4mdNxFPNOH5/WFckNpKgFRZk7OnOsLtm0=",
"owner": "mitchellh",
"repo": "zig-overlay",
"rev": "d0df3a2fd0f11134409d6d5ea0e510e5e477f7d6",
"rev": "d0f239b887b1ac736c0f3dde91bf5bf2ecf3a420",
"type": "github"
},
"original": {

View File

@@ -49,7 +49,7 @@
targetPkgs =
pkgs: with pkgs; [
# Build Tools
zigpkgs."0.15.1"
zigpkgs."0.15.2"
zls
python3
pkg-config

View File

@@ -212,7 +212,7 @@ pub fn addFromElement(self: *ScriptManager, element: *parser.Element, comptime c
if (source == .@"inline" and self.scripts.first == null) {
// inline script with no pending scripts, execute it immediately.
// (if there is a pending script, then we cannot execute this immediately
// as it needs to best executed in order)
// as it needs to be executed in order)
return script.eval(page);
}
@@ -326,16 +326,31 @@ pub fn waitForModule(self: *ScriptManager, url: [:0]const u8) !GetResult {
};
const sync = entry.value_ptr.*;
// We can have multiple scripts waiting for the same module in concurrency.
// We use the waiters to ensures only the last waiter deinit the resources.
sync.waiters += 1;
defer sync.waiters -= 1;
var client = self.client;
while (true) {
switch (sync.state) {
.loading => {},
.done => {
// Our caller has its own higher level cache (caching the
// actual compiled module). There's no reason for us to keep this
defer self.sync_module_pool.destroy(sync);
defer self.sync_modules.removeByPtr(entry.key_ptr);
if (sync.waiters == 1) {
// Our caller has its own higher level cache (caching the
// actual compiled module). There's no reason for us to keep
// this if we are the last waiter.
defer self.sync_module_pool.destroy(sync);
defer self.sync_modules.removeByPtr(entry.key_ptr);
return .{
.shared = false,
.buffer = sync.buffer,
.buffer_pool = &self.buffer_pool,
};
}
return .{
.shared = true,
.buffer = sync.buffer,
.buffer_pool = &self.buffer_pool,
};
@@ -882,6 +897,8 @@ const SyncModule = struct {
manager: *ScriptManager,
buffer: std.ArrayListUnmanaged(u8) = .{},
state: State = .loading,
// number of waiters for the module.
waiters: u8 = 0,
const State = union(enum) {
done,
@@ -997,6 +1014,7 @@ pub const AsyncModule = struct {
var self: *AsyncModule = @ptrCast(@alignCast(ctx));
defer self.manager.async_module_pool.destroy(self);
self.cb(self.cb_data, .{
.shared = false,
.buffer = self.buffer,
.buffer_pool = &self.manager.buffer_pool,
});
@@ -1020,8 +1038,13 @@ pub const AsyncModule = struct {
pub const GetResult = struct {
buffer: std.ArrayListUnmanaged(u8),
buffer_pool: *BufferPool,
shared: bool,
pub fn deinit(self: *GetResult) void {
// if the result is shared, don't deinit.
if (self.shared) {
return;
}
self.buffer_pool.release(self.buffer);
}

View File

@@ -562,7 +562,7 @@ pub const Selector = union(enum) {
const ntag = try n.tag();
if (std.ascii.eqlIgnoreCase("intput", ntag)) {
if (std.ascii.eqlIgnoreCase("input", ntag)) {
const ntype = try n.attr("type");
if (ntype == null) return false;

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const js = @import("../js/js.zig");
const parser = @import("../netsurf.zig");
@@ -313,6 +314,11 @@ pub const Document = struct {
const state = try page.getOrCreateNodeState(@ptrCast(@alignCast(self)));
state.adopted_style_sheets = try sheets.persist();
}
pub fn _hasFocus(_: *parser.Document) bool {
log.debug(.web_api, "not implemented", .{ .feature = "Document hasFocus" });
return true;
}
};
const testing = @import("../../testing.zig");

View File

@@ -34,6 +34,7 @@ pub const Union = union(enum) {
screen_orientation: *@import("../html/screen.zig").ScreenOrientation,
performance: *@import("performance.zig").Performance,
media_query_list: *@import("../html/media_query_list.zig").MediaQueryList,
navigation: *@import("../navigation/Navigation.zig"),
};
// EventTarget implementation
@@ -82,6 +83,11 @@ pub const EventTarget = struct {
.media_query_list => {
return .{ .media_query_list = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et))) };
},
.navigation => {
const NavigationEventTarget = @import("../navigation/NavigationEventTarget.zig");
const base: *NavigationEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et)));
return .{ .navigation = @fieldParentPtr("proto", base) };
},
}
}

View File

@@ -286,7 +286,7 @@ const Opts = struct {
// WEB IDL https://dom.spec.whatwg.org/#htmlcollection
// HTMLCollection is re implemented in zig here because libdom
// dom_html_collection expects a comparison function callback as arguement.
// dom_html_collection expects a comparison function callback as argument.
// But we wanted a dynamically comparison here, according to the match tagname.
pub const HTMLCollection = struct {
matcher: Matcher,

View File

@@ -360,18 +360,30 @@ pub const Node = struct {
node: Union,
};
pub fn _getRootNode(self: *parser.Node, options: ?struct { composed: bool = false }, page: *Page) !GetRootNodeResult {
if (options) |options_| if (options_.composed) {
log.warn(.web_api, "not implemented", .{ .feature = "getRootNode composed" });
};
const composed = if (options) |opts| opts.composed else false;
const root = parser.nodeGetRootNode(self);
if (page.getNodeState(root)) |state| {
if (state.shadow_root) |sr| {
return .{ .shadow_root = sr };
var current_root = parser.nodeGetRootNode(self);
while (true) {
const node_type = parser.nodeType(current_root);
if (node_type == .document_fragment) {
if (parser.documentFragmentGetHost(@ptrCast(current_root))) |host| {
if (page.getNodeState(host)) |state| {
if (state.shadow_root) |sr| {
if (!composed) {
return .{ .shadow_root = sr };
}
current_root = parser.nodeGetRootNode(@ptrCast(sr.host));
continue;
}
}
}
}
break;
}
return .{ .node = try Node.toInterface(root) };
return .{ .node = try Node.toInterface(current_root) };
}
pub fn _hasChildNodes(self: *parser.Node) bool {
@@ -461,7 +473,7 @@ pub const Node = struct {
// Check if the hierarchy node tree constraints are respected.
// For now, it checks only if new nodes are not self.
// TODO implements the others contraints.
// TODO implements the others constraints.
// see https://dom.spec.whatwg.org/#concept-node-tree
pub fn hierarchy(self: *parser.Node, nodes: []const NodeOrText) bool {
for (nodes) |n| {

View File

@@ -106,7 +106,6 @@ pub const NodeIterator = struct {
defer self.callbackEnd();
if (self.pointer_before_current) {
self.pointer_before_current = false;
// Unlike TreeWalker, NodeIterator starts at the first node
if (.accept == try NodeFilter.verify(self.what_to_show, self.filter_func, self.reference_node)) {
self.pointer_before_current = false;
@@ -116,6 +115,7 @@ pub const NodeIterator = struct {
if (try self.firstChild(self.reference_node)) |child| {
self.reference_node = child;
self.pointer_before_current = false;
return try Node.toInterface(child);
}

View File

@@ -195,7 +195,7 @@ test "Performance: now" {
}
var after = perf._now();
while (after <= now) { // Loop untill after > now
while (after <= now) { // Loop until after > now
try testing.expectEqual(after, now);
after = perf._now();
}

View File

@@ -92,7 +92,7 @@ pub const Range = struct {
pub fn _setStart(self: *Range, node: *parser.Node, offset_: i32) !void {
try ensureValidOffset(node, offset_);
const offset: u32 = @intCast(offset_);
const position = compare(node, offset, self.proto.start_node, self.proto.start_offset) catch |err| switch (err) {
const position = compare(node, offset, self.proto.end_node, self.proto.end_offset) catch |err| switch (err) {
error.WrongDocument => blk: {
// allow a node with a different root than the current, or
// a disconnected one. Treat it as if it's "after", so that
@@ -103,7 +103,7 @@ pub const Range = struct {
};
if (position == 1) {
// if we're setting the node after the current start, the end must
// if we're setting the node after the current end, the end must
// be set too.
self.proto.end_offset = offset;
self.proto.end_node = node;
@@ -378,7 +378,7 @@ fn compare(node_a: *parser.Node, offset_a: u32, node_b: *parser.Node, offset_b:
const child_parent, const child_index = try getParentAndIndex(child);
std.debug.assert(node_a == child_parent);
return if (child_index < offset_a) -1 else 1;
return if (offset_a <= child_index) -1 else 1;
}
return -1;

View File

@@ -227,7 +227,6 @@ pub const TreeWalker = struct {
continue;
};
if (!result.should_descend) {
// This is an .accept node - return it
self.current_node = result.node;

View File

@@ -0,0 +1,57 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const parser = @import("../netsurf.zig");
// https://developer.mozilla.org/en-US/docs/Web/API/CompositionEvent
pub const CompositionEvent = struct {
data: []const u8,
proto: parser.Event,
pub const union_make_copy = true;
pub const prototype = *parser.Event;
pub const ConstructorOptions = struct {
data: []const u8 = "",
};
pub fn constructor(event_type: []const u8, options_: ?ConstructorOptions) !CompositionEvent {
const options: ConstructorOptions = options_ orelse .{};
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{});
parser.eventSetInternalType(event, .composition_event);
return .{
.proto = event.*,
.data = options.data,
};
}
pub fn get_data(self: *const CompositionEvent) []const u8 {
return self.data;
}
};
const testing = @import("../../testing.zig");
test "Browser: Events.Composition" {
try testing.htmlRunner("events/composition.html");
}

View File

@@ -17,6 +17,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const js = @import("../js/js.zig");
const Allocator = std.mem.Allocator;
const log = @import("../../log.zig");
@@ -37,6 +38,8 @@ const KeyboardEvent = @import("keyboard_event.zig").KeyboardEvent;
const ErrorEvent = @import("../html/error_event.zig").ErrorEvent;
const MessageEvent = @import("../dom/MessageChannel.zig").MessageEvent;
const PopStateEvent = @import("../html/History.zig").PopStateEvent;
const CompositionEvent = @import("composition_event.zig").CompositionEvent;
const NavigationCurrentEntryChangeEvent = @import("../navigation/navigation.zig").NavigationCurrentEntryChangeEvent;
// Event interfaces
pub const Interfaces = .{
@@ -48,6 +51,8 @@ pub const Interfaces = .{
ErrorEvent,
MessageEvent,
PopStateEvent,
CompositionEvent,
NavigationCurrentEntryChangeEvent,
};
pub const Union = generate.Union(Interfaces);
@@ -72,10 +77,14 @@ pub const Event = struct {
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
.mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) },
.error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* },
.error_event => .{ .ErrorEvent = (@as(*ErrorEvent, @fieldParentPtr("proto", evt))).* },
.message_event => .{ .MessageEvent = @as(*MessageEvent, @ptrCast(evt)).* },
.keyboard_event => .{ .KeyboardEvent = @as(*parser.KeyboardEvent, @ptrCast(evt)) },
.pop_state => .{ .PopStateEvent = @as(*PopStateEvent, @ptrCast(evt)).* },
.composition_event => .{ .CompositionEvent = (@as(*CompositionEvent, @fieldParentPtr("proto", evt))).* },
.navigation_current_entry_change_event => .{
.NavigationCurrentEntryChangeEvent = @as(*NavigationCurrentEntryChangeEvent, @ptrCast(evt)).*,
},
};
}
@@ -223,8 +232,6 @@ pub const EventHandler = struct {
node: parser.EventNode,
listener: *parser.EventListener,
const js = @import("../js/js.zig");
pub const Listener = union(enum) {
function: js.Function,
object: js.Object,
@@ -396,6 +403,40 @@ const SignalCallback = struct {
}
};
pub fn DirectEventHandler(
comptime TargetT: type,
target: *TargetT,
event_type: []const u8,
maybe_listener: ?EventHandler.Listener,
cb: *?js.Function,
page_arena: std.mem.Allocator,
) !void {
const event_target = parser.toEventTarget(TargetT, target);
// Check if we have a listener set.
if (cb.*) |callback| {
const listener = try parser.eventTargetHasListener(event_target, event_type, false, callback.id);
std.debug.assert(listener != null);
try parser.eventTargetRemoveEventListener(event_target, event_type, listener.?, false);
}
if (maybe_listener) |listener| {
switch (listener) {
// If an object is given as listener, do nothing.
.object => {},
.function => |callback| {
_ = try EventHandler.register(page_arena, event_target, event_type, listener, null) orelse unreachable;
cb.* = callback;
return;
},
}
}
// Just unset the listener.
cb.* = null;
}
const testing = @import("../../testing.zig");
test "Browser: Event" {
try testing.htmlRunner("events/event.html");

View File

@@ -21,140 +21,79 @@ const log = @import("../../log.zig");
const js = @import("../js/js.zig");
const Page = @import("../page.zig").Page;
const Window = @import("window.zig").Window;
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface
const History = @This();
const HistoryEntry = struct {
url: []const u8,
// This is serialized as JSON because
// History must survive a JsContext.
state: ?[]u8,
};
const ScrollRestorationMode = enum {
pub const ENUM_JS_USE_TAG = true;
auto,
manual,
pub fn fromString(str: []const u8) ?ScrollRestorationMode {
for (std.enums.values(ScrollRestorationMode)) |mode| {
if (std.ascii.eqlIgnoreCase(str, @tagName(mode))) {
return mode;
}
} else {
return null;
}
}
pub fn toString(self: ScrollRestorationMode) []const u8 {
return @tagName(self);
}
};
scroll_restoration: ScrollRestorationMode = .auto,
stack: std.ArrayListUnmanaged(HistoryEntry) = .empty,
current: ?usize = null,
pub fn get_length(self: *History) u32 {
return @intCast(self.stack.items.len);
pub fn get_length(_: *History, page: *Page) u32 {
return @intCast(page.session.navigation.entries.items.len);
}
pub fn get_scrollRestoration(self: *History) ScrollRestorationMode {
return self.scroll_restoration;
}
pub fn set_scrollRestoration(self: *History, mode: []const u8) void {
self.scroll_restoration = ScrollRestorationMode.fromString(mode) orelse self.scroll_restoration;
pub fn set_scrollRestoration(self: *History, mode: ScrollRestorationMode) void {
self.scroll_restoration = mode;
}
pub fn get_state(self: *History, page: *Page) !?js.Value {
if (self.current) |curr| {
const entry = self.stack.items[curr];
if (entry.state) |state| {
const value = try js.Value.fromJson(page.js, state);
return value;
} else {
return null;
}
pub fn get_state(_: *History, page: *Page) !?js.Value {
if (page.session.navigation.currentEntry().state) |state| {
const value = try js.Value.fromJson(page.js, state);
return value;
} else {
return null;
}
}
pub fn pushNavigation(self: *History, _url: []const u8, page: *Page) !void {
pub fn _pushState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void {
const arena = page.session.arena;
const url = try arena.dupe(u8, _url);
const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw);
const entry = HistoryEntry{ .state = null, .url = url };
try self.stack.append(arena, entry);
self.current = self.stack.items.len - 1;
const json = state.toJson(arena) catch return error.DataClone;
_ = try page.session.navigation.pushEntry(url, json, page, true);
}
pub fn dispatchPopStateEvent(state: ?[]const u8, page: *Page) void {
log.debug(.script_event, "dispatch popstate event", .{
.type = "popstate",
.source = "history",
});
History._dispatchPopStateEvent(state, page) catch |err| {
log.err(.app, "dispatch popstate event error", .{
.err = err,
.type = "popstate",
.source = "history",
});
};
}
fn _dispatchPopStateEvent(state: ?[]const u8, page: *Page) !void {
var evt = try PopStateEvent.constructor("popstate", .{ .state = state });
_ = try parser.eventTargetDispatchEvent(
@as(*parser.EventTarget, @ptrCast(&page.window)),
&evt.proto,
);
}
pub fn _pushState(self: *History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void {
pub fn _replaceState(_: *const History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void {
const arena = page.session.arena;
const entry = page.session.navigation.currentEntry();
const json = try state.toJson(arena);
const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw);
const entry = HistoryEntry{ .state = json, .url = url };
try self.stack.append(arena, entry);
self.current = self.stack.items.len - 1;
entry.state = json;
entry.url = url;
}
pub fn _replaceState(self: *History, state: js.Object, _: ?[]const u8, _url: ?[]const u8, page: *Page) !void {
const arena = page.session.arena;
if (self.current) |curr| {
const entry = &self.stack.items[curr];
const json = try state.toJson(arena);
const url = if (_url) |u| try arena.dupe(u8, u) else try arena.dupe(u8, page.url.raw);
entry.* = HistoryEntry{ .state = json, .url = url };
} else {
try self._pushState(state, "", _url, page);
}
}
pub fn go(self: *History, delta: i32, page: *Page) !void {
pub fn go(_: *const History, delta: i32, page: *Page) !void {
// 0 behaves the same as no argument, both reloading the page.
// If this is getting called, there SHOULD be an entry, atleast from pushNavigation.
const current = self.current.?;
const current = page.session.navigation.index;
const index_s: i64 = @intCast(@as(i64, @intCast(current)) + @as(i64, @intCast(delta)));
if (index_s < 0 or index_s > self.stack.items.len - 1) {
if (index_s < 0 or index_s > page.session.navigation.entries.items.len - 1) {
return;
}
const index = @as(usize, @intCast(index_s));
const entry = self.stack.items[index];
self.current = index;
const entry = page.session.navigation.entries.items[index];
if (try page.isSameOrigin(entry.url)) {
History.dispatchPopStateEvent(entry.state, page);
if (entry.url) |url| {
if (try page.isSameOrigin(url)) {
PopStateEvent.dispatch(entry.state, page);
}
}
try page.navigateFromWebAPI(entry.url, .{ .reason = .history });
_ = try page.session.navigation.navigate(entry.url, .{ .traverse = index }, page);
}
pub fn _go(self: *History, _delta: ?i32, page: *Page) !void {
@@ -207,9 +146,38 @@ pub const PopStateEvent = struct {
return null;
}
}
pub fn dispatch(state: ?[]const u8, page: *Page) void {
log.debug(.script_event, "dispatch popstate event", .{
.type = "popstate",
.source = "history",
});
var evt = PopStateEvent.constructor("popstate", .{ .state = state }) catch |err| {
log.err(.app, "event constructor error", .{
.err = err,
.type = "popstate",
.source = "history",
});
return;
};
_ = parser.eventTargetDispatchEvent(
parser.toEventTarget(Window, &page.window),
&evt.proto,
) catch |err| {
log.err(.app, "dispatch popstate event error", .{
.err = err,
.type = "popstate",
.source = "history",
});
};
}
};
const testing = @import("../../testing.zig");
test "Browser: HTML.History" {
try testing.htmlRunner("html/history.html");
try testing.htmlRunner("html/history/history.html");
try testing.htmlRunner("html/history/history2.html");
}

View File

@@ -195,7 +195,7 @@ pub const HTMLDocument = struct {
}
pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null });
}
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {

View File

@@ -732,6 +732,9 @@ pub const HTMLInputElement = struct {
pub fn set_value(self: *parser.Input, value: []const u8) !void {
try parser.inputSetValue(self, value);
}
pub fn _select(_: *parser.Input) void {
log.debug(.web_api, "not implemented", .{ .feature = "HTMLInputElement select" });
}
};
pub const HTMLLIElement = struct {

View File

@@ -42,7 +42,7 @@ pub const ErrorEvent = struct {
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{});
parser.eventSetInternalType(event, .event);
parser.eventSetInternalType(event, .error_event);
const o = opts orelse ErrorEventInit{};

View File

@@ -38,7 +38,7 @@ pub const Location = struct {
}
pub fn set_href(_: *const Location, href: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(href, .{ .reason = .script });
return page.navigateFromWebAPI(href, .{ .reason = .script }, .{ .push = null });
}
pub fn get_protocol(self: *Location) []const u8 {
@@ -74,15 +74,15 @@ pub const Location = struct {
}
pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null });
}
pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
return page.navigateFromWebAPI(url, .{ .reason = .script }, .replace);
}
pub fn _reload(_: *const Location, page: *Page) !void {
return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script });
return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script }, .reload);
}
pub fn _toString(self: *Location, page: *Page) ![]const u8 {

View File

@@ -25,7 +25,7 @@ pub const SVGElement = struct {
// Currently the prototype chain is not implemented (will not be returned by toInterface())
// For that we need parser.SvgElement and the derived types with tags in the v-table.
pub const prototype = *Element;
// While this is a Node, could consider not exposing the subtype untill we have
// While this is a Node, could consider not exposing the subtype until we have
// a Self type to cast to.
pub const subtype = .node;
};

View File

@@ -25,6 +25,7 @@ const Page = @import("../page.zig").Page;
const Navigator = @import("navigator.zig").Navigator;
const History = @import("History.zig");
const Navigation = @import("../navigation/Navigation.zig");
const Location = @import("location.zig").Location;
const Crypto = @import("../crypto/crypto.zig").Crypto;
const Console = @import("../console/console.zig").Console;
@@ -41,6 +42,9 @@ const Request = @import("../fetch/Request.zig");
const fetchFn = @import("../fetch/fetch.zig").fetch;
const storage = @import("../storage/storage.zig");
const ErrorEvent = @import("error_event.zig").ErrorEvent;
const DirectEventHandler = @import("../events/event.zig").DirectEventHandler;
// https://dom.spec.whatwg.org/#interface-window-extensions
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#window
@@ -68,6 +72,7 @@ pub const Window = struct {
scroll_x: u32 = 0,
scroll_y: u32 = 0,
onload_callback: ?js.Function = null,
onpopstate_callback: ?js.Function = null,
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
var fbs = std.io.fixedBufferStream("");
@@ -109,31 +114,17 @@ pub const Window = struct {
/// Sets `onload_callback`.
pub fn set_onload(self: *Window, maybe_listener: ?EventHandler.Listener, page: *Page) !void {
const event_target = parser.toEventTarget(Window, self);
const event_type = "load";
try DirectEventHandler(Window, self, "load", maybe_listener, &self.onload_callback, page.arena);
}
// Check if we have a listener set.
if (self.onload_callback) |callback| {
const listener = try parser.eventTargetHasListener(event_target, event_type, false, callback.id);
std.debug.assert(listener != null);
try parser.eventTargetRemoveEventListener(event_target, event_type, listener.?, false);
}
/// Returns `onpopstate_callback`.
pub fn get_onpopstate(self: *const Window) ?js.Function {
return self.onpopstate_callback;
}
if (maybe_listener) |listener| {
switch (listener) {
// If an object is given as listener, do nothing.
.object => {},
.function => |callback| {
_ = try EventHandler.register(page.arena, event_target, event_type, listener, null) orelse unreachable;
self.onload_callback = callback;
return;
},
}
}
// Just unset the listener.
self.onload_callback = null;
/// Sets `onpopstate_callback`.
pub fn set_onpopstate(self: *Window, maybe_listener: ?EventHandler.Listener, page: *Page) !void {
try DirectEventHandler(Window, self, "popstate", maybe_listener, &self.onpopstate_callback, page.arena);
}
pub fn get_location(self: *Window) *Location {
@@ -141,7 +132,7 @@ pub const Window = struct {
}
pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
return page.navigateFromWebAPI(url, .{ .reason = .script });
return page.navigateFromWebAPI(url, .{ .reason = .script }, .{ .push = null });
}
// frames return the window itself, but accessing it via a pseudo
@@ -189,6 +180,10 @@ pub const Window = struct {
return &page.session.history;
}
pub fn get_navigation(_: *Window, page: *Page) *Navigation {
return &page.session.navigation;
}
// The interior height of the window in pixels, including the height of the horizontal scroll bar, if present.
pub fn get_innerHeight(_: *Window, page: *Page) u32 {
// We do not have scrollbars or padding so this is the same as Element.clientHeight
@@ -281,6 +276,25 @@ pub const Window = struct {
return out;
}
pub fn _reportError(self: *Window, err: js.Object, page: *Page) !void {
var error_event = try ErrorEvent.constructor("error", .{
.@"error" = err,
});
_ = try parser.eventTargetDispatchEvent(
parser.toEventTarget(Window, self),
@as(*parser.Event, &error_event.proto),
);
if (parser.eventDefaultPrevented(&error_event.proto) == false) {
const err_string = err.toString() catch "Unknown error";
log.info(.user_script, "error", .{
.err = err_string,
.stack = page.stackTrace() catch "???",
.source = "window.reportError",
});
}
}
const CreateTimeoutOpts = struct {
name: []const u8,
args: []js.Object = &.{},

View File

@@ -750,9 +750,16 @@ pub fn jsValueToZig(self: *Context, comptime named_function: NamedFunction, comp
unreachable;
},
.@"enum" => |e| {
switch (@typeInfo(e.tag_type)) {
.int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)),
else => @compileError(named_function.full_name ++ " has an unsupported enum parameter type: " ++ @typeName(T)),
if (@hasDecl(T, "ENUM_JS_USE_TAG")) {
const str = try self.jsValueToZig(named_function, []const u8, js_value);
return std.meta.stringToEnum(T, str) orelse return error.InvalidEnumValue;
} else {
switch (@typeInfo(e.tag_type)) {
.int => return std.meta.intToEnum(T, try jsIntToZig(e.tag_type, js_value, self.v8_context)),
else => {
@compileError(named_function.full_name ++ " has an unsupported enum parameter type: " ++ @typeName(T));
},
}
}
},
else => {},

View File

@@ -378,8 +378,13 @@ pub fn simpleZigValueToJs(isolate: v8.Isolate, value: anytype, comptime fail: bo
.@"enum" => {
const T = @TypeOf(value);
if (@hasDecl(T, "toString")) {
// This should be deprecated in favor of the ENUM_JS_USE_TAG.
return simpleZigValueToJs(isolate, value.toString(), fail);
}
if (@hasDecl(T, "ENUM_JS_USE_TAG")) {
return simpleZigValueToJs(isolate, @tagName(value), fail);
}
},
else => {},
}

View File

@@ -16,6 +16,7 @@ const Interfaces = generate.Tuple(.{
@import("../storage/storage.zig").Interfaces,
@import("../url/url.zig").Interfaces,
@import("../xhr/xhr.zig").Interfaces,
@import("../navigation/navigation.zig").Interfaces,
@import("../xhr/form_data.zig").Interfaces,
@import("../xhr/File.zig"),
@import("../xmlserializer/xmlserializer.zig").Interfaces,

View File

@@ -0,0 +1,292 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const URL = @import("../../url.zig").URL;
const js = @import("../js/js.zig");
const Page = @import("../page.zig").Page;
const DirectEventHandler = @import("../events/event.zig").DirectEventHandler;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventHandler = @import("../events/event.zig").EventHandler;
const parser = @import("../netsurf.zig");
// https://developer.mozilla.org/en-US/docs/Web/API/Navigation
const Navigation = @This();
const NavigationKind = @import("navigation.zig").NavigationKind;
const NavigationHistoryEntry = @import("navigation.zig").NavigationHistoryEntry;
const NavigationTransition = @import("navigation.zig").NavigationTransition;
const NavigationEventTarget = @import("NavigationEventTarget.zig");
const NavigationCurrentEntryChangeEvent = @import("navigation.zig").NavigationCurrentEntryChangeEvent;
pub const prototype = *NavigationEventTarget;
proto: NavigationEventTarget = NavigationEventTarget{},
index: usize = 0,
// Need to be stable pointers, because Events can reference entries.
entries: std.ArrayListUnmanaged(*NavigationHistoryEntry) = .empty,
next_entry_id: usize = 0,
pub fn get_canGoBack(self: *const Navigation) bool {
return self.index > 0;
}
pub fn get_canGoForward(self: *const Navigation) bool {
return self.entries.items.len > self.index + 1;
}
pub fn currentEntry(self: *Navigation) *NavigationHistoryEntry {
return self.entries.items[self.index];
}
pub fn get_currentEntry(self: *Navigation) *NavigationHistoryEntry {
return self.currentEntry();
}
pub fn get_transition(_: *const Navigation) ?NavigationTransition {
// For now, all transitions are just considered complete.
return null;
}
const NavigationReturn = struct {
committed: js.Promise,
finished: js.Promise,
};
pub fn _back(self: *Navigation, page: *Page) !NavigationReturn {
if (!self.get_canGoBack()) {
return error.InvalidStateError;
}
const new_index = self.index - 1;
const next_entry = self.entries.items[new_index];
self.index = new_index;
return self.navigate(next_entry.url, .{ .traverse = new_index }, page);
}
pub fn _entries(self: *const Navigation) []*NavigationHistoryEntry {
return self.entries.items;
}
pub fn _forward(self: *Navigation, page: *Page) !NavigationReturn {
if (!self.get_canGoForward()) {
return error.InvalidStateError;
}
const new_index = self.index + 1;
const next_entry = self.entries.items[new_index];
self.index = new_index;
return self.navigate(next_entry.url, .{ .traverse = new_index }, page);
}
// This is for after true navigation processing, where we need to ensure that our entries are up to date.
// This is only really safe to run in the `pageDoneCallback` where we can guarantee that the URL and NavigationKind are correct.
pub fn processNavigation(self: *Navigation, page: *Page) !void {
const url = page.url.raw;
const kind = page.session.navigation_kind;
if (kind) |k| {
switch (k) {
.replace => {
// When replacing, we just update the URL but the state is nullified.
const entry = self.currentEntry();
entry.url = url;
entry.state = null;
},
.push => |state| {
_ = try self.pushEntry(url, state, page, false);
},
.traverse, .reload => {},
}
} else {
_ = try self.pushEntry(url, null, page, false);
}
}
/// Pushes an entry into the Navigation stack WITHOUT actually navigating to it.
/// For that, use `navigate`.
pub fn pushEntry(self: *Navigation, _url: []const u8, state: ?[]const u8, page: *Page, dispatch: bool) !*NavigationHistoryEntry {
const arena = page.session.arena;
const url = try arena.dupe(u8, _url);
// truncates our history here.
if (self.entries.items.len > self.index + 1) {
self.entries.shrinkRetainingCapacity(self.index + 1);
}
const index = self.entries.items.len;
const id = self.next_entry_id;
self.next_entry_id += 1;
const id_str = try std.fmt.allocPrint(arena, "{d}", .{id});
const entry = try arena.create(NavigationHistoryEntry);
entry.* = NavigationHistoryEntry{
.id = id_str,
.key = id_str,
.url = url,
.state = state,
};
// we don't always have a current entry...
const previous = if (self.entries.items.len > 0) self.currentEntry() else null;
try self.entries.append(arena, entry);
if (previous) |prev| {
if (dispatch) {
NavigationCurrentEntryChangeEvent.dispatch(self, prev, .push);
}
}
self.index = index;
return entry;
}
const NavigateOptions = struct {
const NavigateOptionsHistory = enum {
pub const ENUM_JS_USE_TAG = true;
auto,
push,
replace,
};
state: ?js.Object = null,
info: ?js.Object = null,
history: NavigateOptionsHistory = .auto,
};
pub fn navigate(
self: *Navigation,
_url: ?[]const u8,
kind: NavigationKind,
page: *Page,
) !NavigationReturn {
const arena = page.session.arena;
const url = _url orelse return error.MissingURL;
// https://github.com/WICG/navigation-api/issues/95
//
// These will only settle on same-origin navigation (mostly intended for SPAs).
// It is fine (and expected) for these to not settle on cross-origin requests :)
const committed = try page.js.createPromiseResolver(.page);
const finished = try page.js.createPromiseResolver(.page);
const new_url = try URL.parse(url, null);
const is_same_document = try page.url.eqlDocument(&new_url, arena);
switch (kind) {
.push => |state| {
if (is_same_document) {
page.url = new_url;
try committed.resolve({});
// todo: Fire navigate event
try finished.resolve({});
_ = try self.pushEntry(url, state, page, true);
} else {
try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind);
}
},
.traverse => |index| {
self.index = index;
if (is_same_document) {
page.url = new_url;
try committed.resolve({});
// todo: Fire navigate event
try finished.resolve({});
} else {
try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind);
}
},
.reload => {
try page.navigateFromWebAPI(url, .{ .reason = .navigation }, kind);
},
else => unreachable,
}
return .{
.committed = committed.promise(),
.finished = finished.promise(),
};
}
pub fn _navigate(self: *Navigation, _url: []const u8, _opts: ?NavigateOptions, page: *Page) !NavigationReturn {
const opts = _opts orelse NavigateOptions{};
const json = if (opts.state) |state| state.toJson(page.session.arena) catch return error.DataClone else null;
return try self.navigate(_url, .{ .push = json }, page);
}
pub const ReloadOptions = struct {
state: ?js.Object = null,
info: ?js.Object = null,
};
pub fn _reload(self: *Navigation, _opts: ?ReloadOptions, page: *Page) !NavigationReturn {
const arena = page.session.arena;
const opts = _opts orelse ReloadOptions{};
const entry = self.currentEntry();
if (opts.state) |state| {
const previous = entry;
entry.state = state.toJson(arena) catch return error.DataClone;
NavigationCurrentEntryChangeEvent.dispatch(self, previous, .reload);
}
return self.navigate(entry.url, .reload, page);
}
pub const TraverseToOptions = struct {
info: ?js.Object = null,
};
pub fn _traverseTo(self: *Navigation, key: []const u8, _opts: ?TraverseToOptions, page: *Page) !NavigationReturn {
log.debug(.browser, "not implemented", .{ .options = _opts });
for (self.entries.items, 0..) |entry, i| {
if (std.mem.eql(u8, key, entry.key)) {
return try self.navigate(entry.url, .{ .traverse = i }, page);
}
}
return error.InvalidStateError;
}
pub const UpdateCurrentEntryOptions = struct {
state: js.Object,
};
pub fn _updateCurrentEntry(self: *Navigation, options: UpdateCurrentEntryOptions, page: *Page) !void {
const arena = page.session.arena;
const previous = self.currentEntry();
self.currentEntry().state = options.state.toJson(arena) catch return error.DataClone;
NavigationCurrentEntryChangeEvent.dispatch(self, previous, null);
}

View File

@@ -0,0 +1,56 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../page.zig").Page;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventHandler = @import("../events/event.zig").EventHandler;
const parser = @import("../netsurf.zig");
pub const NavigationEventTarget = @This();
pub const prototype = *EventTarget;
// Extend libdom event target for pure zig struct.
base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .navigation },
oncurrententrychange_cbk: ?js.Function = null,
fn register(
self: *NavigationEventTarget,
alloc: std.mem.Allocator,
typ: []const u8,
listener: EventHandler.Listener,
) !?js.Function {
const target = parser.toEventTarget(NavigationEventTarget, self);
// The only time this can return null if the listener is already
// registered. But before calling `register`, all of our functions
// remove any existing listener, so it should be impossible to get null
// from this function call.
const eh = (try EventHandler.register(alloc, target, typ, listener, null)) orelse unreachable;
return eh.callback;
}
fn unregister(self: *NavigationEventTarget, typ: []const u8, cbk_id: usize) !void {
const et = parser.toEventTarget(NavigationEventTarget, self);
// check if event target has already this listener
const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id);
if (lst == null) {
return;
}
// remove listener
try parser.eventTargetRemoveEventListener(et, typ, lst.?, false);
}
pub fn get_oncurrententrychange(self: *NavigationEventTarget) ?js.Function {
return self.oncurrententrychange_cbk;
}
pub fn set_oncurrententrychange(self: *NavigationEventTarget, listener: ?EventHandler.Listener, page: *Page) !void {
if (self.oncurrententrychange_cbk) |cbk| try self.unregister("currententrychange", cbk.id);
if (listener) |listen| {
self.oncurrententrychange_cbk = try self.register(page.arena, "currententrychange", listen);
}
}

View File

@@ -0,0 +1,215 @@
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const log = @import("../../log.zig");
const URL = @import("../../url.zig").URL;
const js = @import("../js/js.zig");
const Page = @import("../page.zig").Page;
const DirectEventHandler = @import("../events/event.zig").DirectEventHandler;
const EventTarget = @import("../dom/event_target.zig").EventTarget;
const EventHandler = @import("../events/event.zig").EventHandler;
const parser = @import("../netsurf.zig");
const Navigation = @import("Navigation.zig");
const NavigationEventTarget = @import("NavigationEventTarget.zig");
pub const Interfaces = .{
Navigation,
NavigationEventTarget,
NavigationActivation,
NavigationTransition,
NavigationHistoryEntry,
};
pub const NavigationType = enum {
pub const ENUM_JS_USE_TAG = true;
push,
replace,
traverse,
reload,
};
pub const NavigationKind = union(NavigationType) {
push: ?[]const u8,
replace,
traverse: usize,
reload,
};
// https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry
pub const NavigationHistoryEntry = struct {
pub const prototype = *EventTarget;
base: parser.EventTargetTBase = parser.EventTargetTBase{ .internal_target_type = .plain },
id: []const u8,
key: []const u8,
url: ?[]const u8,
state: ?[]const u8,
pub fn get_id(self: *const NavigationHistoryEntry) []const u8 {
return self.id;
}
pub fn get_index(self: *const NavigationHistoryEntry, page: *Page) i32 {
const navigation = page.session.navigation;
for (navigation.entries.items, 0..) |entry, i| {
if (std.mem.eql(u8, entry.id, self.id)) {
return @intCast(i);
}
}
return -1;
}
pub fn get_key(self: *const NavigationHistoryEntry) []const u8 {
return self.key;
}
pub fn get_sameDocument(self: *const NavigationHistoryEntry, page: *Page) !bool {
const _url = self.url orelse return false;
const url = try URL.parse(_url, null);
return page.url.eqlDocument(&url, page.arena);
}
pub fn get_url(self: *const NavigationHistoryEntry) ?[]const u8 {
return self.url;
}
pub fn _getState(self: *const NavigationHistoryEntry, page: *Page) !?js.Value {
if (self.state) |state| {
return try js.Value.fromJson(page.js, state);
} else {
return null;
}
}
};
// https://developer.mozilla.org/en-US/docs/Web/API/NavigationActivation
pub const NavigationActivation = struct {
const NavigationActivationType = enum {
pub const ENUM_JS_USE_TAG = true;
push,
reload,
replace,
traverse,
};
entry: NavigationHistoryEntry,
from: ?NavigationHistoryEntry = null,
type: NavigationActivationType,
pub fn get_entry(self: *const NavigationActivation) NavigationHistoryEntry {
return self.entry;
}
pub fn get_from(self: *const NavigationActivation) ?NavigationHistoryEntry {
return self.from;
}
pub fn get_navigationType(self: *const NavigationActivation) NavigationActivationType {
return self.type;
}
};
// https://developer.mozilla.org/en-US/docs/Web/API/NavigationTransition
pub const NavigationTransition = struct {
finished: js.Promise,
from: NavigationHistoryEntry,
navigation_type: NavigationActivation.NavigationActivationType,
};
const Event = @import("../events/event.zig").Event;
pub const NavigationCurrentEntryChangeEvent = struct {
pub const prototype = *Event;
pub const union_make_copy = true;
pub const EventInit = struct {
from: *NavigationHistoryEntry,
navigationType: ?NavigationType = null,
};
proto: parser.Event,
from: *NavigationHistoryEntry,
navigation_type: ?NavigationType,
pub fn constructor(event_type: []const u8, opts: EventInit) !NavigationCurrentEntryChangeEvent {
const event = try parser.eventCreate();
defer parser.eventDestroy(event);
try parser.eventInit(event, event_type, .{});
parser.eventSetInternalType(event, .navigation_current_entry_change_event);
return .{
.proto = event.*,
.from = opts.from,
.navigation_type = opts.navigationType,
};
}
pub fn get_from(self: *NavigationCurrentEntryChangeEvent) *NavigationHistoryEntry {
return self.from;
}
pub fn get_navigationType(self: *const NavigationCurrentEntryChangeEvent) ?NavigationType {
return self.navigation_type;
}
pub fn dispatch(navigation: *Navigation, from: *NavigationHistoryEntry, typ: ?NavigationType) void {
log.debug(.script_event, "dispatch event", .{
.type = "currententrychange",
.source = "navigation",
});
var evt = NavigationCurrentEntryChangeEvent.constructor(
"currententrychange",
.{ .from = from, .navigationType = typ },
) catch |err| {
log.err(.app, "event constructor error", .{
.err = err,
.type = "currententrychange",
.source = "navigation",
});
return;
};
_ = parser.eventTargetDispatchEvent(
@as(*parser.EventTarget, @ptrCast(navigation)),
&evt.proto,
) catch |err| {
log.err(.app, "dispatch event error", .{
.err = err,
.type = "currententrychange",
.source = "navigation",
});
};
}
};
const testing = @import("../../testing.zig");
test "Browser: Navigation" {
try testing.htmlRunner("html/navigation/navigation.html");
try testing.htmlRunner("html/navigation/navigation_currententrychange.html");
}

View File

@@ -559,6 +559,8 @@ pub const EventType = enum(u8) {
message_event = 7,
keyboard_event = 8,
pop_state = 9,
composition_event = 10,
navigation_current_entry_change_event = 11,
};
pub const MutationEvent = c.dom_mutation_event;
@@ -830,6 +832,7 @@ pub const EventTargetTBase = extern struct {
message_port = 7,
screen = 8,
screen_orientation = 9,
navigation = 10,
};
vtable: ?*const c.struct_dom_event_target_vtable = &c.struct_dom_event_target_vtable{

View File

@@ -35,6 +35,9 @@ const ScriptManager = @import("ScriptManager.zig");
const SlotChangeMonitor = @import("SlotChangeMonitor.zig");
const HTMLDocument = @import("html/document.zig").HTMLDocument;
const NavigationKind = @import("navigation/navigation.zig").NavigationKind;
const NavigationCurrentEntryChangeEvent = @import("navigation/navigation.zig").NavigationCurrentEntryChangeEvent;
const js = @import("js/js.zig");
const URL = @import("../url.zig").URL;
@@ -815,8 +818,8 @@ pub const Page = struct {
},
}
// Push the navigation after a successful load.
try self.session.history.pushNavigation(self.url.raw, self);
// We need to handle different navigation types differently.
try self.session.navigation.processNavigation(self);
}
fn pageErrorCallback(ctx: *anyopaque, err: anyerror) void {
@@ -906,7 +909,7 @@ pub const Page = struct {
.a => {
const element: *parser.Element = @ptrCast(node);
const href = (try parser.elementGetAttribute(element, "href")) orelse return;
try self.navigateFromWebAPI(href, .{});
try self.navigateFromWebAPI(href, .{}, .{ .push = null });
},
.input => {
const element: *parser.Element = @ptrCast(node);
@@ -1013,13 +1016,55 @@ pub const Page = struct {
}
}
// insertText is a shortcut to insert text into the active element.
pub fn insertText(self: *Page, v: []const u8) !void {
const Document = @import("dom/document.zig").Document;
const element = (try Document.getActiveElement(@ptrCast(self.window.document), self)) orelse return;
const node = parser.elementToNode(element);
const tag = (try parser.nodeHTMLGetTagType(node)) orelse return;
switch (tag) {
.input => {
const input_type = try parser.inputGetType(@ptrCast(element));
if (std.mem.eql(u8, input_type, "text")) {
const value = try parser.inputGetValue(@ptrCast(element));
const new_value = try std.mem.concat(self.arena, u8, &.{ value, v });
try parser.inputSetValue(@ptrCast(element), new_value);
}
},
.textarea => {
const value = try parser.textareaGetValue(@ptrCast(node));
const new_value = try std.mem.concat(self.arena, u8, &.{ value, v });
try parser.textareaSetValue(@ptrCast(node), new_value);
},
else => {},
}
}
// We cannot navigate immediately as navigating will delete the DOM tree,
// which holds this event's node.
// As such we schedule the function to be called as soon as possible.
// The page.arena is safe to use here, but the transfer_arena exists
// specifically for this type of lifetime.
pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts) !void {
pub fn navigateFromWebAPI(self: *Page, url: []const u8, opts: NavigateOpts, kind: NavigationKind) !void {
const session = self.session;
const stitched_url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always });
// Force will force a page load.
// Otherwise, we need to check if this is a true navigation.
if (!opts.force) {
// If we are navigating within the same document, just change URL.
const new_url = try URL.parse(stitched_url, null);
if (try self.url.eqlDocument(&new_url, session.transfer_arena)) {
self.url = new_url;
const prev = session.navigation.currentEntry();
NavigationCurrentEntryChangeEvent.dispatch(&self.session.navigation, prev, kind);
return;
}
}
if (session.queued_navigation != null) {
// It might seem like this should never happen. And it might not,
// BUT..consider the case where we have script like:
@@ -1042,9 +1087,11 @@ pub const Page = struct {
session.queued_navigation = .{
.opts = opts,
.url = try URL.stitch(session.transfer_arena, url, self.url.raw, .{ .alloc = .always }),
.url = stitched_url,
};
session.navigation_kind = kind;
self.http_client.abort();
// In v8, this throws an exception which JS code cannot catch.
@@ -1095,7 +1142,7 @@ pub const Page = struct {
} else {
action = try URL.concatQueryString(transfer_arena, action, buf.items);
}
try self.navigateFromWebAPI(action, opts);
try self.navigateFromWebAPI(action, opts, .{ .push = null });
}
pub fn isNodeAttached(self: *const Page, node: *parser.Node) bool {
@@ -1153,6 +1200,7 @@ pub const NavigateReason = enum {
form,
script,
history,
navigation,
};
pub const NavigateOpts = struct {
@@ -1161,6 +1209,7 @@ pub const NavigateOpts = struct {
method: Http.Method = .GET,
body: ?[]const u8 = null,
header: ?[:0]const u8 = null,
force: bool = false,
};
const IdleNotification = union(enum) {

View File

@@ -22,9 +22,11 @@ const Allocator = std.mem.Allocator;
const js = @import("js/js.zig");
const Page = @import("page.zig").Page;
const NavigationKind = @import("navigation/navigation.zig").NavigationKind;
const Browser = @import("browser.zig").Browser;
const NavigateOpts = @import("page.zig").NavigateOpts;
const History = @import("html/History.zig");
const Navigation = @import("navigation/Navigation.zig");
const log = @import("../log.zig");
const parser = @import("netsurf.zig");
@@ -57,6 +59,8 @@ pub const Session = struct {
// History is persistent across the "tab".
// https://developer.mozilla.org/en-US/docs/Web/API/History
history: History = .{},
navigation: Navigation = .{},
navigation_kind: ?NavigationKind = null,
page: ?Page = null,

View File

@@ -702,7 +702,7 @@ const IsolatedWorld = struct {
// The isolate world must share at least some of the state with the related page, specifically the DocumentHTML
// (assuming grantUniveralAccess will be set to True!).
// We just created the world and the page. The page's state lives in the session, but is update on navigation.
// This also means this pointer becomes invalid after removePage untill a new page is created.
// This also means this pointer becomes invalid after removePage until a new page is created.
// Currently we have only 1 page/frame and thus also only 1 state in the isolate world.
pub fn createContext(self: *IsolatedWorld, page: *Page) !void {
// if (self.executor.context != null) return error.Only1IsolatedContextSupported;

View File

@@ -23,11 +23,13 @@ pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
dispatchKeyEvent,
dispatchMouseEvent,
insertText,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.dispatchKeyEvent => return dispatchKeyEvent(cmd),
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
.insertText => return insertText(cmd),
}
}
@@ -115,6 +117,20 @@ fn dispatchMouseEvent(cmd: anytype) !void {
// result already sent
}
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-insertText
fn insertText(cmd: anytype) !void {
const params = (try cmd.params(struct {
text: []const u8, // The text to insert
})) orelse return error.InvalidParams;
const bc = cmd.browser_context orelse return;
const page = bc.session.currentPage() orelse return;
try page.insertText(params.text);
try cmd.sendResult(null, .{});
}
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
const bc = cmd.browser_context.?;

View File

@@ -174,7 +174,7 @@ pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.Pa
var cdp = bc.cdp;
const reason_: ?[]const u8 = switch (event.opts.reason) {
.anchor => "anchorClick",
.script, .history => "scriptInitiated",
.script, .history, .navigation => "scriptInitiated",
.form => switch (event.opts.method) {
.GET => "formSubmissionGet",
.POST => "formSubmissionPost",

View File

@@ -21,9 +21,48 @@ const std = @import("std");
pub fn processMessage(cmd: anytype) !void {
const action = std.meta.stringToEnum(enum {
enable,
setIgnoreCertificateErrors,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
.enable => return cmd.sendResult(null, .{}),
.setIgnoreCertificateErrors => return setIgnoreCertificateErrors(cmd),
}
}
fn setIgnoreCertificateErrors(cmd: anytype) !void {
const params = (try cmd.params(struct {
ignore: bool,
})) orelse return error.InvalidParams;
if (params.ignore) {
try cmd.cdp.browser.http_client.disableTlsVerify();
} else {
try cmd.cdp.browser.http_client.enableTlsVerify();
}
return cmd.sendResult(null, .{});
}
const testing = @import("../testing.zig");
test "cdp.Security: setIgnoreCertificateErrors" {
var ctx = testing.context();
defer ctx.deinit();
_ = try ctx.loadBrowserContext(.{ .id = "BID-9" });
try ctx.processMessage(.{
.id = 8,
.method = "Security.setIgnoreCertificateErrors",
.params = .{ .ignore = true },
});
try ctx.expectSentResult(null, .{ .id = 8 });
try ctx.processMessage(.{
.id = 9,
.method = "Security.setIgnoreCertificateErrors",
.params = .{ .ignore = false },
});
try ctx.expectSentResult(null, .{ .id = 9 });
}

View File

@@ -93,6 +93,11 @@ notification: ?*Notification = null,
// restoring, this originally-configured value is what it goes to.
http_proxy: ?[:0]const u8 = null,
// track if the client use a proxy for connections.
// We can't use http_proxy because we want also to track proxy configured via
// CDP.
use_proxy: bool,
// The complete user-agent header line
user_agent: [:0]const u8,
@@ -126,6 +131,7 @@ pub fn init(allocator: Allocator, ca_blob: ?c.curl_blob, opts: Http.Opts) !*Clie
.handles = handles,
.allocator = allocator,
.http_proxy = opts.http_proxy,
.use_proxy = opts.http_proxy != null,
.user_agent = opts.user_agent,
.transfer_pool = transfer_pool,
};
@@ -315,6 +321,7 @@ pub fn changeProxy(self: *Client, proxy: [:0]const u8) !void {
for (self.handles.handles) |*h| {
try errorCheck(c.curl_easy_setopt(h.conn.easy, c.CURLOPT_PROXY, proxy.ptr));
}
self.use_proxy = true;
}
// Same restriction as changeProxy. Should be ok since this is only called on
@@ -326,6 +333,41 @@ pub fn restoreOriginalProxy(self: *Client) !void {
for (self.handles.handles) |*h| {
try errorCheck(c.curl_easy_setopt(h.conn.easy, c.CURLOPT_PROXY, proxy));
}
self.use_proxy = proxy != null;
}
// Enable TLS verification on all connections.
pub fn enableTlsVerify(self: *const Client) !void {
try self.ensureNoActiveConnection();
for (self.handles.handles) |*h| {
const easy = h.conn.easy;
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYHOST, @as(c_long, 2)));
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYPEER, @as(c_long, 1)));
if (self.use_proxy) {
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYHOST, @as(c_long, 2)));
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYPEER, @as(c_long, 1)));
}
}
}
// Disable TLS verification on all connections.
pub fn disableTlsVerify(self: *const Client) !void {
try self.ensureNoActiveConnection();
for (self.handles.handles) |*h| {
const easy = h.conn.easy;
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYHOST, @as(c_long, 0)));
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_SSL_VERIFYPEER, @as(c_long, 0)));
if (self.use_proxy) {
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYHOST, @as(c_long, 0)));
try errorCheck(c.curl_easy_setopt(easy, c.CURLOPT_PROXY_SSL_VERIFYPEER, @as(c_long, 0)));
}
}
}
fn makeRequest(self: *Client, handle: *Handle, transfer: *Transfer) !void {
@@ -808,7 +850,7 @@ pub const Transfer = struct {
self.deinit();
}
// abortAuthChallenge is called when an auth chanllenge interception is
// abortAuthChallenge is called when an auth challenge interception is
// abort. We don't call self.client.endTransfer here b/c it has been done
// before interception process.
pub fn abortAuthChallenge(self: *Transfer) void {

View File

@@ -487,7 +487,7 @@ pub const Client = struct {
}
// called by CDP
// Websocket frames have a variable lenght header. For server-client,
// Websocket frames have a variable length header. For server-client,
// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have
// writev, so we need to get creative. We'll JSON serialize to a
// buffer, where the first 10 bytes are reserved. We can then backfill

View File

@@ -402,19 +402,13 @@ pub fn htmlRunner(file: []const u8) !void {
const url = try std.fmt.allocPrint(arena_allocator, "http://localhost:9582/src/tests/{s}", .{file});
try page.navigate(url, .{});
_ = page.wait(2000);
test_session.fetchWait(2000);
// page exits more aggressively in tests. We want to make sure this is called
// at lease once.
page.session.browser.runMicrotasks();
page.session.browser.runMessageLoop();
const needs_second_wait = try js_context.exec("testing._onPageWait.length > 0", "check_onPageWait");
if (needs_second_wait.value.toBool(page.js.isolate)) {
// sets the isSecondWait flag in testing.
_ = js_context.exec("testing._isSecondWait = true", "set_second_wait_flag") catch {};
_ = page.wait(2000);
}
@import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start);
const value = js_context.exec("testing.getStatus()", "testing.getStatus()") catch |err| {

View File

@@ -7,6 +7,7 @@
<p id="para"> And</p>
<!--comment-->
</div>
<div id="rootNodeComposed"></div>
</body>
<script src="../testing.js"></script>
@@ -36,6 +37,26 @@ let first_child = content.firstChild.nextSibling; // nextSibling because of line
testing.expectEqual('HTMLDocument', content.getRootNode().__proto__.constructor.name);
</script>
<script id=getRootNodeComposed>
const testContainer = $('#rootNodeComposed');
const shadowHost = document.createElement('div');
testContainer.appendChild(shadowHost);
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
const shadowChild = document.createElement('span');
shadowRoot.appendChild(shadowChild);
testing.expectEqual('ShadowRoot', shadowChild.getRootNode().__proto__.constructor.name);
testing.expectEqual('ShadowRoot', shadowChild.getRootNode({ composed: false }).__proto__.constructor.name);
testing.expectEqual('HTMLDocument', shadowChild.getRootNode({ composed: true }).__proto__.constructor.name);
testing.expectEqual('HTMLDocument', shadowHost.getRootNode().__proto__.constructor.name);
const disconnected = document.createElement('div');
const disconnectedChild = document.createElement('span');
disconnected.appendChild(disconnectedChild);
testing.expectEqual('HTMLDivElement', disconnectedChild.getRootNode().__proto__.constructor.name);
testing.expectEqual('HTMLDivElement', disconnectedChild.getRootNode({ composed: true }).__proto__.constructor.name);
</script>
<script id=firstChild>
let body_first_child = document.body.firstChild;
testing.expectEqual('div', body_first_child.localName);

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id=noNata>
{
let event = new CompositionEvent("test", {});
testing.expectEqual(true, event instanceof CompositionEvent);
testing.expectEqual(true, event instanceof Event);
testing.expectEqual("test", event.type);
testing.expectEqual("", event.data);
}
</script>
<script id=withData>
{
let event = new CompositionEvent("test2", {data: "over 9000!"});
testing.expectEqual("test2", event.type);
testing.expectEqual("over 9000!", event.data);
}
</script>
<script id=dispatch>
{
let called = 0;
document.addEventListener('CE', (e) => {
testing.expectEqual('test-data', e.data);
testing.expectEqual(true, e instanceof CompositionEvent);
called += 1
});
document.dispatchEvent(new CompositionEvent('CE', {data: 'test-data'}));
testing.expectEqual(1, called);
}
</script>

View File

@@ -1,21 +1,22 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script src="../../testing.js"></script>
<script id=history>
testing.expectEqual('auto', history.scrollRestoration);
history.scrollRestoration = 'manual';
history.scrollRestoration = 'foo';
testing.expectEqual('manual', history.scrollRestoration);
history.scrollRestoration = 'auto';
testing.expectEqual('auto', history.scrollRestoration);
testing.expectEqual(null, history.state)
history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/xhr/json');
history.pushState({ testInProgress: true }, null, 'http://127.0.0.1:9582/src/tests/html/history/history_after_nav.html');
testing.expectEqual({ testInProgress: true }, history.state);
history.pushState({ testInProgress: false }, null, 'http://127.0.0.1:9582/xhr/json');
history.replaceState({ "new": "field", testComplete: true }, null);
let state = { "new": "field", testComplete: true };
testing.expectEqual(state, history.state);
@@ -32,10 +33,5 @@
testing.expectEqual(state, popstateEventState);
})
testing.onPageWait(() => {
testing.expectEqual(true, history.state && history.state.testComplete);
testing.expectEqual(state, history.state);
});
testing.expectEqual(undefined, history.go());
history.back();
</script>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<script id=history2>
history.pushState(
{"new": "field", testComplete: true },
null,
'http://127.0.0.1:9582/src/tests/html/history/history_after_nav.html'
);
let popstateEventFired = false;
let popstateEventState = null;
// uses the window event listener.
window.onpopstate = (event) => {
popstateEventFired = true;
popstateEventState = event.state;
};
testing.eventually(() => {
testing.expectEqual(true, popstateEventFired);
testing.expectEqual(true, popstateEventState.testComplete);
})
history.back();
</script>

View File

@@ -0,0 +1,6 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<script id=history2>
testing.expectEqual(true, history.state && history.state.testInProgress);
</script>

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<script id=navigation>
testing.expectEqual('object', typeof navigation);
testing.expectEqual('object', typeof navigation.currentEntry);
testing.expectEqual('string', typeof navigation.currentEntry.id);
testing.expectEqual('string', typeof navigation.currentEntry.key);
testing.expectEqual('string', typeof navigation.currentEntry.url);
const currentIndex = navigation.currentEntry.index;
navigation.navigate(
'http://localhost:9582/src/tests/html/navigation/navigation2.html',
{ state: { currentIndex: currentIndex, navTestInProgress: true } }
);
</script>

View File

@@ -0,0 +1,8 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<script id=navigation2>
const state = navigation.currentEntry.getState();
testing.expectEqual(true, state.navTestInProgress);
testing.expectEqual(state.currentIndex + 1, navigation.currentEntry.index);
</script>

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<script id=navigation_currententrychange>
let currentEntryChanged = false;
navigation.addEventListener("currententrychange", () => {
currentEntryChanged = true;
});
// Doesn't fully navigate if same document.
location.href = location.href + "#1";
testing.expectEqual(true, currentEntryChanged);
</script>

View File

@@ -51,14 +51,6 @@
// if we're already in a fail state, return fail, nothing can recover this
if (testing._status === 'fail') return 'fail';
if (testing._isSecondWait) {
for (const pw of (testing._onPageWait)) {
testing._captured = pw[1];
pw[0]();
testing._captured = null;
}
}
// run any eventually's that we've captured
for (const ev of testing._eventually) {
testing._captured = ev[1];
@@ -101,18 +93,6 @@
_registerErrorCallback();
}
// Set expectations to happen on the next time that `page.wait` is executed.
//
// History specifically uses this as it queues navigation that needs to be checked
// when the next page is loaded.
function onPageWait(fn) {
// Store callbacks to run when page.wait() happens
testing._onPageWait.push([fn, {
script_id: document.currentScript.id,
stack: new Error().stack,
}]);
}
async function async(promise, cb) {
const script_id = document.currentScript ? document.currentScript.id : '<script id is unavailable in browsers>';
const stack = new Error().stack;
@@ -192,15 +172,12 @@
window.testing = {
_status: 'empty',
_eventually: [],
_onPageWait: [],
_executed_scripts: new Set(),
_captured: null,
_isSecondWait: false,
skip: skip,
async: async,
getStatus: getStatus,
eventually: eventually,
onPageWait: onPageWait,
expectEqual: expectEqual,
expectError: expectError,
withError: withError,

View File

@@ -149,3 +149,19 @@
testing.eventually(() => testing.expectEqual(true, isWindowTarget));
</script>
<script id=reportError>
let errorEventFired = false;
let capturedError = null;
window.addEventListener('error', (e) => {
errorEventFired = true;
capturedError = e.error;
});
const testError = new Error('Test error message');
window.reportError(testError);
testing.expectEqual(true, errorEventFired);
testing.expectEqual(testError, capturedError);
</script>

View File

@@ -217,6 +217,26 @@ pub const URL = struct {
buf.appendSliceAssumeCapacity(query_string);
return buf.items;
}
// Compares two URLs, returning true if it is the same document.
pub fn eqlDocument(self: *const URL, other: *const URL, arena: Allocator) !bool {
if (!std.mem.eql(u8, self.scheme(), other.scheme())) return false;
if (!std.mem.eql(u8, self.host(), other.host())) return false;
if (self.port() != other.port()) return false;
const path1 = try self.uri.path.toRawMaybeAlloc(arena);
const path2 = try other.uri.path.toRawMaybeAlloc(arena);
if ((self.uri.query == null) != (other.uri.query == null)) return false;
if (self.uri.query) |self_query| {
const other_query = other.uri.query.?;
const query1 = try self_query.toRawMaybeAlloc(arena);
const query2 = try other_query.toRawMaybeAlloc(arena);
if (!std.mem.eql(u8, query1, query2)) return false;
}
return std.mem.eql(u8, path1, path2);
}
};
const StitchOpts = struct {
@@ -553,3 +573,92 @@ test "URL: concatQueryString" {
try testing.expectEqual("https://www.lightpanda.io/index?1=2&a=b", url);
}
}
test "URL: eqlDocument" {
defer testing.reset();
const arena = testing.arena_allocator;
{
const url1 = try URL.parse("https://lightpanda.io/about", null);
const url2 = try URL.parse("https://lightpanda.io/about", null);
try testing.expectEqual(true, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about", null);
const url2 = try URL.parse("http://lightpanda.io/about", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about", null);
const url2 = try URL.parse("https://example.com/about", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io:8080/about", null);
const url2 = try URL.parse("https://lightpanda.io:9090/about", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about", null);
const url2 = try URL.parse("https://lightpanda.io/contact", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about?foo=bar", null);
const url2 = try URL.parse("https://lightpanda.io/about?baz=qux", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about#section1", null);
const url2 = try URL.parse("https://lightpanda.io/about#section2", null);
try testing.expectEqual(true, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about", null);
const url2 = try URL.parse("https://lightpanda.io/about/", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/", null);
const url2 = try URL.parse("https://lightpanda.io", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about?foo=bar", null);
const url2 = try URL.parse("https://lightpanda.io/about", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about", null);
const url2 = try URL.parse("https://lightpanda.io/about?foo=bar", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about?foo=bar", null);
const url2 = try URL.parse("https://lightpanda.io/about?foo=bar", null);
try testing.expectEqual(true, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://lightpanda.io/about?", null);
const url2 = try URL.parse("https://lightpanda.io/about", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
{
const url1 = try URL.parse("https://duckduckgo.com/", null);
const url2 = try URL.parse("https://duckduckgo.com/?q=lightpanda", null);
try testing.expectEqual(false, try url1.eqlDocument(&url2, arena));
}
}