mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-06-11 09:35:59 -04:00
Merge pull request #2487 from navidemad/feat/external-stylesheets-flag
Add --enable-external-stylesheets flag with fetch + parse
This commit is contained in:
@@ -100,6 +100,7 @@ const CommonOptions = .{
|
||||
.{ .name = "storage_sqlite_path", .type = ?[:0]const u8 },
|
||||
.{ .name = "disable_subframes", .type = bool },
|
||||
.{ .name = "disable_workers", .type = bool },
|
||||
.{ .name = "enable_external_stylesheets", .type = bool },
|
||||
};
|
||||
|
||||
fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat {
|
||||
@@ -255,6 +256,13 @@ pub fn disableWorkers(self: *const Config) bool {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn enableExternalStylesheets(self: *const Config) bool {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.enable_external_stylesheets,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.http_proxy,
|
||||
|
||||
@@ -52,6 +52,7 @@ const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
const MutationObserver = @import("webapi/MutationObserver.zig");
|
||||
const IntersectionObserver = @import("webapi/IntersectionObserver.zig");
|
||||
const Worker = @import("webapi/Worker.zig");
|
||||
const CSSStyleSheet = @import("webapi/css/CSSStyleSheet.zig");
|
||||
const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig");
|
||||
const PageTransitionEvent = @import("webapi/event/PageTransitionEvent.zig");
|
||||
const SubmitEvent = @import("webapi/event/SubmitEvent.zig");
|
||||
@@ -1712,6 +1713,131 @@ pub fn queueLoad(self: *Frame, html: *Element.Html) !void {
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap on a single external stylesheet body. CSS rule storage is per-
|
||||
// arena so a hostile sheet could otherwise inflate page memory; 2 MiB is
|
||||
// well above anything seen on real sites (Tailwind's `preflight + utilities`
|
||||
// build is ~400 KiB gzipped, ~3 MiB raw — at which point a site should be
|
||||
// splitting by route anyway).
|
||||
const MAX_STYLESHEET_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
// Synchronously fetch and parse an external `<link rel=stylesheet>`.
|
||||
// href is passed in as an optimization since the [currently] only callsite has
|
||||
// it, so why look it up again?
|
||||
pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []const u8) !void {
|
||||
if (self.isGoingAway() or href.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = self._session;
|
||||
|
||||
// this feature is disabled by default, and can be turned on via a command
|
||||
// line flag or via an CDP command
|
||||
if (session.load_external_stylesheets == false) {
|
||||
return self.queueLoad(link._proto);
|
||||
}
|
||||
|
||||
// Fragment-parsed links (innerHTML, DOMParser, ...) may not be attached.
|
||||
// TODO: this isn't correct in all cases. If the link is added into an
|
||||
// attached node, I think we SHOULD load it.
|
||||
if (self._parse_mode == .fragment) {
|
||||
return;
|
||||
}
|
||||
const element = link.asElement();
|
||||
|
||||
const arena = try session.getArena(.medium, "Frame.loadExternalStylesheet");
|
||||
defer session.releaseArena(arena);
|
||||
|
||||
const resolved = URL.resolve(arena, self.base(), href, .{ .encoding = self.charset }) catch |err| {
|
||||
log.warn(.http, "external stylesheet resolve", .{ .err = err, .href = href });
|
||||
try self.fireElementEvent(element, comptime .wrap("error"));
|
||||
return;
|
||||
};
|
||||
|
||||
const http_client = &session.browser.http_client;
|
||||
var headers = try http_client.newHeaders();
|
||||
try headers.add("Accept: text/css,*/*;q=0.1");
|
||||
try self.headersForRequest(&headers);
|
||||
|
||||
// Set the script-manager `is_evaluating` flag for the same reason
|
||||
// `ScriptManager.addFromElement` does: `syncRequest` pumps the CDP
|
||||
// socket inline, so a `Target.closeTarget` / `Page.close` arriving
|
||||
// mid-fetch would otherwise drive `Session.removePage` while this
|
||||
// function still holds pointers to `self`. The check in
|
||||
// `Session.removePage` (Session.zig:253) consults
|
||||
// `frame.anyScriptEvaluating()`, which only sees this flag.
|
||||
const sm = &self._script_manager.base;
|
||||
const was_evaluating = sm.is_evaluating;
|
||||
sm.is_evaluating = true;
|
||||
defer sm.is_evaluating = was_evaluating;
|
||||
|
||||
var response = http_client.syncRequest(arena, .{
|
||||
.url = resolved,
|
||||
.method = .GET,
|
||||
.frame_id = self._frame_id,
|
||||
.loader_id = self._loader_id,
|
||||
.headers = headers,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.cookie_origin = self.url,
|
||||
.resource_type = .stylesheet,
|
||||
.notification = session.notification,
|
||||
}) catch |err| {
|
||||
log.warn(.http, "external stylesheet fetch", .{ .err = err, .url = resolved });
|
||||
return self.fireElementEvent(element, comptime .wrap("error"));
|
||||
};
|
||||
defer response.deinit(arena);
|
||||
|
||||
if (response.status < 200 or response.status >= 300) {
|
||||
log.info(.http, "external stylesheet status", .{ .status = response.status, .url = resolved });
|
||||
return self.fireElementEvent(element, comptime .wrap("error"));
|
||||
}
|
||||
|
||||
if (response.body.items.len > MAX_STYLESHEET_BYTES) {
|
||||
log.warn(.http, "external stylesheet too large", .{
|
||||
.bytes = response.body.items.len,
|
||||
.max = MAX_STYLESHEET_BYTES,
|
||||
.url = resolved,
|
||||
});
|
||||
return self.fireElementEvent(element, comptime .wrap("error"));
|
||||
}
|
||||
|
||||
// Reuse the cached sheet on re-fetch (href mutation on a connected
|
||||
// link) so `document.styleSheets` keeps a single entry per <link>
|
||||
// instead of accumulating one per href change. On first load, create
|
||||
// and register; on subsequent loads, replace content in place.
|
||||
//
|
||||
// First-load creation assigns `link._sheet` AFTER `sheets.add`
|
||||
// succeeds so an OOM during registration doesn't cache an unregistered
|
||||
// sheet (which would short-circuit every future re-fetch via the
|
||||
// `orelse` branch, leaving the sheet permanently unreachable through
|
||||
// `document.styleSheets`).
|
||||
const sheet = link._sheet orelse blk: {
|
||||
const new_sheet = try CSSStyleSheet.initWithOwner(element, self);
|
||||
const sheets = try self.document.getStyleSheets(self);
|
||||
try sheets.add(new_sheet, self);
|
||||
link._sheet = new_sheet;
|
||||
break :blk new_sheet;
|
||||
};
|
||||
|
||||
// Parse first, only swap `_href` on success. `replaceSync` itself is
|
||||
// not atomic (clears rules before the insert loop), so a mid-parse
|
||||
// OOM still drops the old rules — full atomicity would require a
|
||||
// scratch-list pattern in `CSSStyleSheet.replaceSync`. Keeping
|
||||
// `_href` consistent with what the sheet actually contains is the
|
||||
// minimum.
|
||||
sheet.replaceSync(response.body.items, self) catch |err| {
|
||||
log.warn(.http, "external stylesheet parse", .{ .err = err, .url = resolved });
|
||||
return self.fireElementEvent(element, comptime .wrap("error"));
|
||||
};
|
||||
sheet._href = try self.arena.dupe(u8, resolved);
|
||||
|
||||
try self.fireElementEvent(element, comptime .wrap("load"));
|
||||
}
|
||||
|
||||
fn fireElementEvent(self: *Frame, el: *Element, name: String) !void {
|
||||
const event = try Event.initTrusted(name, .{}, self._page);
|
||||
try self._event_manager.dispatch(el.asEventTarget(), event);
|
||||
}
|
||||
|
||||
fn dispatchLoad(self: *Frame) !void {
|
||||
const has_dom_load_listener = self._event_manager.has_dom_load_listener;
|
||||
|
||||
@@ -1724,8 +1850,7 @@ fn dispatchLoad(self: *Frame) !void {
|
||||
|
||||
for (to_process.items) |html_element| {
|
||||
if (has_dom_load_listener or html_element.hasAttributeFunction(.onload, self)) {
|
||||
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self._page);
|
||||
try self._event_manager.dispatch(html_element.asEventTarget(), event);
|
||||
try self.fireElementEvent(html_element.asElement(), comptime .wrap("load"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3045,6 +3170,20 @@ pub fn removeNode(self: *Frame, parent: *Node, child: *Node, opts: RemoveNodeOpt
|
||||
style._sheet = null;
|
||||
}
|
||||
self._style_manager.sheetModified();
|
||||
} else if (el.is(Element.Html.Link)) |link| {
|
||||
// External stylesheet links registered via Frame.loadExternalStylesheet
|
||||
// must be symmetrically deregistered on disconnect, or
|
||||
// `document.styleSheets` accumulates phantom entries and the
|
||||
// visibility cascade keeps honoring rules from removed links —
|
||||
// exactly the SPA theme-switch pattern (append new sheet,
|
||||
// remove old) the feature exists to serve.
|
||||
if (link._sheet) |sheet| {
|
||||
if (self.document._style_sheets) |sheets| {
|
||||
sheets.remove(sheet);
|
||||
}
|
||||
link._sheet = null;
|
||||
self._style_manager.sheetModified();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -977,6 +977,7 @@ pub const Request = struct {
|
||||
xhr,
|
||||
script,
|
||||
fetch,
|
||||
stylesheet,
|
||||
|
||||
// Allowed Values: Document, Stylesheet, Image, Media, Font, Script,
|
||||
// TextTrack, XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest,
|
||||
@@ -988,6 +989,7 @@ pub const Request = struct {
|
||||
.xhr => "XHR",
|
||||
.script => "Script",
|
||||
.fetch => "Fetch",
|
||||
.stylesheet => "Stylesheet",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,6 +91,15 @@ subframe_loading_enabled: bool = true,
|
||||
// session init; the LP.configureLoading CDP method can flip it per-session.
|
||||
worker_loading_enabled: bool = true,
|
||||
|
||||
// Opt-in fetch of external <link rel=stylesheet> resources. Defaults to
|
||||
// false to preserve the current rendering-free fast path: drivers that
|
||||
// don't need accurate visibility checks pay nothing. Set from the
|
||||
// `--enable-external-stylesheets` CLI flag at session init; the
|
||||
// LP.configureLoading CDP method can flip it per-session. When true,
|
||||
// `Link.linkAddedCallback` routes to `Frame.loadExternalStylesheet`
|
||||
// (synchronous fetch + parse + register on `document.styleSheets`).
|
||||
load_external_stylesheets: bool = false,
|
||||
|
||||
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
|
||||
const allocator = browser.app.allocator;
|
||||
const arena_pool = browser.arena_pool;
|
||||
@@ -112,6 +121,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
|
||||
// CLI defaults; LP.configureLoading can flip these per-session.
|
||||
.subframe_loading_enabled = !browser.app.config.disableSubframes(),
|
||||
.worker_loading_enabled = !browser.app.config.disableWorkers(),
|
||||
.load_external_stylesheets = browser.app.config.enableExternalStylesheets(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
185
src/browser/tests/css/external_stylesheet.html
Normal file
185
src/browser/tests/css/external_stylesheet.html
Normal file
@@ -0,0 +1,185 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<script src="../testing.js"></script>
|
||||
<link id="ext" rel="stylesheet" href="/styles/visibility.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="visible">always visible</div>
|
||||
<div id="hidden" class="ext-hide">hidden by external rule</div>
|
||||
|
||||
<script id="ext_stylesheet_registered">
|
||||
{
|
||||
// The parser-synchronous fetch in Frame.loadExternalStylesheet runs
|
||||
// before this script executes, so document.styleSheets reflects the
|
||||
// fetched sheet by the time we look.
|
||||
testing.expectEqual(1, document.styleSheets.length);
|
||||
|
||||
const sheet = document.styleSheets[0];
|
||||
testing.expectEqual(testing.ORIGIN + '/styles/visibility.css', sheet.href);
|
||||
testing.expectEqual(1, sheet.cssRules.length);
|
||||
testing.expectEqual('.ext-hide', sheet.cssRules[0].selectorText);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_cascade">
|
||||
{
|
||||
// External rule must reach StyleManager: a `.ext-hide` element should
|
||||
// report checkVisibility() === false, exactly as if the rule were
|
||||
// declared inline in a `<style>` block.
|
||||
testing.expectTrue(document.getElementById('visible').checkVisibility());
|
||||
testing.expectFalse(document.getElementById('hidden').checkVisibility());
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_dynamic_load_event">
|
||||
{
|
||||
// Dynamically-inserted <link> fires `load` after the fetch completes.
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/visibility.css';
|
||||
testing.async(async () => {
|
||||
const fired = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve(true));
|
||||
link.addEventListener('error', () => resolve(false));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual(true, fired);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_404_fires_error">
|
||||
{
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/404.css';
|
||||
testing.async(async () => {
|
||||
const evt = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve('load'));
|
||||
link.addEventListener('error', () => resolve('error'));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual('error', evt);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_oversize_fires_error">
|
||||
{
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/oversize.css';
|
||||
testing.async(async () => {
|
||||
const evt = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve('load'));
|
||||
link.addEventListener('error', () => resolve('error'));
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual('error', evt);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_href_change_replaces">
|
||||
{
|
||||
// Mutating link.href on a connected stylesheet link must REPLACE the
|
||||
// cached sheet's rules, not append a second entry to
|
||||
// document.styleSheets. Regression test for the stale-sheet
|
||||
// accumulation bug caught in code review.
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/visibility.css';
|
||||
testing.async(async () => {
|
||||
const baseline = document.styleSheets.length;
|
||||
|
||||
await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve(), { once: true });
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
const afterFirst = document.styleSheets.length;
|
||||
testing.expectEqual(baseline + 1, afterFirst);
|
||||
|
||||
const second = await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve('load'), { once: true });
|
||||
link.addEventListener('error', () => resolve('error'), { once: true });
|
||||
link.href = '/styles/visibility2.css';
|
||||
});
|
||||
testing.expectEqual('load', second);
|
||||
testing.expectEqual(afterFirst, document.styleSheets.length);
|
||||
|
||||
// New rules must be in effect after the swap. The reused sheet is
|
||||
// the last registered one (the static head link registered first).
|
||||
const sheet = document.styleSheets[afterFirst - 1];
|
||||
testing.expectEqual(testing.ORIGIN + '/styles/visibility2.css', sheet.href);
|
||||
testing.expectEqual(1, sheet.cssRules.length);
|
||||
testing.expectEqual('.ext-hide-2', sheet.cssRules[0].selectorText);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_fragment_parse_skipped">
|
||||
{
|
||||
// Stylesheet links parsed via innerHTML / outerHTML /
|
||||
// insertAdjacentHTML / Range.createContextualFragment / <template>
|
||||
// content / DOMParser.parseFromString must NOT trigger a network
|
||||
// fetch or register a sheet on the live document — the owning
|
||||
// subtree may never be attached or belongs to a different Document.
|
||||
//
|
||||
// Assertion is synchronous on purpose: both parse paths run inline,
|
||||
// so any unintended sheet registration would be visible immediately.
|
||||
// Deferring to testing.onload would race against the async tests
|
||||
// above that legitimately add sheets.
|
||||
const before = document.styleSheets.length;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<link rel="stylesheet" href="/styles/visibility.css">';
|
||||
testing.expectEqual(before, document.styleSheets.length);
|
||||
|
||||
const parsed = new DOMParser().parseFromString(
|
||||
'<html><head><link rel="stylesheet" href="/styles/visibility2.css"></head><body></body></html>',
|
||||
'text/html'
|
||||
);
|
||||
// Parsed link exists in the new document...
|
||||
testing.expectEqual(1, parsed.querySelectorAll('link').length);
|
||||
// ...but no sheet on the live document.
|
||||
testing.expectEqual(before, document.styleSheets.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_disconnect_removes">
|
||||
{
|
||||
// Removing a connected stylesheet link must drop its sheet from
|
||||
// document.styleSheets. Without symmetric deregistration the SPA
|
||||
// theme-switch pattern (append new, remove old) would accumulate
|
||||
// phantom entries and stale cascade rules.
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/visibility2.css';
|
||||
testing.async(async () => {
|
||||
const baseline = document.styleSheets.length;
|
||||
|
||||
await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve(), { once: true });
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual(baseline + 1, document.styleSheets.length);
|
||||
|
||||
link.remove();
|
||||
testing.expectEqual(baseline, document.styleSheets.length);
|
||||
|
||||
// Re-appending must register fresh — the disconnect cleanup
|
||||
// cleared link._sheet so the cached short-circuit doesn't apply.
|
||||
await new Promise(resolve => {
|
||||
link.addEventListener('load', () => resolve(), { once: true });
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
testing.expectEqual(baseline + 1, document.styleSheets.length);
|
||||
});
|
||||
testing.expectEqual(true, true);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -137,3 +137,19 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="ext_stylesheet_disabled_default">
|
||||
{
|
||||
// Flag defaults off — a <link rel=stylesheet> must NOT register a sheet
|
||||
// on document.styleSheets and must NOT touch the network. The synthetic
|
||||
// load event still fires (asserted by other tests above).
|
||||
const before = document.styleSheets.length;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = '/styles/visibility.css';
|
||||
document.head.appendChild(link);
|
||||
testing.onload(() => {
|
||||
testing.expectEqual(before, document.styleSheets.length);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,17 @@ pub fn parseFromString(
|
||||
const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
|
||||
defer frame.releaseArena(arena);
|
||||
|
||||
// DOMParser builds a detached Document. Borrow the same fragment
|
||||
// parse-mode that `parseHtmlAsChildren` uses so frame-side hooks
|
||||
// triggered from `Build.created` / `nodeIsReady` (external stylesheet
|
||||
// fetches, script execution, mutation-observer fan-out, default-script
|
||||
// injection) treat the parsed nodes as detached and skip
|
||||
// side effects on the live document. The frame's `_parse_mode` is
|
||||
// restored on exit.
|
||||
const previous_parse_mode = frame._parse_mode;
|
||||
frame._parse_mode = .fragment;
|
||||
defer frame._parse_mode = previous_parse_mode;
|
||||
|
||||
return switch (target_mime) {
|
||||
.@"text/html" => {
|
||||
// Create a new HTMLDocument
|
||||
|
||||
@@ -26,6 +26,12 @@ const HtmlElement = @import("../Html.zig");
|
||||
|
||||
const Link = @This();
|
||||
_proto: *HtmlElement,
|
||||
// Cached CSSStyleSheet for an external `rel=stylesheet` once
|
||||
// `Frame.loadExternalStylesheet` has registered it. Re-fetches (href
|
||||
// mutated on a connected link) reuse this sheet via `replaceSync` so the
|
||||
// old rules are dropped instead of accumulating in `document.styleSheets`.
|
||||
// Mirrors `Style._sheet`.
|
||||
_sheet: ?*@import("../../css/CSSStyleSheet.zig") = null,
|
||||
|
||||
pub fn asElement(self: *Link) *Element {
|
||||
return self._proto._proto;
|
||||
@@ -114,6 +120,14 @@ pub fn linkAddedCallback(self: *Link, frame: *Frame) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opt-in fetch for `rel="stylesheet"` — drives `frame.loadExternalStylesheet`,
|
||||
// which fires the load/error event itself. Other rels (preload,
|
||||
// modulepreload) and the disabled case keep the rendering-free stub that
|
||||
// fires a synthetic `load` event without touching the network.
|
||||
if (std.mem.eql(u8, rel, "stylesheet")) {
|
||||
return frame.loadExternalStylesheet(self, href);
|
||||
}
|
||||
|
||||
try frame.queueLoad(self._proto);
|
||||
}
|
||||
|
||||
@@ -143,7 +157,24 @@ pub const JsApi = struct {
|
||||
}
|
||||
};
|
||||
|
||||
// Parser-created <link> elements are void (no closing tag) so they never
|
||||
// reach `Frame.nodeComplete`. Mirror `Image.Build.created` so static head
|
||||
// links in HTML go through `linkAddedCallback` at element-create time,
|
||||
// with attributes already populated by `populateElementAttributes`.
|
||||
pub const Build = struct {
|
||||
pub fn created(node: *Node, frame: *Frame) !void {
|
||||
const self = node.as(Link);
|
||||
return self.linkAddedCallback(frame);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../../../testing.zig");
|
||||
test "WebApi: HTML.Link" {
|
||||
try testing.htmlRunner("element/html/link.html", .{});
|
||||
}
|
||||
|
||||
test "WebApi: HTML.Link external stylesheet" {
|
||||
const filter: testing.LogFilter = .init(&.{.http});
|
||||
defer filter.deinit();
|
||||
try testing.htmlRunner("css/external_stylesheet.html", .{ .load_external_stylesheets = true });
|
||||
}
|
||||
|
||||
@@ -209,6 +209,7 @@ pub fn requestIntercept(bc: *CDP.BrowserContext, intercept: *const Notification.
|
||||
.xhr => "XHR",
|
||||
.document => "Document",
|
||||
.fetch => "Fetch",
|
||||
.stylesheet => "Stylesheet",
|
||||
},
|
||||
.networkId = &id.toRequestId(transfer), // matches the Network REQ-ID
|
||||
}, .{ .session_id = session_id });
|
||||
@@ -453,6 +454,7 @@ pub fn requestAuthRequired(bc: *CDP.BrowserContext, intercept: *const Notificati
|
||||
.xhr => "XHR",
|
||||
.document => "Document",
|
||||
.fetch => "Fetch",
|
||||
.stylesheet => "Stylesheet",
|
||||
},
|
||||
.authChallenge = .{
|
||||
.origin = "", // TODO get origin, could be the proxy address for example.
|
||||
|
||||
@@ -68,11 +68,13 @@ fn configureLoading(cmd: *CDP.Command) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
subFrame: ?bool = null,
|
||||
worker: ?bool = null,
|
||||
externalStylesheets: ?bool = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.NoBrowserContext;
|
||||
if (params.subFrame) |v| bc.session.subframe_loading_enabled = v;
|
||||
if (params.worker) |v| bc.session.worker_loading_enabled = v;
|
||||
if (params.externalStylesheets) |v| bc.session.load_external_stylesheets = v;
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
@@ -688,3 +690,36 @@ test "cdp.lp: configureLoading toggles subFrame and worker independently" {
|
||||
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
|
||||
try testing.expectEqual(true, bc.session.worker_loading_enabled);
|
||||
}
|
||||
|
||||
test "cdp.lp: configureLoading toggles externalStylesheets independently" {
|
||||
var ctx = try testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{});
|
||||
_ = try bc.session.createPage();
|
||||
|
||||
// Default is opt-in: off unless the CLI flag or CDP toggle enables it.
|
||||
try testing.expectEqual(false, bc.session.load_external_stylesheets);
|
||||
|
||||
// Enable via CDP; the other two loading toggles stay at their defaults.
|
||||
try ctx.processMessage(.{
|
||||
.id = 1,
|
||||
.method = "LP.configureLoading",
|
||||
.params = .{ .externalStylesheets = true },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 1 });
|
||||
try testing.expectEqual(true, bc.session.load_external_stylesheets);
|
||||
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
|
||||
try testing.expectEqual(true, bc.session.worker_loading_enabled);
|
||||
|
||||
// Flip back off; partial params must not reset the other fields.
|
||||
try ctx.processMessage(.{
|
||||
.id = 2,
|
||||
.method = "LP.configureLoading",
|
||||
.params = .{ .externalStylesheets = false },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 2 });
|
||||
try testing.expectEqual(false, bc.session.load_external_stylesheets);
|
||||
try testing.expectEqual(true, bc.session.subframe_loading_enabled);
|
||||
try testing.expectEqual(true, bc.session.worker_loading_enabled);
|
||||
}
|
||||
|
||||
@@ -157,6 +157,13 @@
|
||||
\\ script fetch is initiated and its scope never runs.
|
||||
\\ Defaults to false.
|
||||
\\
|
||||
\\--enable-external-stylesheets
|
||||
\\ Fetch external <link rel=stylesheet> resources so
|
||||
\\ their rules contribute to computed styles (and
|
||||
\\ therefore to visibility checks like display,
|
||||
\\ visibility, opacity, pointer-events).
|
||||
\\ Defaults to false.
|
||||
\\
|
||||
\\--block-private-networks Block HTTP requests to private/internal IP
|
||||
\\ addresses after DNS resolution.
|
||||
\\ Defaults to false.
|
||||
|
||||
@@ -341,6 +341,7 @@ const WEB_API_TEST_ROOT = "src/browser/tests/";
|
||||
const HtmlRunnerOpts = struct {
|
||||
timeout_ms: u32 = 2000,
|
||||
inject_script: ?[]const u8 = null,
|
||||
load_external_stylesheets: bool = false,
|
||||
};
|
||||
|
||||
pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void {
|
||||
@@ -353,6 +354,9 @@ pub fn htmlRunner(comptime path: []const u8, opts: HtmlRunnerOpts) !void {
|
||||
}
|
||||
defer test_session.inject_scripts = &.{};
|
||||
|
||||
test_session.load_external_stylesheets = opts.load_external_stylesheets;
|
||||
defer test_session.load_external_stylesheets = false;
|
||||
|
||||
const root = try std.fs.path.joinZ(arena_allocator, &.{ WEB_API_TEST_ROOT, path });
|
||||
const stat = std.fs.cwd().statFile(root) catch |err| {
|
||||
std.debug.print("Failed to stat file: '{s}'", .{root});
|
||||
@@ -678,6 +682,53 @@ fn testHTTPHandler(req: *std.http.Server.Request) !void {
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, path, "/styles/visibility.css")) {
|
||||
// Used by css/external_stylesheet.html — drives the visibility
|
||||
// cascade through StyleManager via Frame.loadExternalStylesheet
|
||||
// so a `.ext-hide` element is observable to checkVisibility().
|
||||
return req.respond(".ext-hide { display: none; }", .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Content-Type", .value = "text/css" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, path, "/styles/visibility2.css")) {
|
||||
// Second visibility sheet used by the href-change regression test:
|
||||
// mutating link.href must replace the cached sheet's rules in place,
|
||||
// not append a new entry to document.styleSheets.
|
||||
return req.respond(".ext-hide-2 { display: none; }", .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Content-Type", .value = "text/css" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, path, "/styles/404.css")) {
|
||||
return req.respond("/* unused */", .{
|
||||
.status = .not_found,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Content-Type", .value = "text/css" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, path, "/styles/oversize.css")) {
|
||||
// Body that exceeds Frame.MAX_STYLESHEET_BYTES (2 MiB) — written as a
|
||||
// long sequence of valid declarations so the response itself parses
|
||||
// fine and the error path is exercised by the size cap, not by a
|
||||
// CSS parse failure.
|
||||
const chunk = ".pad { color: #abcdef; } "; // 25 bytes
|
||||
const repeats = (2 * 1024 * 1024 / chunk.len) + 1024;
|
||||
var body = try std.ArrayList(u8).initCapacity(arena_allocator, chunk.len * repeats);
|
||||
for (0..repeats) |_| body.appendSliceAssumeCapacity(chunk);
|
||||
return req.respond(body.items, .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Content-Type", .value = "text/css" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, path, "/echo_referer")) {
|
||||
// Echo the request's Referer header back as HTML so tests can assert
|
||||
// what Referer the navigation sent. Used by the cross-page Referer test.
|
||||
|
||||
Reference in New Issue
Block a user