Merge remote-tracking branch 'origin/main' into fix-a21-disabled-inheritance

This commit is contained in:
Navid EMAD
2026-04-29 05:42:27 +02:00
9 changed files with 327 additions and 3 deletions

View File

@@ -202,6 +202,20 @@ pub const JavascriptDialogOpening = struct {
url: [:0]const u8,
message: []const u8,
dialog_type: []const u8,
// Output param. The CDP listener may set this from a pre-armed response
// queued by Page.handleJavaScriptDialog. The dispatcher (alert/confirm/
// prompt in Window.zig) reads it back to decide what to return to JS.
// Headless mode auto-dismisses if no listener fills it in: confirm→false,
// prompt→null, alert→void (default-zero DialogResponse).
response: *DialogResponse,
};
pub const DialogResponse = struct {
accept: bool = false,
// Set when the CDP client sent a `promptText` with `accept: true`. Memory
// is owned by whoever filled in the response (typically the BrowserContext
// arena) and must outlive a single dispatch call.
prompt_text: ?[]const u8 = null,
};
pub fn init(allocator: Allocator) !*Notification {

View File

@@ -3648,7 +3648,11 @@ pub fn handleClick(self: *Frame, target: *Node) !void {
},
.input => |input| {
try element.focus(self);
if (input._input_type == .submit) {
// Per HTML §4.10.18.6.4 "Image Button state (type=image)", clicking an
// image button submits its form. The form-data set already gets the
// submitter's coordinate fields appended via FormData.collectForm
// (see src/browser/webapi/net/FormData.zig).
if (input._input_type == .submit or input._input_type == .image) {
return self.submitForm(element, input.getForm(self), .{});
}
},

View File

@@ -0,0 +1 @@
<p>dialog test page</p>

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<script src="../../testing.js"></script>
<form id="img-form" action="/should-not-navigate-img" method="get">
<input type="hidden" name="hidden_field" value="hv">
<input id="img-named" type="image" name="img_btn" src="x.png">
</form>
<form id="img-form-unnamed" action="/should-not-navigate-img2" method="get">
<input type="hidden" name="hidden_field" value="hv2">
<input id="img-unnamed" type="image" src="x.png">
</form>
<form id="img-form-disabled" action="/should-not-navigate-img3" method="get">
<input id="img-disabled" type="image" name="d" src="x.png" disabled>
</form>
<form id="img-form-prevent" action="/should-not-navigate-img4" method="get">
<input id="img-prevent" type="image" name="p" src="x.png">
</form>
<form id="img-form-orphan" action="/should-not-navigate-img5" method="get">
<input type="hidden" name="orphan_field" value="ov">
</form>
<input id="img-orphan" type="image" form="img-form-orphan" name="o" src="x.png">
<script id="image_click_fires_submit_event">
{
const form = document.getElementById('img-form');
let submitFired = false;
let submitter = null;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitFired = true;
submitter = e.submitter;
});
document.getElementById('img-named').click();
testing.expectEqual(true, submitFired);
testing.expectEqual(document.getElementById('img-named'), submitter);
}
</script>
<script id="image_submitter_appends_named_coordinate_fields">
{
// The form-data set algorithm appends "name.x" / "name.y" coordinate
// entries when the submitter is an image button with a name attribute.
// Constructing FormData directly with the submitter exercises the same
// path the click handler reaches via Frame.submitForm.
const form = document.getElementById('img-form');
const fd = new FormData(form, document.getElementById('img-named'));
testing.expectEqual('hv', fd.get('hidden_field'));
testing.expectEqual('0', fd.get('img_btn.x'));
testing.expectEqual('0', fd.get('img_btn.y'));
}
</script>
<script id="unnamed_image_submitter_appends_bare_xy_fields">
{
const form = document.getElementById('img-form-unnamed');
const fd = new FormData(form, document.getElementById('img-unnamed'));
// An unnamed image submitter contributes bare "x" and "y" entries.
testing.expectEqual('0', fd.get('x'));
testing.expectEqual('0', fd.get('y'));
testing.expectEqual('hv2', fd.get('hidden_field'));
}
</script>
<script id="disabled_image_click_does_not_submit">
{
const form = document.getElementById('img-form-disabled');
let submitFired = false;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitFired = true;
});
document.getElementById('img-disabled').click();
testing.expectEqual(false, submitFired);
}
</script>
<script id="image_click_preventDefault_blocks_navigation">
{
const form = document.getElementById('img-form-prevent');
let submitFired = false;
form.addEventListener('submit', (e) => {
submitFired = true;
e.preventDefault();
});
document.getElementById('img-prevent').click();
// submit event fired, but preventDefault stops the actual navigation.
testing.expectEqual(true, submitFired);
}
</script>
<script id="image_with_form_attribute_submits_referenced_form">
{
const form = document.getElementById('img-form-orphan');
let submitFired = false;
let submitter = null;
form.addEventListener('submit', (e) => {
e.preventDefault();
submitFired = true;
submitter = e.submitter;
});
document.getElementById('img-orphan').click();
testing.expectEqual(true, submitFired);
testing.expectEqual(document.getElementById('img-orphan'), submitter);
}
</script>

