mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 01:25:53 -04:00
Merge remote-tracking branch 'origin/main' into fix-a21-disabled-inheritance
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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), .{});
|
||||
}
|
||||
},
|
||||
|
||||
1
src/browser/tests/cdp/dialog.html
Normal file
1
src/browser/tests/cdp/dialog.html
Normal file
@@ -0,0 +1 @@
|
||||
<p>dialog test page</p>
|
||||
109
src/browser/tests/element/html/input_image_submit.html
Normal file
109
src/browser/tests/element/html/input_image_submit.html
Normal 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>
|
||||
@@ -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, .{});
|
||||
|
||||
|
||||
@@ -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", .{});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user