Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-04-30 20:42:23 +02:00
7 changed files with 209 additions and 20 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<!--
Lightpanda has no caret/keyboard editing pipeline, so isContentEditable
always returns false regardless of the element's effective contenteditable
state. The spec walk still runs internally to log .not_implemented when the
spec would have said true; only the return value is asserted here. See
Html.zig:getIsContentEditable and PR #2310 for context.
-->
<div id="own-true" contenteditable="true">own true</div>
<div id="own-empty" contenteditable="">own empty</div>
<div id="own-false" contenteditable="false">own false</div>
<div id="own-plaintext" contenteditable="plaintext-only">own plaintext-only</div>
<div id="ancestor-true" contenteditable="true">
<span id="child-of-true">child</span>
</div>
<div id="ancestor-false" contenteditable="false">
<div contenteditable="true">
<span id="grandchild-rehosted">re-hosted</span>
</div>
</div>
<div id="plain"><span id="child-of-plain">no editing context</span></div>
<script id="always-false">
{
testing.expectEqual(false, document.getElementById('own-true').isContentEditable);
testing.expectEqual(false, document.getElementById('own-empty').isContentEditable);
testing.expectEqual(false, document.getElementById('own-false').isContentEditable);
testing.expectEqual(false, document.getElementById('own-plaintext').isContentEditable);
testing.expectEqual(false, document.getElementById('ancestor-true').isContentEditable);
testing.expectEqual(false, document.getElementById('child-of-true').isContentEditable);
testing.expectEqual(false, document.getElementById('ancestor-false').isContentEditable);
testing.expectEqual(false, document.getElementById('grandchild-rehosted').isContentEditable);
testing.expectEqual(false, document.getElementById('plain').isContentEditable);
testing.expectEqual(false, document.getElementById('child-of-plain').isContentEditable);
testing.expectEqual(false, document.body.isContentEditable);
testing.expectEqual(false, document.documentElement.isContentEditable);
}
</script>
<script id="dynamic-attribute">
{
const el = document.createElement('div');
testing.expectEqual(false, el.isContentEditable);
el.setAttribute('contenteditable', 'true');
testing.expectEqual(false, el.isContentEditable);
el.setAttribute('contenteditable', 'false');
testing.expectEqual(false, el.isContentEditable);
el.removeAttribute('contenteditable');
testing.expectEqual(false, el.isContentEditable);
}
</script>

View File

@@ -107,3 +107,27 @@
testing.expectEqual(null, window.opener);
}
</script>
<script id=popup_self_close type=module>
{
// Popup navigates to a real URL (not about:blank, which takes a synchronous
// shortcut) and calls window.close() from inside its own running script.
// Regression: synchronous frame teardown here used to destroy the popup's
// V8 context mid-eval, panicking on the deferred load-event dispatch.
// Receiving the postMessage proves the popup's script ran to completion
// (including the close call) without crashing.
const state = await testing.async();
window.addEventListener('message', (e) => {
if (e.data && e.data.from === 'popup_self_close') {
state.resolve(e.data);
}
}, { once: true });
const w = window.open(testing.BASE_URL + 'window/support/popup_self_close.html');
testing.expectTrue(w != null);
await state.done((data) => {
testing.expectEqual('popup_self_close', data.from);
});
}
</script>

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<script>
// Post a message back to the opener, then close ourselves from inside the
// popup's own running script. This exercises the deferred-close path:
// close() is invoked while the popup's V8 context is still entered and the
// parser is still on the stack. A synchronous teardown here would destroy
// the context and crash the eval's deferred load-event dispatch.
if (window.opener) {
window.opener.postMessage({ from: 'popup_self_close' }, '*');
}
window.close();
</script>

View File

@@ -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 {

View File

@@ -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", .{});
}