From 0abc6f9c974213a13ccf83648be9e715d1e769de Mon Sep 17 00:00:00 2001 From: DF-Nico Date: Mon, 18 May 2026 17:53:07 +0200 Subject: [PATCH] feat(coverage): add code coverage capabilities Using CDP Session, provide simple API to extract code coverage from JS/CSS Signed-off-by: DF-Nico --- .../com/microsoft/playwright/Coverage.java | 141 ++++++++ .../java/com/microsoft/playwright/Page.java | 4 + .../playwright/impl/CoverageImpl.java | 339 ++++++++++++++++++ .../microsoft/playwright/impl/PageImpl.java | 8 + 4 files changed, 492 insertions(+) create mode 100644 playwright/src/main/java/com/microsoft/playwright/Coverage.java create mode 100644 playwright/src/main/java/com/microsoft/playwright/impl/CoverageImpl.java diff --git a/playwright/src/main/java/com/microsoft/playwright/Coverage.java b/playwright/src/main/java/com/microsoft/playwright/Coverage.java new file mode 100644 index 000000000..49f085f54 --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/Coverage.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright; + + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +/** + * {@code Coverage} gathers information about parts of JavaScript and CSS that were used by the page. + * + *

An example of using {@code Coverage} class to generate v8 report: + *

{@code
+ * import com.microsoft.playwright.*;
+ *
+ * public class Example {
+ *   public static void main(String[] args) {
+ *     try (Playwright playwright = Playwright.create()) {
+ *       BrowserType chromium = playwright.chromium();
+ *       Browser browser = chromium.launch();
+ *       Page page = browser.newPage();
+ *       Coverage coverage = page.coverage().startJSCoverage();
+ *       ... some tests and assertions...
+ *       CoverageReport v8Report = coverage.stopJSCoverage();
+ *       browser.close();
+ *     }
+ *   }
+ * }
+ * }
+ * + *

NOTE: Coverage APIs are only supported on Chromium-based browsers. + */ +public interface Coverage { + class CoverageReport { + /** + * Entries containing coverage reports + */ + public JsonArray entries; + + public CoverageReport addEntry(JsonObject entry) { + if (entries == null) { + entries = new JsonArray(); + } + entries.add(entry); + return this; + } + } + + class CoverageCSSOptions { + /** + * Whether to reset coverage on every navigation. Defaults to `true`. + */ + public Boolean resetOnNavigation; + + public CoverageCSSOptions setResetOnNavigation(Boolean restOnNavigation) { + this.resetOnNavigation = restOnNavigation; + return this; + } + } + + class CoverageJSOptions { + /** + * Whether to reset coverage on every navigation. Defaults to `true`. + */ + public Boolean resetOnNavigation; + /** + * Whether anonymous scripts generated by the page should be reported. Defaults to `false`. + */ + public Boolean reportAnonymousScripts; + + public CoverageJSOptions setResetOnNavigation(Boolean resetOnNavigation) { + this.resetOnNavigation = resetOnNavigation; + return this; + } + + public CoverageJSOptions setReportAnonymousScripts(Boolean reportAnonymousScripts) { + this.reportAnonymousScripts = reportAnonymousScripts; + return this; + } + } + + /** + * Returns coverage is started + */ + void startCSSCoverage(); + + /** + * Returns coverage is started + */ + void startCSSCoverage(CoverageCSSOptions options); + + /** + * Returns the array of coverage reports for all stylesheets + * + *

NOTE:CSS Coverage doesn't include dynamically injected style tags without sourceURLs. + * + */ + CoverageReport stopCSSCoverage(); + + /** + * Returns coverage is started + * + *

NOTE: Anonymous scripts are ones that don't have an associated url. These are scripts that are dynamically + * created on the page using `eval` or `new Function`. If {@link com.microsoft.playwright.Coverage.CoverageJSOptions#reportAnonymousScripts} + * is set to `true`, anonymous scripts will have `__playwright_evaluation_script__` as their URL. + */ + void startJSCoverage(); + + /** + * Returns coverage is started + * + *

NOTE: Anonymous scripts are ones that don't have an associated url. These are scripts that are dynamically + * created on the page using `eval` or `new Function`. If {@link com.microsoft.playwright.Coverage.CoverageJSOptions#reportAnonymousScripts} + * is set to `true`, anonymous scripts will have `__playwright_evaluation_script__` as their URL. + */ + void startJSCoverage(CoverageJSOptions options); + + /** + * Returns the array of coverage reports for all scripts + * + *

NOTE: JavaScript Coverage doesn't include anonymous scripts by default. However, scripts with sourceURLs are + * reported. + * + */ + CoverageReport stopJSCoverage(); +} + diff --git a/playwright/src/main/java/com/microsoft/playwright/Page.java b/playwright/src/main/java/com/microsoft/playwright/Page.java index da5f4e865..615baab51 100644 --- a/playwright/src/main/java/com/microsoft/playwright/Page.java +++ b/playwright/src/main/java/com/microsoft/playwright/Page.java @@ -4097,6 +4097,10 @@ default void close() { * @since v1.8 */ BrowserContext context(); + /** + * Get the coverage report associated to the page. + */ + Coverage coverage(); /** * This method double clicks an element matching {@code selector} by performing the following steps: *

    diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/CoverageImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/CoverageImpl.java new file mode 100644 index 000000000..50290009a --- /dev/null +++ b/playwright/src/main/java/com/microsoft/playwright/impl/CoverageImpl.java @@ -0,0 +1,339 @@ +/* + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.microsoft.playwright.impl; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.microsoft.playwright.CDPSession; +import com.microsoft.playwright.Coverage; +import com.microsoft.playwright.PlaywrightException; + +import java.util.*; + +class CoverageImpl implements Coverage { + private final CSSCoverage cssCoverage; + private final JSCoverage jsCoverage; + + CoverageImpl(PageImpl page) { + this.cssCoverage = new CSSCoverage(page); + this.jsCoverage = new JSCoverage(page); + } + + + @Override + public void startCSSCoverage() { + this.cssCoverage.start(); + } + + @Override + public void startCSSCoverage(CoverageCSSOptions options) { + this.cssCoverage.start(options); + } + + @Override + public CoverageReport stopCSSCoverage() { + return this.cssCoverage.stop(); + } + + @Override + public void startJSCoverage() { + this.jsCoverage.start(); + } + + @Override + public void startJSCoverage(CoverageJSOptions options) { + this.jsCoverage.start(options); + } + + @Override + public CoverageReport stopJSCoverage() { + return this.jsCoverage.stop(); + } +} + +class CSSCoverage { + private final PageImpl page; + private final Map stylesheetURLs = new HashMap<>(); + private final Map stylesheetSources = new HashMap<>(); + private CDPSession cdpSession; + private Boolean enabled = false; + private Coverage.CoverageCSSOptions options; + + CSSCoverage(PageImpl page) { + this.page = page; + this.options = new Coverage.CoverageCSSOptions().setResetOnNavigation(true); + } + + void start(Coverage.CoverageCSSOptions options) { + this.options = options; + this.start(); + } + + void start() { + if (Boolean.TRUE.equals(enabled)) { + throw new PlaywrightException("CSSCoverage is already enabled"); + } + this.enabled = true; + this.stylesheetURLs.clear(); + this.stylesheetSources.clear(); + this.cdpSession = this.page.context().newCDPSession(this.page); + + cdpSession.on("CSS.styleSheetAdded", this::onStyleSheet); + cdpSession.on("Runtime.executionContextsCleared", this::onExecutionContextsCleared); + + cdpSession.send("DOM.enable"); + cdpSession.send("CSS.enable"); + cdpSession.send("CSS.startRuleUsageTracking"); + } + + private void onStyleSheet(JsonObject event) { + JsonObject header = event.get("header").getAsJsonObject(); + String sourceURL = header.get("url").getAsString(); + boolean urlEmpty = sourceURL == null || sourceURL.isEmpty(); + if (urlEmpty) + return; + + String stylesheetID = header.get("styleSheetId").getAsString(); + JsonObject params = new JsonObject(); + params.addProperty("styleSheetId", stylesheetID); + JsonObject scriptResult = cdpSession.send("CSS.getStyleSheetText", params); + if (scriptResult != null) { + this.stylesheetURLs.put(stylesheetID, sourceURL); + this.stylesheetSources.put(stylesheetID, scriptResult.get("text")); + } + } + + private void onExecutionContextsCleared(JsonObject event) { + if (!this.options.resetOnNavigation) + return; + this.stylesheetURLs.clear(); + this.stylesheetSources.clear(); + } + + Coverage.CoverageReport stop() { + if (this.enabled == Boolean.FALSE) + return new Coverage.CoverageReport(); + + try { + JsonObject result = cdpSession.send("CSS.stopRuleUsageTracking"); + cdpSession.send("Profiler.stopPreciseCoverage"); + cdpSession.send("CSS.disable"); + cdpSession.send("DOM.disable"); + + cdpSession.off("CSS.styleSheetAdded", this::onStyleSheet); + cdpSession.off("Runtime.executionContextsCleared", this::onExecutionContextsCleared); + cdpSession.detach(); + this.enabled = Boolean.FALSE; + + JsonArray scripts = result.getAsJsonArray("ruleUsage"); + Map styleSheetIdToCoverage = new HashMap<>(); + if (scripts != null && !scripts.isEmpty()) { + for (JsonElement element : scripts) { + JsonObject entry = element.getAsJsonObject(); + String styleSheetId = entry.get("styleSheetId").getAsString(); + JsonArray ranges = styleSheetIdToCoverage.computeIfAbsent(styleSheetId, k -> new JsonArray()); + + JsonObject range = new JsonObject(); + range.addProperty("startOffset", entry.get("startOffset").getAsInt()); + range.addProperty("endOffset", entry.get("endOffset").getAsInt()); + range.addProperty("count", entry.get("used").getAsBoolean() ? 1 : 0); + ranges.add(range); + } + } + Coverage.CoverageReport coverageReport = new Coverage.CoverageReport(); + for (Map.Entry mapEntry : this.stylesheetURLs.entrySet()) { + JsonElement text = this.stylesheetSources.get(mapEntry.getKey()); + JsonArray ranges = convertToDisjointRanges(styleSheetIdToCoverage.getOrDefault(mapEntry.getKey(), new JsonArray())); + JsonObject coverage = new JsonObject(); + coverage.addProperty("url", mapEntry.getKey()); + coverage.add("ranges", ranges); + coverage.add("text", text); + coverageReport.addEntry(coverage); + } + return coverageReport; + } catch (Exception e) { + throw new PlaywrightException("Failed to gather JS coverage report", e); + } + } + + private JsonArray convertToDisjointRanges(JsonArray nestedRanges) { + List points = new ArrayList<>(); + for (JsonElement element : nestedRanges) { + JsonObject range = element.getAsJsonObject(); + JsonObject point1 = new JsonObject(); + point1.addProperty("offset", range.get("startOffset").getAsInt()); + point1.addProperty("type", 0); + point1.add("range", range); + points.add(point1); + JsonObject point2 = new JsonObject(); + point2.addProperty("offset", range.get("endOffset").getAsInt()); + point2.addProperty("type", 1); + point2.add("range", range); + points.add(point2); + } + points.sort((a, b) -> { + if (a.get("offset").getAsInt() != b.get("offset").getAsInt()) + return a.get("offset").getAsInt() - b.get("offset").getAsInt(); + if (a.get("type").getAsInt() != b.get("type").getAsInt()) + return b.get("type").getAsInt() - a.get("type").getAsInt(); + JsonObject aRange = a.get("range").getAsJsonObject(); + JsonObject bRange = b.get("range").getAsJsonObject(); + int aLength = aRange.get("endOffset").getAsInt() - aRange.get("startOffset").getAsInt(); + int bLength = bRange.get("endOffset").getAsInt() - bRange.get("startOffset").getAsInt(); + if (a.get("type").getAsInt() == 0) + return bLength - aLength; + return aLength - bLength; + }); + + Deque hitCountStack = new ArrayDeque<>(); + List results = new ArrayList<>(); + int lastOffset = 0; + for (JsonObject point : points) { + int offset = point.get("offset").getAsInt(); + if (!hitCountStack.isEmpty() && lastOffset < offset && hitCountStack.getLast() > 0) { + JsonObject lastResult = results.isEmpty() ? null : results.get(results.size() - 1).getAsJsonObject(); + if (lastResult != null && lastResult.get("end").getAsInt() == lastOffset) { + lastResult.remove("end"); + lastResult.addProperty("end", offset); + } else { + JsonObject result = new JsonObject(); + result.addProperty("start", lastOffset); + result.addProperty("end", offset); + results.add(result); + } + } + lastOffset = offset; + if (point.get("type").getAsInt() == 0) { + JsonObject range = point.get("range").getAsJsonObject(); + hitCountStack.push(range.get("count").getAsInt()); + } else { + hitCountStack.pop(); + } + } + return results.stream().filter(result -> result.get("end").getAsInt() - result.get("start").getAsInt() > 1) + .collect(JsonArray::new, JsonArray::add, JsonArray::addAll); + } +} + +class JSCoverage { + private final PageImpl page; + private final Set scriptsId = new HashSet<>(); + private final Map scriptsSources = new HashMap<>(); + private CDPSession cdpSession; + private Boolean enabled = false; + private Coverage.CoverageJSOptions options; + + JSCoverage(PageImpl page) { + this.page = page; + this.options = new Coverage.CoverageJSOptions().setResetOnNavigation(true).setReportAnonymousScripts(false); + } + + void start(Coverage.CoverageJSOptions options) { + this.options = options; + this.start(); + } + + void start() { + if (Boolean.TRUE.equals(enabled)) { + throw new PlaywrightException("JSCoverage is already enabled"); + } + this.enabled = true; + this.scriptsId.clear(); + this.scriptsSources.clear(); + this.cdpSession = this.page.context().newCDPSession(this.page); + + cdpSession.on("Debugger.scriptParsed", this::onScriptParsed); + cdpSession.on("Runtime.executionContextsCleared", this::onExecutionContextsCleared); + cdpSession.on("Debugger.paused", this::onDebuggerPaused); + + cdpSession.send("Profiler.enable"); + JsonObject profilerParams = new JsonObject(); + profilerParams.addProperty("callCount", true); + profilerParams.addProperty("detailed", true); + cdpSession.send("Profiler.startPreciseCoverage", profilerParams); + + cdpSession.send("Debugger.enable"); + JsonObject debuggerParam = new JsonObject(); + debuggerParam.addProperty("skip", true); + cdpSession.send("Debugger.setSkipAllPauses", debuggerParam); + } + + private void onScriptParsed(JsonObject event) { + String scriptId = event.get("scriptId").getAsString(); + String scriptUrl = event.get("url").getAsString(); + boolean urlEmpty = scriptUrl == null || scriptUrl.isEmpty(); + if (urlEmpty && !this.options.reportAnonymousScripts) + return; + if (scriptsId.contains(scriptId)) + return; + + scriptsId.add(scriptId); + JsonObject scriptParams = new JsonObject(); + scriptParams.addProperty("scriptId", scriptId); + JsonObject scriptResult = cdpSession.send("Debugger.getScriptSource", scriptParams); + Optional.ofNullable(scriptResult).map(object -> object.get("scriptSource")) + .ifPresent(scriptSource -> scriptsSources.put(scriptId, scriptSource)); + } + + private void onExecutionContextsCleared(JsonObject event) { + if (!this.options.resetOnNavigation) + return; + this.scriptsId.clear(); + this.scriptsSources.clear(); + } + + private void onDebuggerPaused(JsonObject event) { + cdpSession.send("Debugger.resume"); + } + + Coverage.CoverageReport stop() { + if (this.enabled == Boolean.FALSE) + return new Coverage.CoverageReport(); + + try { + JsonObject result = cdpSession.send("Profiler.takePreciseCoverage"); + cdpSession.send("Profiler.stopPreciseCoverage"); + cdpSession.send("Profiler.disable"); + cdpSession.send("Debugger.disable"); + + cdpSession.off("Debugger.scriptParsed", this::onScriptParsed); + cdpSession.off("Runtime.executionContextsCleared", this::onExecutionContextsCleared); + cdpSession.off("Debugger.paused", this::onDebuggerPaused); + cdpSession.detach(); + this.enabled = Boolean.FALSE; + + JsonArray scripts = result.getAsJsonArray("result"); + Coverage.CoverageReport coverageReport = new Coverage.CoverageReport(); + if (scripts != null && !scripts.isEmpty()) { + for (JsonElement element : scripts) { + JsonObject entry = element.getAsJsonObject(); + String scriptId = entry.get("scriptId").getAsString(); + if (!scriptsSources.containsKey(scriptId)) + continue; + JsonElement source = scriptsSources.get(scriptId); + if (source != null) + entry.add("source", scriptsSources.get(scriptId)); + coverageReport.addEntry(entry); + } + } + return coverageReport; + } catch (Exception e) { + throw new PlaywrightException("Failed to gather JS coverage report", e); + } + } +} diff --git a/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java b/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java index 19d7b3839..aa0351070 100644 --- a/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java +++ b/playwright/src/main/java/com/microsoft/playwright/impl/PageImpl.java @@ -47,6 +47,7 @@ public class PageImpl extends ChannelOwner implements Page { private final MouseImpl mouse; private final TouchscreenImpl touchscreen; private final ScreencastImpl screencast; + private final CoverageImpl coverage; final Waitable waitableClosedOrCrashed; private ViewportSize viewport; private final Router routes = new Router(); @@ -140,6 +141,8 @@ enum EventType { frames.add(mainFrame); timeoutSettings = new TimeoutSettings(browserContext.timeoutSettings); waitableClosedOrCrashed = createWaitForCloseHelper(); + coverage = new CoverageImpl(this); + if (initializer.has("opener")) { opener = connection.getExistingObject(initializer.getAsJsonObject("opener").get("guid").getAsString()); } else { @@ -723,6 +726,11 @@ public BrowserContextImpl context() { return browserContext; } + @Override + public CoverageImpl coverage() { + return coverage; + } + @Override public void dblclick(String selector, DblclickOptions options) { mainFrame.dblclickImpl(selector, convertType(options, Frame.DblclickOptions.class), null);