diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 11e31ff6..e0ab9295 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.4.4' + default: 'v0.4.5' v8: description: 'v8 version to install' required: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..bae474a3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# AGENTS.md + +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to open a pull request (CLA, dev setup, pre-PR checks). + +## Tests + +```bash +make test # Run all tests +make test F="server" # Filter by substring +TEST_FILTER="WebApi: #selector_all" make test # Filter main + subtest (separator: #) +TEST_VERBOSE=true make test +TEST_FAIL_FIRST=true make test +METRICS=true make test # Capture allocation/duration metrics as JSON +``` + +The custom test runner (`src/test_runner.zig`) detects memory leaks in debug builds. **A test that allocates without freeing fails** — not just lints. + +## Formatting + +```bash +zig fmt --check ./*.zig ./**/*.zig # Exact command CI runs +``` + +`zig build` depends on the fmt step, so a local build catches drift too. + +## Conventions + +Mirror the patterns in neighboring files. In particular: + +- `@import` alias case follows the imported file's basename (`const Frame = @import("Frame.zig")`, `const ast = @import("ast.zig")`). +- Prefer struct-init type inference (`.{ ... }`) where the expected type is known from the function signature or variable annotation. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 351d63e5..f0304578 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,21 @@ Lightpanda accepts pull requests through GitHub. +## Development + +- Run the tests: `make test` +- Check formatting: `zig fmt --check ./*.zig ./**/*.zig` + +See [AGENTS.md](AGENTS.md) for the full set of test, formatting, and code conventions (test filters, the leak-detection invariant, `@import` alias case, struct-init inference). + +## Before opening a PR + +- [ ] Tests pass (`make test`). +- [ ] Formatting is clean (`zig fmt --check ./*.zig ./**/*.zig`). +- [ ] CLA signed (see below). + +## CLA + You have to sign our [CLA](CLA.md) during your first pull request process otherwise we're not able to accept your contributions. diff --git a/Dockerfile b/Dockerfile index 5966c66d..33c583b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.4.4 +ARG ZIG_V8=v0.4.5 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/README.md b/README.md index 86aa97aa..a5e81c39 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,15 @@ See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details You can test Lightpanda by running `make test`. +```bash +make test # Run all tests +make test F="server" # Filter by substring +TEST_FILTER="WebApi: #selector_all" make test # Filter main + subtest (separator: #) +TEST_VERBOSE=true make test +TEST_FAIL_FIRST=true make test +METRICS=true make test # Capture allocation/duration metrics as JSON +``` + ### End to end tests To run end to end tests, you need to clone the [demo diff --git a/build.zig.zon b/build.zig.zon index ac4cf968..7d8de4ec 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.4.tar.gz", - .hash = "v8-0.0.0-xddH6xiUBACrbPC02HPqL4IOl_1EKAF6zf0IwNKaCILK", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.5.tar.gz", + .hash = "v8-0.0.0-xddH65CXBADLRFlCM_pECcwsoY-9P9mZ7VnYM-6V3mXW", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/CustomElementReactions.zig b/src/browser/CustomElementReactions.zig deleted file mode 100644 index d7e9b1dc..00000000 --- a/src/browser/CustomElementReactions.zig +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -// Implements the spec's "custom element reactions" mechanism: callbacks -// (connectedCallback, disconnectedCallback, adoptedCallback, -// attributeChangedCallback) are enqueued during DOM mutation and invoked at -// the outer algorithm boundary, not synchronously mid-mutation. -// -// The "stack of element queues" is collapsed to a single flat ArrayList plus -// per-scope checkpoint indices: push() captures items.len, popAndInvoke() -// drains items[checkpoint..] and truncates. Nested scopes work naturally — -// inside a callback, a new scope captures its own checkpoint past the current -// length, drains its own range, and the outer iteration continues from where -// it left off. - -const std = @import("std"); -const lp = @import("lightpanda"); - -const Frame = @import("Frame.zig"); -const Element = @import("webapi/Element.zig"); -const Document = @import("webapi/Document.zig"); -const Custom = @import("webapi/element/html/Custom.zig"); - -const String = lp.String; -const Allocator = std.mem.Allocator; - -const Self = @This(); - -allocator: Allocator, -queue: std.ArrayList(Reaction) = .empty, -// Number of currently-open scopes (push() that hasn't been pop'd). Every -// enqueue must happen inside a scope — that's the leak-detection invariant. -// Checked in debug at enqueue time so leaks surface where the bug is, not -// later at some unrelated boundary. -active_scopes: u32 = 0, - -/// Open a new reactions scope. Returns a checkpoint to be passed to popAndInvoke. -pub fn push(self: *Self) usize { - self.active_scopes += 1; - return self.queue.items.len; -} - -/// Drain reactions queued at indices >= checkpoint, then truncate. Reactions -/// enqueued within a nested scope drain at that scope's pop, before this loop -/// sees them. -pub fn popAndInvoke(self: *Self, checkpoint: usize, frame: *Frame) void { - for (self.queue.items[checkpoint..]) |reaction| { - Custom.fireReaction(reaction, frame); - } - self.queue.items.len = checkpoint; - self.active_scopes -= 1; -} - -inline fn assertScopeActive(self: *const Self) void { - lp.assert(self.active_scopes > 0, "ce_reactions enqueue without active scope", .{}); -} - -pub fn enqueueConnected(self: *Self, element: *Element) !void { - self.assertScopeActive(); - try self.queue.append(self.allocator, .{ .connected = element }); -} - -pub fn enqueueDisconnected(self: *Self, element: *Element) !void { - self.assertScopeActive(); - try self.queue.append(self.allocator, .{ .disconnected = element }); -} - -pub fn enqueueAdopted(self: *Self, element: *Element, old_document: *Document, new_document: *Document) !void { - self.assertScopeActive(); - try self.queue.append(self.allocator, .{ .adopted = .{ - .element = element, - .old_document = old_document, - .new_document = new_document, - } }); -} - -pub fn enqueueAttributeChanged( - self: *Self, - element: *Element, - name: String, - old_value: ?String, - new_value: ?String, - namespace: ?String, -) !void { - self.assertScopeActive(); - try self.queue.append(self.allocator, .{ .attribute_changed = .{ - .name = name, - .element = element, - .old_value = old_value, - .new_value = new_value, - .namespace = namespace, - } }); -} - -pub const Reaction = union(enum) { - connected: *Element, - disconnected: *Element, - adopted: Adopted, - attribute_changed: AttributeChanged, - - pub const Adopted = struct { - element: *Element, - old_document: *Document, - new_document: *Document, - }; - - pub const AttributeChanged = struct { - element: *Element, - name: String, - old_value: ?String, - new_value: ?String, - namespace: ?String, - }; -}; diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f92595bd..0b36c75a 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -32,8 +32,6 @@ const StyleManager = @import("StyleManager.zig"); const Parser = @import("parser/Parser.zig"); const h5e = @import("parser/html5ever.zig"); -const CustomElementReactions = @import("CustomElementReactions.zig"); - const URL = @import("URL.zig"); const Blob = @import("webapi/Blob.zig"); const Node = @import("webapi/Node.zig"); @@ -190,12 +188,6 @@ _upgrading_element: ?*Node = null, // List of custom elements that were created before their definition was registered _undefined_custom_elements: std.ArrayList(*Element.Html.Custom) = .{}, -// Pending custom-element reactions (connected/disconnected/adopted/attribute -// changed). Reactions are enqueued during DOM mutation and drained at the -// outer algorithm boundary — set up by the JS bridge for [CEReactions] -// methods and by the parser pump on each yield. -_ce_reactions: CustomElementReactions, - // for heap allocations and managing WebAPI objects _factory: *Factory, @@ -296,7 +288,6 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, parent: ?*Frame) !void { ._type = if (parent == null) .root else .frame, ._style_manager = undefined, ._script_manager = undefined, - ._ce_reactions = .{ .allocator = arena }, ._event_manager = EventManager.init(arena, self), ._console_messages = .init(arena), }; @@ -1868,7 +1859,7 @@ pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: // Per spec, adopted steps run on each element after its document is set. if (node.is(Element)) |el| { - Element.Html.Custom.enqueueAdoptedCallbackOnElement(el, old_owner, new_owner, self); + Element.Html.Custom.invokeAdoptedCallbackOnElement(el, old_owner, new_owner, self); } var it = node.childrenIterator(); @@ -2604,7 +2595,7 @@ pub fn createElementNS(self: *Frame, namespace: Element.Namespace, name: []const if (element._attributes) |attributes| { var it = attributes.iterator(); while (it.next()) |attr| { - Element.Html.Custom.enqueueAttributeChangedCallbackOnElement( + Element.Html.Custom.invokeAttributeChangedCallbackOnElement( element, attr._name, null, // old_value is null for initial attributes @@ -3026,7 +3017,7 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt self.removeElementIdWithMaps(id_maps.?, id); } - Element.Html.Custom.enqueueDisconnectedCallbackOnElement(el, self); + Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self); // If a