diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 9dd32da0..aafa4ce7 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -63,6 +63,25 @@ jobs: zig-out/bin/lightpanda retention-days: 1 + cdpproxy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + repository: 'lightpanda-io/cdpproxy' + fetch-depth: 0 + + - name: build proxy + run: go build + + - name: upload artifact + uses: actions/upload-artifact@v7 + with: + name: cdpproxy + path: cdpproxy + retention-days: 1 + demo-runner: strategy: fail-fast: false @@ -73,7 +92,9 @@ jobs: robotstxt: [true, false] name: demo-runner - needs: zig-build-release + needs: + - zig-build-release + - cdpproxy runs-on: ubuntu-latest timeout-minutes: 15 @@ -86,19 +107,28 @@ jobs: - run: npm install - - name: download artifact + - name: download lightpanda release uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda + - name: download cdpproxy + uses: actions/download-artifact@v8 + with: + name: cdpproxy + + - run: chmod a+x ./cdpproxy + + - run: ./cdpproxy > cdp.log & + - if: matrix.proxy == true name: build and start proxy run: | cd proxy go build - ./proxy & echo $! > PROXY.id + ./proxy & - if: matrix.cache == true run: mkdir /tmp/lp-cache @@ -120,17 +150,18 @@ jobs: echo "value=$args" >> "$GITHUB_OUTPUT" - run: | - ./lightpanda serve ${{ steps.args.outputs.value }} & echo $! > LPD.pid + ./lightpanda serve --port 9223 ${{ steps.args.outputs.value }} & - run: | go run runner/main.go - - run: | - kill `cat LPD.pid` - - - if: matrix.proxy == true - run: | - pkill proxy + - name: upload cdp log + uses: actions/upload-artifact@v7 + if: always() + with: + name: cdp-log-demo-runner-${{ matrix.proxy }}-${{ matrix.wba }}-${{ matrix.cache }}-${{ matrix.robotstxt }} + path: cdp.log + retention-days: 1 proxy-auth: strategy: @@ -140,8 +171,10 @@ jobs: cache: [true, false] robotstxt: [true, false] - name: demo-runner - needs: zig-build-release + name: proxy-auth + needs: + - zig-build-release + - cdpproxy runs-on: ubuntu-latest timeout-minutes: 15 @@ -154,18 +187,27 @@ jobs: - run: npm install - - name: download artifact + - name: download lightpanda release uses: actions/download-artifact@v8 with: name: lightpanda-build-release - run: chmod a+x ./lightpanda + - name: download cdpproxy + uses: actions/download-artifact@v8 + with: + name: cdpproxy + + - run: chmod a+x ./cdpproxy + + - run: ./cdpproxy > cdp.log & + - name: build and start proxy run: | cd proxy go build - ./proxy & echo $! > PROXY.id + ./proxy & - if: matrix.cache == true run: mkdir /tmp/lp-cache @@ -188,19 +230,23 @@ jobs: - name: run end to end tests through proxy run: | export PROXY_USERNAME=username PROXY_PASSWORD=password - ./lightpanda serve --http-proxy http://127.0.0.1:3000 ${{ steps.args.outputs.value }} & echo $! > LPD.pid + ./lightpanda serve --port 9223 --http-proxy http://127.0.0.1:3000 ${{ steps.args.outputs.value }} & go run runner/main.go URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js - kill `cat LPD.pid` - name: run request interception through proxy and playwright run: | export PROXY_USERNAME=username PROXY_PASSWORD=password - ./lightpanda serve ${{ steps.args.outputs.value }} & echo $! > LPD.pid + ./lightpanda serve --port 9223 ${{ steps.args.outputs.value }} & BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js - kill `cat LPD.pid` - - run: pkill proxy + - name: upload cdp log + uses: actions/upload-artifact@v7 + if: always() + with: + name: cdp-log-proxy-auth-${{ matrix.wba }}-${{ matrix.cache }}-${{ matrix.robotstxt }} + path: cdp.log + retention-days: 1 wba-test: name: wba-test diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 929cd07c..927d2f05 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -96,6 +96,12 @@ frame: Frame, // to the original page like this. popups: std.ArrayList(*Frame) = .empty, +// Popups that have called window.close() but whose teardown is deferred to +// Page.deinit. We can't deinit synchronously from window.close() because +// that's invoked from JS still running on top of the Frame's V8 context (or +// from a script eval whose parser still holds the Frame). +queued_close: std.ArrayList(*Frame) = .empty, + // Initialize a Page and its root Frame. pub fn init(self: *Page, session: *Session, frame_id: u32) !void { const frame_arena = try session.arena_pool.acquire(.large, "Page.frame_arena"); @@ -115,6 +121,11 @@ pub fn init(self: *Page, session: *Session, frame_id: u32) !void { // Tear down the Page and its root Frame. Equivalent to the old // Session.removePage + Session.resetFrameResources. pub fn deinit(self: *Page, abort_http: bool) void { + for (self.queued_close.items) |popup| { + popup.deinit(abort_http); + } + self.queued_close = .empty; + for (self.popups.items) |popup| { popup.deinit(abort_http); } diff --git a/src/browser/tests/element/html/contenteditable.html b/src/browser/tests/element/html/contenteditable.html new file mode 100644 index 00000000..eca1ec2b --- /dev/null +++ b/src/browser/tests/element/html/contenteditable.html @@ -0,0 +1,60 @@ + + + + + +
own true
+
own empty
+
own false
+
own plaintext-only
+ +
+ child +
+ +
+
+ re-hosted +
+
+ +
no editing context
+ + + + diff --git a/src/browser/tests/window/open.html b/src/browser/tests/window/open.html index 4bc0b72d..864339ac 100644 --- a/src/browser/tests/window/open.html +++ b/src/browser/tests/window/open.html @@ -107,3 +107,27 @@ testing.expectEqual(null, window.opener); } + + diff --git a/src/browser/tests/window/support/popup_self_close.html b/src/browser/tests/window/support/popup_self_close.html new file mode 100644 index 00000000..6b39da4a --- /dev/null +++ b/src/browser/tests/window/support/popup_self_close.html @@ -0,0 +1,12 @@ + + diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 0945dc7f..570c5a3e 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -568,7 +568,14 @@ pub fn close(self: *Window) void { } } - frame.deinit(true); + // We can't tear the Frame down here — close() is invoked from JS still + // running on top of this Frame's V8 context, often deep inside a script + // eval whose parser is still holding the Frame. Destroying the context + // now leaves dangling pointers in the unwinding script eval (load event + // dispatch, runMacrotasks, etc.). Defer to Page.deinit instead. + page.queued_close.append(page.frame_arena, frame) catch |err| { + log.err(.frame, "queue popup close", .{ .err = err }); + }; } pub fn postMessage(self: *Window, message: js.Value.Temp, target_origin: ?[]const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 5853fd14..3ed96f57 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -382,6 +382,31 @@ pub fn setTitle(self: *HtmlElement, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("title"), .wrap(value), frame); } +// HTML §7.7.5.2 specifies the IDL attribute as true iff the element's effective +// content editable state is "true" or "plaintext-only". Lightpanda has no +// caret/keyboard editing pipeline, so a true answer cannot be honored +// end-to-end — downstream CDP tools (notably Puppeteer's dispatchKeyEvent +// path) would route into an input pipeline that silently no-ops. Always +// return false, and log .not_implemented when the spec would have said true +// so usage surfaces in telemetry rather than silently depending on an +// unsupported value. Spec walk per HTML §7.7.5.2 still applies — the nearest +// ancestor with `contenteditable` wins; "false" disables. See PR #2310 for +// the routing-vs-fail-loud discussion. +// +// "contenteditable" is 15 bytes — past the comptime SSO limit — so the +// String wrap runs at runtime, mirroring the pattern in interactive.zig. +pub fn getIsContentEditable(self: *HtmlElement) bool { + var current: ?*Element = self.asElement(); + while (current) |el| : (current = el.parentElement()) { + const raw = el.getAttributeSafe(.wrap("contenteditable")) orelse continue; + if (!std.ascii.eqlIgnoreCase(raw, "false")) { + log.info(.not_implemented, "IsContentEditable", .{}); + } + break; + } + return false; +} + pub fn getAttributeFunction( self: *HtmlElement, listener_type: GlobalEventHandler, @@ -1220,6 +1245,7 @@ pub const JsApi = struct { pub const dir = bridge.accessor(HtmlElement.getDir, HtmlElement.setDir, .{}); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{}); + pub const isContentEditable = bridge.accessor(HtmlElement.getIsContentEditable, null, .{}); pub const lang = bridge.accessor(HtmlElement.getLang, HtmlElement.setLang, .{}); pub const tabIndex = bridge.accessor(HtmlElement.getTabIndex, HtmlElement.setTabIndex, .{}); pub const title = bridge.accessor(HtmlElement.getTitle, HtmlElement.setTitle, .{}); @@ -1357,3 +1383,6 @@ test "WebApi: HTML.event_listeners" { test "WebApi: HTMLElement.props" { try testing.htmlRunner("element/html/htmlelement-props.html", .{}); } +test "WebApi: HTMLElement.contenteditable" { + try testing.htmlRunner("element/html/contenteditable.html", .{}); +}