View File

@@ -44,6 +44,7 @@ const Element = @import("Element.zig");
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const CustomElementRegistry = @import("CustomElementRegistry.zig");
const Selection = @import("Selection.zig");
const Notification = @import("../../Notification.zig");
const log = lp.log;
const IS_DEBUG = builtin.mode == .Debug;
@@ -918,31 +919,42 @@ pub const JsApi = struct {
pub const alert = bridge.function(struct {
fn alert(_: *const Window, message: ?[]const u8, frame: *Frame) void {
var response: Notification.DialogResponse = .{};
frame._session.notification.dispatch(.javascript_dialog_opening, &.{
.url = frame.url,
.message = message orelse "",
.dialog_type = "alert",
.response = &response,
});
// Return value is void; we still pop a pre-armed response so the
// CDP client's pre-arm doesn't leak across to the next dialog.
}
}.alert, .{});
pub const confirm = bridge.function(struct {
fn confirm(_: *const Window, message: ?[]const u8, frame: *Frame) bool {
var response: Notification.DialogResponse = .{};
frame._session.notification.dispatch(.javascript_dialog_opening, &.{
.url = frame.url,
.message = message orelse "",
.dialog_type = "confirm",
.response = &response,
});
return false;
return response.accept;
}
}.confirm, .{});
pub const prompt = bridge.function(struct {
fn prompt(_: *const Window, message: ?[]const u8, _: ?[]const u8, frame: *Frame) ?[]const u8 {
var response: Notification.DialogResponse = .{};
frame._session.notification.dispatch(.javascript_dialog_opening, &.{
.url = frame.url,
.message = message orelse "",
.dialog_type = "prompt",
.response = &response,
});
return null;
if (!response.accept) return null;
// promptText omitted with accept=true is "" per CDP spec
// (https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-handleJavaScriptDialog).
return response.prompt_text orelse "";
}
}.prompt, .{});

View File

@@ -1114,6 +1114,7 @@ const testing = @import("../../../../testing.zig");
test "WebApi: HTML.Input" {
try testing.htmlRunner("element/html/input.html", .{});
try testing.htmlRunner("element/html/input_click.html", .{});
try testing.htmlRunner("element/html/input_image_submit.html", .{});
try testing.htmlRunner("element/html/input_radio.html", .{});
try testing.htmlRunner("element/html/input-attrs.html", .{});
}

View File

@@ -386,6 +386,13 @@ pub const BrowserContext = struct {
notification: *Notification,
// Pre-armed response for the next JS dialog (alert/confirm/prompt).
// Set by Page.handleJavaScriptDialog; consumed (and cleared) when the
// next javascript_dialog_opening notification is dispatched. Strings
// are duplicated into self.arena so they outlive the CDP command's
// own message arena.
pending_dialog_response: ?Notification.DialogResponse = null,
fn init(self: *BrowserContext, id: []const u8, cdp: *CDP) !void {
const allocator = cdp.allocator;

View File

@@ -41,6 +41,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
fillNode,
scrollNode,
waitForSelector,
handleJavaScriptDialog,
}, cmd.input.action) orelse return error.UnknownMethod;
switch (action) {
@@ -54,6 +55,7 @@ pub fn processMessage(cmd: *CDP.Command) !void {
.fillNode => return fillNode(cmd),
.scrollNode => return scrollNode(cmd),
.waitForSelector => return waitForSelector(cmd),
.handleJavaScriptDialog => return handleJavaScriptDialog(cmd),
}
}
@@ -293,6 +295,53 @@ fn waitForSelector(cmd: anytype) !void {
}, .{});
}
// Lightpanda-namespaced pre-arm for window.alert/confirm/prompt return values.
//
// Standard CDP drivers send Page.handleJavaScriptDialog *reactively* in
// response to a Page.javascriptDialogOpening event — the dialog suspends
// JS, the client picks accept/dismiss, the runtime resumes. Lightpanda's
// dialogs auto-dismiss in headless mode (window.alert/confirm/prompt
// return immediately rather than blocking V8), so by the time the event
// reaches the client, JS has already returned. A reactive call has
// nothing left to influence — full Chrome-faithful behavior would
// require V8 suspension, which #2082 / PR #2085 deferred.
//
// LP.handleJavaScriptDialog gives Lightpanda-aware clients a *proactive*
// opt-in: the client sets {accept, promptText} *before* triggering the JS
// that opens the dialog. The handler stashes the response on the
// BrowserContext; when the next dialog dispatches the
// `javascript_dialog_opening` notification, the listener in page.zig
// pops the stash and fills it into the dispatch's response output param.
// window.alert/confirm/prompt then return that value.
//
// Page.handleJavaScriptDialog continues to return -32000 "No dialog is
// showing" so reactive Chrome-style drivers see no semantic change.
//
// Without a pre-armed response, behavior is unchanged from PR #2085:
// confirm→false, prompt→null, alert→void.
fn handleJavaScriptDialog(cmd: anytype) !void {
const params = (try cmd.params(struct {
accept: bool,
promptText: ?[]const u8 = null,
})) orelse return error.InvalidParam;
const bc = cmd.browser_context orelse return error.NoBrowserContext;
// Duplicate promptText into the BrowserContext arena so it outlives the
// CDP command's own message arena (the dialog may fire on a later command).
const prompt_text: ?[]const u8 = if (params.promptText) |t|
try bc.arena.dupe(u8, t)
else
null;
bc.pending_dialog_response = .{
.accept = params.accept,
.prompt_text = prompt_text,
};
return cmd.sendResult(null, .{});
}
const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" {
var ctx = try testing.context();
@@ -441,3 +490,117 @@ test "cdp.lp: waitForSelector" {
const err_obj = (try ctx.getSentMessage(2)).?.object.get("error").?.object;
try testing.expect(err_obj.get("code") != null);
}
test "cdp.lp: handleJavaScriptDialog accepts/dismisses without an open dialog" {
var ctx = try testing.context();
defer ctx.deinit();
{
// Without a BrowserContext: error (matches other LP handlers' shape).
try ctx.processMessage(.{ .id = 1, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentError(-31998, "NoBrowserContext", .{ .id = 1 });
}
_ = try ctx.loadBrowserContext(.{ .id = "BID-D1", .url = "cdp/dialog.html", .target_id = "FID-000000000X".* });
{
// Pre-arming with accept=true succeeds. Headless browsers auto-dismiss,
// so the CDP client sends LP.handleJavaScriptDialog *before* the JS
// that opens the dialog — handler stashes the response on the
// BrowserContext.
try ctx.processMessage(.{ .id = 2, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 2 });
}
{
// Pre-arming with accept=false also succeeds.
try ctx.processMessage(.{ .id = 3, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = false } });
try ctx.expectSentResult(null, .{ .id = 3 });
}
{
// Pre-arming with a promptText also succeeds. The string is dup'd into
// the BrowserContext arena so it survives until the dialog dispatches.
try ctx.processMessage(.{ .id = 4, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true, .promptText = "hello" } });
try ctx.expectSentResult(null, .{ .id = 4 });
}
}
test "cdp.lp: handleJavaScriptDialog controls confirm/prompt/alert return values" {
var ctx = try testing.context();
defer ctx.deinit();
var bc = try ctx.loadBrowserContext(.{ .id = "BID-D2", .url = "cdp/dialog.html", .target_id = "FID-000000000X".* });
const frame = bc.session.currentFrame() orelse unreachable;
var ls: lp.js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
// ---- confirm: accept=true makes confirm() return true ----
try ctx.processMessage(.{ .id = 1, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 1 });
const c_accept = try ls.local.exec("confirm('proceed?')", null);
try testing.expectEqual(true, c_accept.toBool());
try ctx.expectSentEvent("Page.javascriptDialogOpening", .{
.message = "proceed?",
.type = "confirm",
.hasBrowserHandler = false,
.defaultPrompt = "",
}, .{ .session_id = "SID-X" });
// ---- confirm: accept=false makes confirm() return false ----
try ctx.processMessage(.{ .id = 2, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = false } });
try ctx.expectSentResult(null, .{ .id = 2 });
const c_dismiss = try ls.local.exec("confirm('again?')", null);
try testing.expectEqual(false, c_dismiss.toBool());
// ---- confirm: no pre-arm preserves PR #2085 default (false) ----
const c_default = try ls.local.exec("confirm('default?')", null);
try testing.expectEqual(false, c_default.toBool());
// ---- prompt: accept=true with promptText returns the text ----
try ctx.processMessage(.{ .id = 3, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true, .promptText = "hello" } });
try ctx.expectSentResult(null, .{ .id = 3 });
const p_text = try ls.local.exec("prompt('name?')", null);
const p_text_str = try p_text.toStringSlice();
try testing.expectEqualSlices(u8, "hello", p_text_str);
// ---- prompt: accept=true without promptText returns "" per CDP spec ----
try ctx.processMessage(.{ .id = 4, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 4 });
const p_empty = try ls.local.exec("prompt('name?')", null);
const p_empty_str = try p_empty.toStringSlice();
try testing.expectEqualSlices(u8, "", p_empty_str);
// ---- prompt: accept=false makes prompt() return null ----
try ctx.processMessage(.{ .id = 5, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = false } });
try ctx.expectSentResult(null, .{ .id = 5 });
const p_dismiss = try ls.local.exec("prompt('cancel?')", null);
try testing.expect(p_dismiss.isNull());
// ---- prompt: no pre-arm preserves PR #2085 default (null) ----
const p_default = try ls.local.exec("prompt('default?')", null);
try testing.expect(p_default.isNull());
// ---- alert: dispatches the event but has no return value to override ----
try ctx.processMessage(.{ .id = 6, .method = "LP.handleJavaScriptDialog", .params = .{ .accept = true } });
try ctx.expectSentResult(null, .{ .id = 6 });
_ = try ls.local.exec("alert('important')", null);
try ctx.expectSentEvent("Page.javascriptDialogOpening", .{
.message = "important",
.type = "alert",
}, .{ .session_id = "SID-X" });
// ---- pending response is consumed by exactly one dialog ----
// After the alert above pops the pre-arm, the next confirm sees no pending
// and falls back to the default (false) — the alert MUST clear pending so
// the response doesn't leak across dialogs.
const c_after_alert = try ls.local.exec("confirm('leak?')", null);
try testing.expectEqual(false, c_after_alert.toBool());
}

View File

@@ -693,6 +693,10 @@ fn handleJavaScriptDialog(cmd: *CDP.Command) !void {
// Dialogs auto-dismiss in headless mode. By the time the CDP client
// sends this command, the dialog has already returned and there is
// no pending dialog to accept or dismiss.
//
// Lightpanda-aware clients that want to control confirm/prompt return
// values can pre-arm a response via LP.handleJavaScriptDialog instead
// (see src/cdp/domains/lp.zig).
_ = try cmd.params(struct {
accept: bool,
promptText: ?[]const u8 = null,
@@ -702,6 +706,15 @@ fn handleJavaScriptDialog(cmd: *CDP.Command) !void {
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-javascriptDialogOpening
pub fn javascriptDialogOpening(bc: anytype, event: *const Notification.JavascriptDialogOpening) !void {
// Pop any response pre-armed via LP.handleJavaScriptDialog onto the
// dispatch's output param so the calling alert/confirm/prompt returns
// the CDP client's choice. Cleared unconditionally — a stash applies
// to exactly one dialog.
if (bc.pending_dialog_response) |pending| {
event.response.* = pending;
bc.pending_dialog_response = null;
}
const session_id = bc.session_id orelse return;
var cdp = bc.cdp;