diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml
index 2261eb18c8eef..9fee5a896dabb 100644
--- a/.github/workflows/tests_primary.yml
+++ b/.github/workflows/tests_primary.yml
@@ -137,7 +137,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- package: [html-reporter, web]
+ package: [html-reporter, coverage-report, web]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 8eda40f3a5675..9822e0a1e9e96 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -58,6 +58,9 @@ const ignores = [
"packages/html-reporter/playwright.config.ts",
"packages/html-reporter/tests/*",
"packages/html-reporter/vite.config.ts",
+ "packages/coverage-report/playwright.config.ts",
+ "packages/coverage-report/tests/*",
+ "packages/coverage-report/vite.config.ts",
"test-results/",
"tests/assets/",
"tests/components/",
@@ -284,6 +287,8 @@ const reactBaseConfig = fixupConfigRules(
const reactFiles = [
`packages/html-reporter/src/**/*.ts`,
`packages/html-reporter/src/**/*.tsx`,
+ `packages/coverage-report/src/**/*.ts`,
+ `packages/coverage-report/src/**/*.tsx`,
`packages/recorder/src/**/*.ts`,
`packages/recorder/src/**/*.tsx`,
`packages/trace-viewer/src/**/*.ts`,
@@ -528,6 +533,7 @@ export default [
files: reactFiles,
})),
reactPackageSection("html-reporter"),
+ reactPackageSection("coverage-report"),
reactPackageSection("recorder"),
reactPackageSection("trace-viewer"),
];
diff --git a/package-lock.json b/package-lock.json
index 8b50e490f8147..30dc261ac1753 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4319,6 +4319,10 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/coverage-report": {
+ "resolved": "packages/coverage-report",
+ "link": true
+ },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
@@ -9638,6 +9642,9 @@
"zod": "^3.25.28 || ^4"
}
},
+ "packages/coverage-report": {
+ "version": "0.0.0"
+ },
"packages/dashboard": {
"name": "@playwright/dashboard",
"version": "0.0.0",
diff --git a/package.json b/package.json
index 3bd7df4a818f9..c7e0ab58ce5bf 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"stest": "playwright test --config=tests/stress/playwright.config.ts",
"biditest": "playwright test --config=tests/bidi/playwright.config.ts",
"test-html-reporter": "playwright test --config=packages/html-reporter",
+ "test-coverage-report": "playwright test --config=packages/coverage-report",
"test-web": "playwright test --config=packages/web",
"ttest": "node ./tests/playwright-test/stable-test-runner/node_modules/@playwright/test/cli test --config=tests/playwright-test/playwright.config.ts",
"test": "playwright test --config=tests/library/playwright.config.ts",
diff --git a/packages/coverage-report/index.html b/packages/coverage-report/index.html
new file mode 100644
index 0000000000000..71f92c85ca88e
--- /dev/null
+++ b/packages/coverage-report/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+ Playwright Coverage Report
+
+
+
+
+
+
diff --git a/packages/coverage-report/package.json b/packages/coverage-report/package.json
new file mode 100644
index 0000000000000..b1c962f7e57fd
--- /dev/null
+++ b/packages/coverage-report/package.json
@@ -0,0 +1,6 @@
+{
+ "name": "coverage-report",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module"
+}
diff --git a/packages/coverage-report/playwright.config.ts b/packages/coverage-report/playwright.config.ts
new file mode 100644
index 0000000000000..3aea331525a0a
--- /dev/null
+++ b/packages/coverage-report/playwright.config.ts
@@ -0,0 +1,55 @@
+/**
+ * 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.
+ */
+
+import path from 'path';
+import url from 'url';
+import { devices, defineConfig } from '@playwright/test';
+
+process.env.PWTEST_UNDER_TEST = '1';
+
+const dirname = path.dirname(url.fileURLToPath(import.meta.url));
+const outputDir = path.join(dirname, '..', '..', 'test-results');
+
+export default defineConfig({
+ testDir: 'src',
+ outputDir,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? [
+ ['dot'],
+ ['json', { outputFile: path.join(outputDir, 'report.json') }],
+ ['blob', { outputDir: path.join(dirname, '..', '..', 'blob-report') }],
+ ['../../tests/config/parquetReporter.ts'],
+ ] : [
+ ['html', { open: 'on-failure' }]
+ ],
+ tag: process.env.PW_TAG,
+ use: {
+ baseURL: 'http://localhost:3103/tests/index.html',
+ serviceWorkers: 'block',
+ reuseContext: true,
+ trace: 'on-first-retry',
+ },
+ projects: [{
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ }],
+ webServer: {
+ command: 'npx vite --port 3103 --strictPort',
+ url: 'http://localhost:3103/tests/index.html',
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/packages/coverage-report/src/DEPS.list b/packages/coverage-report/src/DEPS.list
new file mode 100644
index 0000000000000..b8163f57f4247
--- /dev/null
+++ b/packages/coverage-report/src/DEPS.list
@@ -0,0 +1,6 @@
+[*]
+@web/**
+@isomorphic/**
+
+[coverageView.spec.ts]
+***
diff --git a/packages/coverage-report/src/colors.css b/packages/coverage-report/src/colors.css
new file mode 100644
index 0000000000000..7c3bef0b9f131
--- /dev/null
+++ b/packages/coverage-report/src/colors.css
@@ -0,0 +1,894 @@
+/* The MIT License (MIT)
+
+Copyright (c) 2021 GitHub Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE. */
+
+:root {
+ --color-canvas-default-transparent: rgba(255,255,255,0);
+ --color-marketing-icon-primary: #218bff;
+ --color-marketing-icon-secondary: #54aeff;
+ --color-diff-blob-addition-num-text: #24292f;
+ --color-diff-blob-addition-fg: #24292f;
+ --color-diff-blob-addition-num-bg: #CCFFD8;
+ --color-diff-blob-addition-line-bg: #E6FFEC;
+ --color-diff-blob-addition-word-bg: #ABF2BC;
+ --color-diff-blob-deletion-num-text: #24292f;
+ --color-diff-blob-deletion-fg: #24292f;
+ --color-diff-blob-deletion-num-bg: #FFD7D5;
+ --color-diff-blob-deletion-line-bg: #FFEBE9;
+ --color-diff-blob-deletion-word-bg: rgba(255,129,130,0.4);
+ --color-diff-blob-hunk-num-bg: rgba(84,174,255,0.4);
+ --color-diff-blob-expander-icon: #57606a;
+ --color-diff-blob-selected-line-highlight-mix-blend-mode: multiply;
+ --color-diffstat-deletion-border: rgba(27,31,36,0.15);
+ --color-diffstat-addition-border: rgba(27,31,36,0.15);
+ --color-diffstat-addition-bg: #2da44e;
+ --color-search-keyword-hl: #fff8c5;
+ --color-prettylights-syntax-comment: #6e7781;
+ --color-prettylights-syntax-constant: #0550ae;
+ --color-prettylights-syntax-entity: #8250df;
+ --color-prettylights-syntax-storage-modifier-import: #24292f;
+ --color-prettylights-syntax-entity-tag: #116329;
+ --color-prettylights-syntax-keyword: #cf222e;
+ --color-prettylights-syntax-string: #0a3069;
+ --color-prettylights-syntax-variable: #953800;
+ --color-prettylights-syntax-brackethighlighter-unmatched: #82071e;
+ --color-prettylights-syntax-invalid-illegal-text: #f6f8fa;
+ --color-prettylights-syntax-invalid-illegal-bg: #82071e;
+ --color-prettylights-syntax-carriage-return-text: #f6f8fa;
+ --color-prettylights-syntax-carriage-return-bg: #cf222e;
+ --color-prettylights-syntax-string-regexp: #116329;
+ --color-prettylights-syntax-markup-list: #3b2300;
+ --color-prettylights-syntax-markup-heading: #0550ae;
+ --color-prettylights-syntax-markup-italic: #24292f;
+ --color-prettylights-syntax-markup-bold: #24292f;
+ --color-prettylights-syntax-markup-deleted-text: #82071e;
+ --color-prettylights-syntax-markup-deleted-bg: #FFEBE9;
+ --color-prettylights-syntax-markup-inserted-text: #116329;
+ --color-prettylights-syntax-markup-inserted-bg: #dafbe1;
+ --color-prettylights-syntax-markup-changed-text: #953800;
+ --color-prettylights-syntax-markup-changed-bg: #ffd8b5;
+ --color-prettylights-syntax-markup-ignored-text: #eaeef2;
+ --color-prettylights-syntax-markup-ignored-bg: #0550ae;
+ --color-prettylights-syntax-meta-diff-range: #8250df;
+ --color-prettylights-syntax-brackethighlighter-angle: #57606a;
+ --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;
+ --color-prettylights-syntax-constant-other-reference-link: #0a3069;
+ --color-codemirror-text: #24292f;
+ --color-codemirror-bg: #ffffff;
+ --color-codemirror-gutters-bg: #ffffff;
+ --color-codemirror-guttermarker-text: #ffffff;
+ --color-codemirror-guttermarker-subtle-text: #6e7781;
+ --color-codemirror-linenumber-text: #57606a;
+ --color-codemirror-cursor: #24292f;
+ --color-codemirror-selection-bg: rgba(84,174,255,0.4);
+ --color-codemirror-activeline-bg: rgba(234,238,242,0.5);
+ --color-codemirror-matchingbracket-text: #24292f;
+ --color-codemirror-lines-bg: #ffffff;
+ --color-codemirror-syntax-comment: #24292f;
+ --color-codemirror-syntax-constant: #0550ae;
+ --color-codemirror-syntax-entity: #8250df;
+ --color-codemirror-syntax-keyword: #cf222e;
+ --color-codemirror-syntax-storage: #cf222e;
+ --color-codemirror-syntax-string: #0a3069;
+ --color-codemirror-syntax-support: #0550ae;
+ --color-codemirror-syntax-variable: #953800;
+ --color-checks-bg: #24292f;
+ --color-checks-run-border-width: 0px;
+ --color-checks-container-border-width: 0px;
+ --color-checks-text-primary: #f6f8fa;
+ --color-checks-text-secondary: #8c959f;
+ --color-checks-text-link: #54aeff;
+ --color-checks-btn-icon: #afb8c1;
+ --color-checks-btn-hover-icon: #f6f8fa;
+ --color-checks-btn-hover-bg: rgba(255,255,255,0.125);
+ --color-checks-input-text: #eaeef2;
+ --color-checks-input-placeholder-text: #8c959f;
+ --color-checks-input-focus-text: #8c959f;
+ --color-checks-input-bg: #32383f;
+ --color-checks-input-shadow: none;
+ --color-checks-donut-error: #fa4549;
+ --color-checks-donut-pending: #bf8700;
+ --color-checks-donut-success: #2da44e;
+ --color-checks-donut-neutral: #afb8c1;
+ --color-checks-dropdown-text: #afb8c1;
+ --color-checks-dropdown-bg: #32383f;
+ --color-checks-dropdown-border: #424a53;
+ --color-checks-dropdown-shadow: rgba(27,31,36,0.3);
+ --color-checks-dropdown-hover-text: #f6f8fa;
+ --color-checks-dropdown-hover-bg: #424a53;
+ --color-checks-dropdown-btn-hover-text: #f6f8fa;
+ --color-checks-dropdown-btn-hover-bg: #32383f;
+ --color-checks-scrollbar-thumb-bg: #57606a;
+ --color-checks-header-label-text: #d0d7de;
+ --color-checks-header-label-open-text: #f6f8fa;
+ --color-checks-header-border: #32383f;
+ --color-checks-header-icon: #8c959f;
+ --color-checks-line-text: #d0d7de;
+ --color-checks-line-num-text: rgba(140,149,159,0.75);
+ --color-checks-line-timestamp-text: #8c959f;
+ --color-checks-line-hover-bg: #32383f;
+ --color-checks-line-selected-bg: rgba(33,139,255,0.15);
+ --color-checks-line-selected-num-text: #54aeff;
+ --color-checks-line-dt-fm-text: #24292f;
+ --color-checks-line-dt-fm-bg: #9a6700;
+ --color-checks-gate-bg: rgba(125,78,0,0.15);
+ --color-checks-gate-text: #d0d7de;
+ --color-checks-gate-waiting-text: #afb8c1;
+ --color-checks-step-header-open-bg: #32383f;
+ --color-checks-step-error-text: #ff8182;
+ --color-checks-step-warning-text: #d4a72c;
+ --color-checks-logline-text: #8c959f;
+ --color-checks-logline-num-text: rgba(140,149,159,0.75);
+ --color-checks-logline-debug-text: #c297ff;
+ --color-checks-logline-error-text: #d0d7de;
+ --color-checks-logline-error-num-text: #ff8182;
+ --color-checks-logline-error-bg: rgba(164,14,38,0.15);
+ --color-checks-logline-warning-text: #d0d7de;
+ --color-checks-logline-warning-num-text: #d4a72c;
+ --color-checks-logline-warning-bg: rgba(125,78,0,0.15);
+ --color-checks-logline-command-text: #54aeff;
+ --color-checks-logline-section-text: #4ac26b;
+ --color-checks-ansi-black: #24292f;
+ --color-checks-ansi-black-bright: #32383f;
+ --color-checks-ansi-white: #d0d7de;
+ --color-checks-ansi-white-bright: #d0d7de;
+ --color-checks-ansi-gray: #8c959f;
+ --color-checks-ansi-red: #ff8182;
+ --color-checks-ansi-red-bright: #ffaba8;
+ --color-checks-ansi-green: #4ac26b;
+ --color-checks-ansi-green-bright: #6fdd8b;
+ --color-checks-ansi-yellow: #d4a72c;
+ --color-checks-ansi-yellow-bright: #eac54f;
+ --color-checks-ansi-blue: #54aeff;
+ --color-checks-ansi-blue-bright: #80ccff;
+ --color-checks-ansi-magenta: #c297ff;
+ --color-checks-ansi-magenta-bright: #d8b9ff;
+ --color-checks-ansi-cyan: #76e3ea;
+ --color-checks-ansi-cyan-bright: #b3f0ff;
+ --color-project-header-bg: #24292f;
+ --color-project-sidebar-bg: #ffffff;
+ --color-project-gradient-in: #ffffff;
+ --color-project-gradient-out: rgba(255,255,255,0);
+ --color-mktg-success: rgba(36,146,67,1);
+ --color-mktg-info: rgba(19,119,234,1);
+ --color-mktg-bg-shade-gradient-top: rgba(27,31,36,0.065);
+ --color-mktg-bg-shade-gradient-bottom: rgba(27,31,36,0);
+ --color-mktg-btn-bg-top: hsla(228,82%,66%,1);
+ --color-mktg-btn-bg-bottom: #4969ed;
+ --color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
+ --color-mktg-btn-bg-overlay-bottom: #3355e0;
+ --color-mktg-btn-text: #ffffff;
+ --color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
+ --color-mktg-btn-primary-bg-bottom: #2ea44f;
+ --color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
+ --color-mktg-btn-primary-bg-overlay-bottom: #22863a;
+ --color-mktg-btn-primary-text: #ffffff;
+ --color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
+ --color-mktg-btn-enterprise-bg-bottom: #6f57ff;
+ --color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
+ --color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
+ --color-mktg-btn-enterprise-text: #ffffff;
+ --color-mktg-btn-outline-text: #4969ed;
+ --color-mktg-btn-outline-border: rgba(73,105,237,0.3);
+ --color-mktg-btn-outline-hover-text: #3355e0;
+ --color-mktg-btn-outline-hover-border: rgba(51,85,224,0.5);
+ --color-mktg-btn-outline-focus-border: #4969ed;
+ --color-mktg-btn-outline-focus-border-inset: rgba(73,105,237,0.5);
+ --color-mktg-btn-dark-text: #ffffff;
+ --color-mktg-btn-dark-border: rgba(255,255,255,0.3);
+ --color-mktg-btn-dark-hover-text: #ffffff;
+ --color-mktg-btn-dark-hover-border: rgba(255,255,255,0.5);
+ --color-mktg-btn-dark-focus-border: #ffffff;
+ --color-mktg-btn-dark-focus-border-inset: rgba(255,255,255,0.5);
+ --color-avatar-bg: #ffffff;
+ --color-avatar-border: rgba(27,31,36,0.15);
+ --color-avatar-stack-fade: #afb8c1;
+ --color-avatar-stack-fade-more: #d0d7de;
+ --color-avatar-child-shadow: -2px -2px 0 rgba(255,255,255,0.8);
+ --color-topic-tag-border: rgba(0,0,0,0);
+ --color-select-menu-backdrop-border: rgba(0,0,0,0);
+ --color-select-menu-tap-highlight: rgba(175,184,193,0.5);
+ --color-select-menu-tap-focus-bg: #b6e3ff;
+ --color-overlay-shadow: 0 1px 3px rgba(27,31,36,0.12), 0 8px 24px rgba(66,74,83,0.12);
+ --color-header-text: rgba(255,255,255,0.7);
+ --color-header-bg: #24292f;
+ --color-header-logo: #ffffff;
+ --color-header-search-bg: #24292f;
+ --color-header-search-border: #57606a;
+ --color-sidenav-selected-bg: #ffffff;
+ --color-menu-bg-active: rgba(0,0,0,0);
+ --color-control-transparent-bg-hover: #818b981a;
+ --color-input-disabled-bg: rgba(175,184,193,0.2);
+ --color-timeline-badge-bg: #eaeef2;
+ --color-ansi-black: #24292f;
+ --color-ansi-black-bright: #57606a;
+ --color-ansi-white: #6e7781;
+ --color-ansi-white-bright: #8c959f;
+ --color-ansi-gray: #6e7781;
+ --color-ansi-red: #cf222e;
+ --color-ansi-red-bright: #a40e26;
+ --color-ansi-green: #116329;
+ --color-ansi-green-bright: #1a7f37;
+ --color-ansi-yellow: #4d2d00;
+ --color-ansi-yellow-bright: #633c01;
+ --color-ansi-blue: #0969da;
+ --color-ansi-blue-bright: #218bff;
+ --color-ansi-magenta: #8250df;
+ --color-ansi-magenta-bright: #a475f9;
+ --color-ansi-cyan: #1b7c83;
+ --color-ansi-cyan-bright: #3192aa;
+ --color-btn-text: #24292f;
+ --color-btn-bg: #f6f8fa;
+ --color-btn-border: rgba(27,31,36,0.15);
+ --color-btn-shadow: 0 1px 0 rgba(27,31,36,0.04);
+ --color-btn-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.25);
+ --color-btn-hover-bg: #f3f4f6;
+ --color-btn-hover-border: rgba(27,31,36,0.15);
+ --color-btn-active-bg: hsla(220,14%,93%,1);
+ --color-btn-active-border: rgba(27,31,36,0.15);
+ --color-btn-selected-bg: hsla(220,14%,94%,1);
+ --color-btn-focus-bg: #f6f8fa;
+ --color-btn-focus-border: rgba(27,31,36,0.15);
+ --color-btn-focus-shadow: 0 0 0 3px rgba(9,105,218,0.3);
+ --color-btn-shadow-active: inset 0 0.15em 0.3em rgba(27,31,36,0.15);
+ --color-btn-shadow-input-focus: 0 0 0 0.2em rgba(9,105,218,0.3);
+ --color-btn-counter-bg: rgba(27,31,36,0.08);
+ --color-btn-primary-text: #ffffff;
+ --color-btn-primary-bg: #2da44e;
+ --color-btn-primary-border: rgba(27,31,36,0.15);
+ --color-btn-primary-shadow: 0 1px 0 rgba(27,31,36,0.1);
+ --color-btn-primary-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
+ --color-btn-primary-hover-bg: #2c974b;
+ --color-btn-primary-hover-border: rgba(27,31,36,0.15);
+ --color-btn-primary-selected-bg: hsla(137,55%,36%,1);
+ --color-btn-primary-selected-shadow: inset 0 1px 0 rgba(0,45,17,0.2);
+ --color-btn-primary-disabled-text: rgba(255,255,255,0.8);
+ --color-btn-primary-disabled-bg: #94d3a2;
+ --color-btn-primary-disabled-border: rgba(27,31,36,0.15);
+ --color-btn-primary-focus-bg: #2da44e;
+ --color-btn-primary-focus-border: rgba(27,31,36,0.15);
+ --color-btn-primary-focus-shadow: 0 0 0 3px rgba(45,164,78,0.4);
+ --color-btn-primary-icon: rgba(255,255,255,0.8);
+ --color-btn-primary-counter-bg: rgba(255,255,255,0.2);
+ --color-btn-outline-text: #0969da;
+ --color-btn-outline-hover-text: #ffffff;
+ --color-btn-outline-hover-bg: #0969da;
+ --color-btn-outline-hover-border: rgba(27,31,36,0.15);
+ --color-btn-outline-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
+ --color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
+ --color-btn-outline-hover-counter-bg: rgba(255,255,255,0.2);
+ --color-btn-outline-selected-text: #ffffff;
+ --color-btn-outline-selected-bg: hsla(212,92%,42%,1);
+ --color-btn-outline-selected-border: rgba(27,31,36,0.15);
+ --color-btn-outline-selected-shadow: inset 0 1px 0 rgba(0,33,85,0.2);
+ --color-btn-outline-disabled-text: rgba(9,105,218,0.5);
+ --color-btn-outline-disabled-bg: #f6f8fa;
+ --color-btn-outline-disabled-counter-bg: rgba(9,105,218,0.05);
+ --color-btn-outline-focus-border: rgba(27,31,36,0.15);
+ --color-btn-outline-focus-shadow: 0 0 0 3px rgba(5,80,174,0.4);
+ --color-btn-outline-counter-bg: rgba(9,105,218,0.1);
+ --color-btn-danger-text: #cf222e;
+ --color-btn-danger-hover-text: #ffffff;
+ --color-btn-danger-hover-bg: #a40e26;
+ --color-btn-danger-hover-border: rgba(27,31,36,0.15);
+ --color-btn-danger-hover-shadow: 0 1px 0 rgba(27,31,36,0.1);
+ --color-btn-danger-hover-inset-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
+ --color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
+ --color-btn-danger-selected-text: #ffffff;
+ --color-btn-danger-selected-bg: hsla(356,72%,44%,1);
+ --color-btn-danger-selected-border: rgba(27,31,36,0.15);
+ --color-btn-danger-selected-shadow: inset 0 1px 0 rgba(76,0,20,0.2);
+ --color-btn-danger-disabled-text: rgba(207,34,46,0.5);
+ --color-btn-danger-disabled-bg: #f6f8fa;
+ --color-btn-danger-disabled-counter-bg: rgba(207,34,46,0.05);
+ --color-btn-danger-focus-border: rgba(27,31,36,0.15);
+ --color-btn-danger-focus-shadow: 0 0 0 3px rgba(164,14,38,0.4);
+ --color-btn-danger-counter-bg: rgba(207,34,46,0.1);
+ --color-btn-danger-icon: #cf222e;
+ --color-btn-danger-hover-icon: #ffffff;
+ --color-underlinenav-icon: #6e7781;
+ --color-underlinenav-border-hover: rgba(175,184,193,0.2);
+ --color-fg-default: #24292f;
+ --color-fg-muted: #57606a;
+ --color-fg-subtle: #6e7781;
+ --color-fg-on-emphasis: #ffffff;
+ --color-canvas-default: #ffffff;
+ --color-canvas-overlay: #ffffff;
+ --color-canvas-inset: #f6f8fa;
+ --color-canvas-subtle: #f6f8fa;
+ --color-border-default: #d0d7de;
+ --color-border-muted: hsla(210,18%,87%,1);
+ --color-border-subtle: rgba(27,31,36,0.15);
+ --color-shadow-small: 0 1px 0 rgba(27,31,36,0.04);
+ --color-shadow-medium: 0 3px 6px rgba(140,149,159,0.15);
+ --color-shadow-large: 0 8px 24px rgba(140,149,159,0.2);
+ --color-shadow-extra-large: 0 12px 28px rgba(140,149,159,0.3);
+ --color-neutral-emphasis-plus: #24292f;
+ --color-neutral-emphasis: #6e7781;
+ --color-neutral-muted: rgba(175,184,193,0.2);
+ --color-neutral-subtle: rgba(234,238,242,0.5);
+ --color-accent-fg: #0969da;
+ --color-accent-emphasis: #0969da;
+ --color-accent-muted: rgba(84,174,255,0.4);
+ --color-accent-subtle: #ddf4ff;
+ --color-success-fg: #1a7f37;
+ --color-success-emphasis: #2da44e;
+ --color-success-muted: rgba(74,194,107,0.4);
+ --color-success-subtle: #dafbe1;
+ --color-attention-fg: #9a6700;
+ --color-attention-emphasis: #bf8700;
+ --color-attention-muted: rgba(212,167,44,0.4);
+ --color-attention-subtle: #fff8c5;
+ --color-severe-fg: #bc4c00;
+ --color-severe-emphasis: #bc4c00;
+ --color-severe-muted: rgba(251,143,68,0.4);
+ --color-severe-subtle: #fff1e5;
+ --color-danger-fg: #cf222e;
+ --color-danger-emphasis: #cf222e;
+ --color-danger-muted: rgba(255,129,130,0.4);
+ --color-danger-subtle: #FFEBE9;
+ --color-done-fg: #8250df;
+ --color-done-emphasis: #8250df;
+ --color-done-muted: rgba(194,151,255,0.4);
+ --color-done-subtle: #fbefff;
+ --color-sponsors-fg: #bf3989;
+ --color-sponsors-emphasis: #bf3989;
+ --color-sponsors-muted: rgba(255,128,200,0.4);
+ --color-sponsors-subtle: #ffeff7;
+ --color-primer-canvas-backdrop: rgba(27,31,36,0.5);
+ --color-primer-canvas-sticky: rgba(255,255,255,0.95);
+ --color-primer-border-active: #FD8C73;
+ --color-primer-border-contrast: rgba(27,31,36,0.1);
+ --color-primer-shadow-highlight: inset 0 1px 0 rgba(255,255,255,0.25);
+ --color-primer-shadow-inset: inset 0 1px 0 rgba(208,215,222,0.2);
+ --color-primer-shadow-focus: 0 0 0 3px rgba(9,105,218,0.3);
+ --color-scale-black: #1b1f24;
+ --color-scale-white: #ffffff;
+ --color-scale-gray-0: #f6f8fa;
+ --color-scale-gray-1: #eaeef2;
+ --color-scale-gray-2: #d0d7de;
+ --color-scale-gray-3: #afb8c1;
+ --color-scale-gray-4: #8c959f;
+ --color-scale-gray-5: #6e7781;
+ --color-scale-gray-6: #57606a;
+ --color-scale-gray-7: #424a53;
+ --color-scale-gray-8: #32383f;
+ --color-scale-gray-9: #24292f;
+ --color-scale-blue-0: #ddf4ff;
+ --color-scale-blue-1: #b6e3ff;
+ --color-scale-blue-2: #80ccff;
+ --color-scale-blue-3: #54aeff;
+ --color-scale-blue-4: #218bff;
+ --color-scale-blue-5: #0969da;
+ --color-scale-blue-6: #0550ae;
+ --color-scale-blue-7: #033d8b;
+ --color-scale-blue-8: #0a3069;
+ --color-scale-blue-9: #002155;
+ --color-scale-green-0: #dafbe1;
+ --color-scale-green-1: #aceebb;
+ --color-scale-green-2: #6fdd8b;
+ --color-scale-green-3: #4ac26b;
+ --color-scale-green-4: #2da44e;
+ --color-scale-green-5: #1a7f37;
+ --color-scale-green-6: #116329;
+ --color-scale-green-7: #044f1e;
+ --color-scale-green-8: #003d16;
+ --color-scale-green-9: #002d11;
+ --color-scale-yellow-0: #fff8c5;
+ --color-scale-yellow-1: #fae17d;
+ --color-scale-yellow-2: #eac54f;
+ --color-scale-yellow-3: #d4a72c;
+ --color-scale-yellow-4: #bf8700;
+ --color-scale-yellow-5: #9a6700;
+ --color-scale-yellow-6: #7d4e00;
+ --color-scale-yellow-7: #633c01;
+ --color-scale-yellow-8: #4d2d00;
+ --color-scale-yellow-9: #3b2300;
+ --color-scale-orange-0: #fff1e5;
+ --color-scale-orange-1: #ffd8b5;
+ --color-scale-orange-2: #ffb77c;
+ --color-scale-orange-3: #fb8f44;
+ --color-scale-orange-4: #e16f24;
+ --color-scale-orange-5: #bc4c00;
+ --color-scale-orange-6: #953800;
+ --color-scale-orange-7: #762c00;
+ --color-scale-orange-8: #5c2200;
+ --color-scale-orange-9: #471700;
+ --color-scale-red-0: #FFEBE9;
+ --color-scale-red-1: #ffcecb;
+ --color-scale-red-2: #ffaba8;
+ --color-scale-red-3: #ff8182;
+ --color-scale-red-4: #fa4549;
+ --color-scale-red-5: #cf222e;
+ --color-scale-red-6: #a40e26;
+ --color-scale-red-7: #82071e;
+ --color-scale-red-8: #660018;
+ --color-scale-red-9: #4c0014;
+ --color-scale-purple-0: #fbefff;
+ --color-scale-purple-1: #ecd8ff;
+ --color-scale-purple-2: #d8b9ff;
+ --color-scale-purple-3: #c297ff;
+ --color-scale-purple-4: #a475f9;
+ --color-scale-purple-5: #8250df;
+ --color-scale-purple-6: #6639ba;
+ --color-scale-purple-7: #512a97;
+ --color-scale-purple-8: #3e1f79;
+ --color-scale-purple-9: #2e1461;
+ --color-scale-pink-0: #ffeff7;
+ --color-scale-pink-1: #ffd3eb;
+ --color-scale-pink-2: #ffadda;
+ --color-scale-pink-3: #ff80c8;
+ --color-scale-pink-4: #e85aad;
+ --color-scale-pink-5: #bf3989;
+ --color-scale-pink-6: #99286e;
+ --color-scale-pink-7: #772057;
+ --color-scale-pink-8: #611347;
+ --color-scale-pink-9: #4d0336;
+ --color-scale-coral-0: #FFF0EB;
+ --color-scale-coral-1: #FFD6CC;
+ --color-scale-coral-2: #FFB4A1;
+ --color-scale-coral-3: #FD8C73;
+ --color-scale-coral-4: #EC6547;
+ --color-scale-coral-5: #C4432B;
+ --color-scale-coral-6: #9E2F1C;
+ --color-scale-coral-7: #801F0F;
+ --color-scale-coral-8: #691105;
+ --color-scale-coral-9: #510901
+}
+
+:root.light-mode {
+ color-scheme: light;
+}
+
+:root.dark-mode {
+ color-scheme: dark;
+ --color-canvas-default-transparent: rgba(13,17,23,0);
+ --color-marketing-icon-primary: #79c0ff;
+ --color-marketing-icon-secondary: #1f6feb;
+ --color-diff-blob-addition-num-text: #c9d1d9;
+ --color-diff-blob-addition-fg: #c9d1d9;
+ --color-diff-blob-addition-num-bg: rgba(63,185,80,0.3);
+ --color-diff-blob-addition-line-bg: rgba(46,160,67,0.15);
+ --color-diff-blob-addition-word-bg: rgba(46,160,67,0.4);
+ --color-diff-blob-deletion-num-text: #c9d1d9;
+ --color-diff-blob-deletion-fg: #c9d1d9;
+ --color-diff-blob-deletion-num-bg: rgba(248,81,73,0.3);
+ --color-diff-blob-deletion-line-bg: rgba(248,81,73,0.15);
+ --color-diff-blob-deletion-word-bg: rgba(248,81,73,0.4);
+ --color-diff-blob-hunk-num-bg: rgba(56,139,253,0.4);
+ --color-diff-blob-expander-icon: #8b949e;
+ --color-diff-blob-selected-line-highlight-mix-blend-mode: screen;
+ --color-diffstat-deletion-border: rgba(240,246,252,0.1);
+ --color-diffstat-addition-border: rgba(240,246,252,0.1);
+ --color-diffstat-addition-bg: #3fb950;
+ --color-search-keyword-hl: rgba(210,153,34,0.4);
+ --color-prettylights-syntax-comment: #8b949e;
+ --color-prettylights-syntax-constant: #79c0ff;
+ --color-prettylights-syntax-entity: #d2a8ff;
+ --color-prettylights-syntax-storage-modifier-import: #c9d1d9;
+ --color-prettylights-syntax-entity-tag: #7ee787;
+ --color-prettylights-syntax-keyword: #ff7b72;
+ --color-prettylights-syntax-string: #a5d6ff;
+ --color-prettylights-syntax-variable: #ffa657;
+ --color-prettylights-syntax-brackethighlighter-unmatched: #f85149;
+ --color-prettylights-syntax-invalid-illegal-text: #f0f6fc;
+ --color-prettylights-syntax-invalid-illegal-bg: #8e1519;
+ --color-prettylights-syntax-carriage-return-text: #f0f6fc;
+ --color-prettylights-syntax-carriage-return-bg: #b62324;
+ --color-prettylights-syntax-string-regexp: #7ee787;
+ --color-prettylights-syntax-markup-list: #f2cc60;
+ --color-prettylights-syntax-markup-heading: #1f6feb;
+ --color-prettylights-syntax-markup-italic: #c9d1d9;
+ --color-prettylights-syntax-markup-bold: #c9d1d9;
+ --color-prettylights-syntax-markup-deleted-text: #ffdcd7;
+ --color-prettylights-syntax-markup-deleted-bg: #67060c;
+ --color-prettylights-syntax-markup-inserted-text: #aff5b4;
+ --color-prettylights-syntax-markup-inserted-bg: #033a16;
+ --color-prettylights-syntax-markup-changed-text: #ffdfb6;
+ --color-prettylights-syntax-markup-changed-bg: #5a1e02;
+ --color-prettylights-syntax-markup-ignored-text: #c9d1d9;
+ --color-prettylights-syntax-markup-ignored-bg: #1158c7;
+ --color-prettylights-syntax-meta-diff-range: #d2a8ff;
+ --color-prettylights-syntax-brackethighlighter-angle: #8b949e;
+ --color-prettylights-syntax-sublimelinter-gutter-mark: #484f58;
+ --color-prettylights-syntax-constant-other-reference-link: #a5d6ff;
+ --color-codemirror-text: #c9d1d9;
+ --color-codemirror-bg: #0d1117;
+ --color-codemirror-gutters-bg: #0d1117;
+ --color-codemirror-guttermarker-text: #0d1117;
+ --color-codemirror-guttermarker-subtle-text: #484f58;
+ --color-codemirror-linenumber-text: #8b949e;
+ --color-codemirror-cursor: #c9d1d9;
+ --color-codemirror-selection-bg: rgba(56,139,253,0.4);
+ --color-codemirror-activeline-bg: rgba(110,118,129,0.1);
+ --color-codemirror-matchingbracket-text: #c9d1d9;
+ --color-codemirror-lines-bg: #0d1117;
+ --color-codemirror-syntax-comment: #8b949e;
+ --color-codemirror-syntax-constant: #79c0ff;
+ --color-codemirror-syntax-entity: #d2a8ff;
+ --color-codemirror-syntax-keyword: #ff7b72;
+ --color-codemirror-syntax-storage: #ff7b72;
+ --color-codemirror-syntax-string: #a5d6ff;
+ --color-codemirror-syntax-support: #79c0ff;
+ --color-codemirror-syntax-variable: #ffa657;
+ --color-checks-bg: #010409;
+ --color-checks-run-border-width: 1px;
+ --color-checks-container-border-width: 1px;
+ --color-checks-text-primary: #c9d1d9;
+ --color-checks-text-secondary: #8b949e;
+ --color-checks-text-link: #58a6ff;
+ --color-checks-btn-icon: #8b949e;
+ --color-checks-btn-hover-icon: #c9d1d9;
+ --color-checks-btn-hover-bg: rgba(110,118,129,0.1);
+ --color-checks-input-text: #8b949e;
+ --color-checks-input-placeholder-text: #484f58;
+ --color-checks-input-focus-text: #c9d1d9;
+ --color-checks-input-bg: #161b22;
+ --color-checks-input-shadow: none;
+ --color-checks-donut-error: #f85149;
+ --color-checks-donut-pending: #d29922;
+ --color-checks-donut-success: #2ea043;
+ --color-checks-donut-neutral: #8b949e;
+ --color-checks-dropdown-text: #c9d1d9;
+ --color-checks-dropdown-bg: #161b22;
+ --color-checks-dropdown-border: #30363d;
+ --color-checks-dropdown-shadow: rgba(1,4,9,0.3);
+ --color-checks-dropdown-hover-text: #c9d1d9;
+ --color-checks-dropdown-hover-bg: rgba(110,118,129,0.1);
+ --color-checks-dropdown-btn-hover-text: #c9d1d9;
+ --color-checks-dropdown-btn-hover-bg: rgba(110,118,129,0.1);
+ --color-checks-scrollbar-thumb-bg: rgba(110,118,129,0.4);
+ --color-checks-header-label-text: #8b949e;
+ --color-checks-header-label-open-text: #c9d1d9;
+ --color-checks-header-border: #21262d;
+ --color-checks-header-icon: #8b949e;
+ --color-checks-line-text: #8b949e;
+ --color-checks-line-num-text: #484f58;
+ --color-checks-line-timestamp-text: #484f58;
+ --color-checks-line-hover-bg: rgba(110,118,129,0.1);
+ --color-checks-line-selected-bg: rgba(56,139,253,0.15);
+ --color-checks-line-selected-num-text: #58a6ff;
+ --color-checks-line-dt-fm-text: #f0f6fc;
+ --color-checks-line-dt-fm-bg: #9e6a03;
+ --color-checks-gate-bg: rgba(187,128,9,0.15);
+ --color-checks-gate-text: #8b949e;
+ --color-checks-gate-waiting-text: #d29922;
+ --color-checks-step-header-open-bg: #161b22;
+ --color-checks-step-error-text: #f85149;
+ --color-checks-step-warning-text: #d29922;
+ --color-checks-logline-text: #8b949e;
+ --color-checks-logline-num-text: #484f58;
+ --color-checks-logline-debug-text: #a371f7;
+ --color-checks-logline-error-text: #8b949e;
+ --color-checks-logline-error-num-text: #484f58;
+ --color-checks-logline-error-bg: rgba(248,81,73,0.15);
+ --color-checks-logline-warning-text: #8b949e;
+ --color-checks-logline-warning-num-text: #d29922;
+ --color-checks-logline-warning-bg: rgba(187,128,9,0.15);
+ --color-checks-logline-command-text: #58a6ff;
+ --color-checks-logline-section-text: #3fb950;
+ --color-checks-ansi-black: #0d1117;
+ --color-checks-ansi-black-bright: #161b22;
+ --color-checks-ansi-white: #b1bac4;
+ --color-checks-ansi-white-bright: #b1bac4;
+ --color-checks-ansi-gray: #6e7681;
+ --color-checks-ansi-red: #ff7b72;
+ --color-checks-ansi-red-bright: #ffa198;
+ --color-checks-ansi-green: #3fb950;
+ --color-checks-ansi-green-bright: #56d364;
+ --color-checks-ansi-yellow: #d29922;
+ --color-checks-ansi-yellow-bright: #e3b341;
+ --color-checks-ansi-blue: #58a6ff;
+ --color-checks-ansi-blue-bright: #79c0ff;
+ --color-checks-ansi-magenta: #bc8cff;
+ --color-checks-ansi-magenta-bright: #d2a8ff;
+ --color-checks-ansi-cyan: #76e3ea;
+ --color-checks-ansi-cyan-bright: #b3f0ff;
+ --color-project-header-bg: #0d1117;
+ --color-project-sidebar-bg: #161b22;
+ --color-project-gradient-in: #161b22;
+ --color-project-gradient-out: rgba(22,27,34,0);
+ --color-mktg-success: rgba(41,147,61,1);
+ --color-mktg-info: rgba(42,123,243,1);
+ --color-mktg-bg-shade-gradient-top: rgba(1,4,9,0.065);
+ --color-mktg-bg-shade-gradient-bottom: rgba(1,4,9,0);
+ --color-mktg-btn-bg-top: hsla(228,82%,66%,1);
+ --color-mktg-btn-bg-bottom: #4969ed;
+ --color-mktg-btn-bg-overlay-top: hsla(228,74%,59%,1);
+ --color-mktg-btn-bg-overlay-bottom: #3355e0;
+ --color-mktg-btn-text: #f0f6fc;
+ --color-mktg-btn-primary-bg-top: hsla(137,56%,46%,1);
+ --color-mktg-btn-primary-bg-bottom: #2ea44f;
+ --color-mktg-btn-primary-bg-overlay-top: hsla(134,60%,38%,1);
+ --color-mktg-btn-primary-bg-overlay-bottom: #22863a;
+ --color-mktg-btn-primary-text: #f0f6fc;
+ --color-mktg-btn-enterprise-bg-top: hsla(249,100%,72%,1);
+ --color-mktg-btn-enterprise-bg-bottom: #6f57ff;
+ --color-mktg-btn-enterprise-bg-overlay-top: hsla(248,65%,63%,1);
+ --color-mktg-btn-enterprise-bg-overlay-bottom: #614eda;
+ --color-mktg-btn-enterprise-text: #f0f6fc;
+ --color-mktg-btn-outline-text: #f0f6fc;
+ --color-mktg-btn-outline-border: rgba(240,246,252,0.3);
+ --color-mktg-btn-outline-hover-text: #f0f6fc;
+ --color-mktg-btn-outline-hover-border: rgba(240,246,252,0.5);
+ --color-mktg-btn-outline-focus-border: #f0f6fc;
+ --color-mktg-btn-outline-focus-border-inset: rgba(240,246,252,0.5);
+ --color-mktg-btn-dark-text: #f0f6fc;
+ --color-mktg-btn-dark-border: rgba(240,246,252,0.3);
+ --color-mktg-btn-dark-hover-text: #f0f6fc;
+ --color-mktg-btn-dark-hover-border: rgba(240,246,252,0.5);
+ --color-mktg-btn-dark-focus-border: #f0f6fc;
+ --color-mktg-btn-dark-focus-border-inset: rgba(240,246,252,0.5);
+ --color-avatar-bg: rgba(240,246,252,0.1);
+ --color-avatar-border: rgba(240,246,252,0.1);
+ --color-avatar-stack-fade: #30363d;
+ --color-avatar-stack-fade-more: #21262d;
+ --color-avatar-child-shadow: -2px -2px 0 #0d1117;
+ --color-topic-tag-border: rgba(0,0,0,0);
+ --color-select-menu-backdrop-border: #484f58;
+ --color-select-menu-tap-highlight: rgba(48,54,61,0.5);
+ --color-select-menu-tap-focus-bg: #0c2d6b;
+ --color-overlay-shadow: 0 0 0 1px #30363d, 0 16px 32px rgba(1,4,9,0.85);
+ --color-header-text: rgba(240,246,252,0.7);
+ --color-header-bg: #161b22;
+ --color-header-logo: #f0f6fc;
+ --color-header-search-bg: #0d1117;
+ --color-header-search-border: #30363d;
+ --color-sidenav-selected-bg: #21262d;
+ --color-menu-bg-active: #161b22;
+ --color-control-transparent-bg-hover: #656c7633;
+ --color-input-disabled-bg: rgba(110,118,129,0);
+ --color-timeline-badge-bg: #21262d;
+ --color-ansi-black: #484f58;
+ --color-ansi-black-bright: #6e7681;
+ --color-ansi-white: #b1bac4;
+ --color-ansi-white-bright: #f0f6fc;
+ --color-ansi-gray: #6e7681;
+ --color-ansi-red: #ff7b72;
+ --color-ansi-red-bright: #ffa198;
+ --color-ansi-green: #3fb950;
+ --color-ansi-green-bright: #56d364;
+ --color-ansi-yellow: #d29922;
+ --color-ansi-yellow-bright: #e3b341;
+ --color-ansi-blue: #58a6ff;
+ --color-ansi-blue-bright: #79c0ff;
+ --color-ansi-magenta: #bc8cff;
+ --color-ansi-magenta-bright: #d2a8ff;
+ --color-ansi-cyan: #39c5cf;
+ --color-ansi-cyan-bright: #56d4dd;
+ --color-btn-text: #c9d1d9;
+ --color-btn-bg: #21262d;
+ --color-btn-border: rgba(240,246,252,0.1);
+ --color-btn-shadow: 0 0 transparent;
+ --color-btn-inset-shadow: 0 0 transparent;
+ --color-btn-hover-bg: #30363d;
+ --color-btn-hover-border: #8b949e;
+ --color-btn-active-bg: hsla(212,12%,18%,1);
+ --color-btn-active-border: #6e7681;
+ --color-btn-selected-bg: #161b22;
+ --color-btn-focus-bg: #21262d;
+ --color-btn-focus-border: #8b949e;
+ --color-btn-focus-shadow: 0 0 0 3px rgba(139,148,158,0.3);
+ --color-btn-shadow-active: inset 0 0.15em 0.3em rgba(1,4,9,0.15);
+ --color-btn-shadow-input-focus: 0 0 0 0.2em rgba(31,111,235,0.3);
+ --color-btn-counter-bg: #30363d;
+ --color-btn-primary-text: #ffffff;
+ --color-btn-primary-bg: #238636;
+ --color-btn-primary-border: rgba(240,246,252,0.1);
+ --color-btn-primary-shadow: 0 0 transparent;
+ --color-btn-primary-inset-shadow: 0 0 transparent;
+ --color-btn-primary-hover-bg: #2ea043;
+ --color-btn-primary-hover-border: rgba(240,246,252,0.1);
+ --color-btn-primary-selected-bg: #238636;
+ --color-btn-primary-selected-shadow: 0 0 transparent;
+ --color-btn-primary-disabled-text: rgba(240,246,252,0.5);
+ --color-btn-primary-disabled-bg: rgba(35,134,54,0.6);
+ --color-btn-primary-disabled-border: rgba(240,246,252,0.1);
+ --color-btn-primary-focus-bg: #238636;
+ --color-btn-primary-focus-border: rgba(240,246,252,0.1);
+ --color-btn-primary-focus-shadow: 0 0 0 3px rgba(46,164,79,0.4);
+ --color-btn-primary-icon: #f0f6fc;
+ --color-btn-primary-counter-bg: rgba(240,246,252,0.2);
+ --color-btn-outline-text: #58a6ff;
+ --color-btn-outline-hover-text: #58a6ff;
+ --color-btn-outline-hover-bg: #30363d;
+ --color-btn-outline-hover-border: rgba(240,246,252,0.1);
+ --color-btn-outline-hover-shadow: 0 1px 0 rgba(1,4,9,0.1);
+ --color-btn-outline-hover-inset-shadow: inset 0 1px 0 rgba(240,246,252,0.03);
+ --color-btn-outline-hover-counter-bg: rgba(240,246,252,0.2);
+ --color-btn-outline-selected-text: #f0f6fc;
+ --color-btn-outline-selected-bg: #0d419d;
+ --color-btn-outline-selected-border: rgba(240,246,252,0.1);
+ --color-btn-outline-selected-shadow: 0 0 transparent;
+ --color-btn-outline-disabled-text: rgba(88,166,255,0.5);
+ --color-btn-outline-disabled-bg: #0d1117;
+ --color-btn-outline-disabled-counter-bg: rgba(31,111,235,0.05);
+ --color-btn-outline-focus-border: rgba(240,246,252,0.1);
+ --color-btn-outline-focus-shadow: 0 0 0 3px rgba(17,88,199,0.4);
+ --color-btn-outline-counter-bg: rgba(31,111,235,0.1);
+ --color-btn-danger-text: #f85149;
+ --color-btn-danger-hover-text: #f0f6fc;
+ --color-btn-danger-hover-bg: #da3633;
+ --color-btn-danger-hover-border: #f85149;
+ --color-btn-danger-hover-shadow: 0 0 transparent;
+ --color-btn-danger-hover-inset-shadow: 0 0 transparent;
+ --color-btn-danger-hover-icon: #f0f6fc;
+ --color-btn-danger-hover-counter-bg: rgba(255,255,255,0.2);
+ --color-btn-danger-selected-text: #ffffff;
+ --color-btn-danger-selected-bg: #b62324;
+ --color-btn-danger-selected-border: #ff7b72;
+ --color-btn-danger-selected-shadow: 0 0 transparent;
+ --color-btn-danger-disabled-text: rgba(248,81,73,0.5);
+ --color-btn-danger-disabled-bg: #0d1117;
+ --color-btn-danger-disabled-counter-bg: rgba(218,54,51,0.05);
+ --color-btn-danger-focus-border: #f85149;
+ --color-btn-danger-focus-shadow: 0 0 0 3px rgba(248,81,73,0.4);
+ --color-btn-danger-counter-bg: rgba(218,54,51,0.1);
+ --color-btn-danger-icon: #f85149;
+ --color-underlinenav-icon: #484f58;
+ --color-underlinenav-border-hover: rgba(110,118,129,0.4);
+ --color-fg-default: #c9d1d9;
+ --color-fg-muted: #8b949e;
+ --color-fg-subtle: #484f58;
+ --color-fg-on-emphasis: #f0f6fc;
+ --color-canvas-default: #0d1117;
+ --color-canvas-overlay: #161b22;
+ --color-canvas-inset: #010409;
+ --color-canvas-subtle: #161b22;
+ --color-border-default: #30363d;
+ --color-border-muted: #21262d;
+ --color-border-subtle: rgba(240,246,252,0.1);
+ --color-shadow-small: 0 0 transparent;
+ --color-shadow-medium: 0 3px 6px #010409;
+ --color-shadow-large: 0 8px 24px #010409;
+ --color-shadow-extra-large: 0 12px 48px #010409;
+ --color-neutral-emphasis-plus: #6e7681;
+ --color-neutral-emphasis: #6e7681;
+ --color-neutral-muted: rgba(110,118,129,0.4);
+ --color-neutral-subtle: rgba(110,118,129,0.1);
+ --color-accent-fg: #58a6ff;
+ --color-accent-emphasis: #1f6feb;
+ --color-accent-muted: rgba(56,139,253,0.4);
+ --color-accent-subtle: rgba(56,139,253,0.15);
+ --color-success-fg: #3fb950;
+ --color-success-emphasis: #238636;
+ --color-success-muted: rgba(46,160,67,0.4);
+ --color-success-subtle: rgba(46,160,67,0.15);
+ --color-attention-fg: #d29922;
+ --color-attention-emphasis: #9e6a03;
+ --color-attention-muted: rgba(187,128,9,0.4);
+ --color-attention-subtle: rgba(187,128,9,0.15);
+ --color-severe-fg: #db6d28;
+ --color-severe-emphasis: #bd561d;
+ --color-severe-muted: rgba(219,109,40,0.4);
+ --color-severe-subtle: rgba(219,109,40,0.15);
+ --color-danger-fg: #f85149;
+ --color-danger-emphasis: #da3633;
+ --color-danger-muted: rgba(248,81,73,0.4);
+ --color-danger-subtle: rgba(248,81,73,0.15);
+ --color-done-fg: #a371f7;
+ --color-done-emphasis: #8957e5;
+ --color-done-muted: rgba(163,113,247,0.4);
+ --color-done-subtle: rgba(163,113,247,0.15);
+ --color-sponsors-fg: #db61a2;
+ --color-sponsors-emphasis: #bf4b8a;
+ --color-sponsors-muted: rgba(219,97,162,0.4);
+ --color-sponsors-subtle: rgba(219,97,162,0.15);
+ --color-primer-canvas-backdrop: rgba(1,4,9,0.8);
+ --color-primer-canvas-sticky: rgba(13,17,23,0.95);
+ --color-primer-border-active: #F78166;
+ --color-primer-border-contrast: rgba(240,246,252,0.2);
+ --color-primer-shadow-highlight: 0 0 transparent;
+ --color-primer-shadow-inset: 0 0 transparent;
+ --color-primer-shadow-focus: 0 0 0 3px #0c2d6b;
+ --color-scale-black: #010409;
+ --color-scale-white: #f0f6fc;
+ --color-scale-gray-0: #f0f6fc;
+ --color-scale-gray-1: #c9d1d9;
+ --color-scale-gray-2: #b1bac4;
+ --color-scale-gray-3: #8b949e;
+ --color-scale-gray-4: #6e7681;
+ --color-scale-gray-5: #484f58;
+ --color-scale-gray-6: #30363d;
+ --color-scale-gray-7: #21262d;
+ --color-scale-gray-8: #161b22;
+ --color-scale-gray-9: #0d1117;
+ --color-scale-blue-0: #cae8ff;
+ --color-scale-blue-1: #a5d6ff;
+ --color-scale-blue-2: #79c0ff;
+ --color-scale-blue-3: #58a6ff;
+ --color-scale-blue-4: #388bfd;
+ --color-scale-blue-5: #1f6feb;
+ --color-scale-blue-6: #1158c7;
+ --color-scale-blue-7: #0d419d;
+ --color-scale-blue-8: #0c2d6b;
+ --color-scale-blue-9: #051d4d;
+ --color-scale-green-0: #aff5b4;
+ --color-scale-green-1: #7ee787;
+ --color-scale-green-2: #56d364;
+ --color-scale-green-3: #3fb950;
+ --color-scale-green-4: #2ea043;
+ --color-scale-green-5: #238636;
+ --color-scale-green-6: #196c2e;
+ --color-scale-green-7: #0f5323;
+ --color-scale-green-8: #033a16;
+ --color-scale-green-9: #04260f;
+ --color-scale-yellow-0: #f8e3a1;
+ --color-scale-yellow-1: #f2cc60;
+ --color-scale-yellow-2: #e3b341;
+ --color-scale-yellow-3: #d29922;
+ --color-scale-yellow-4: #bb8009;
+ --color-scale-yellow-5: #9e6a03;
+ --color-scale-yellow-6: #845306;
+ --color-scale-yellow-7: #693e00;
+ --color-scale-yellow-8: #4b2900;
+ --color-scale-yellow-9: #341a00;
+ --color-scale-orange-0: #ffdfb6;
+ --color-scale-orange-1: #ffc680;
+ --color-scale-orange-2: #ffa657;
+ --color-scale-orange-3: #f0883e;
+ --color-scale-orange-4: #db6d28;
+ --color-scale-orange-5: #bd561d;
+ --color-scale-orange-6: #9b4215;
+ --color-scale-orange-7: #762d0a;
+ --color-scale-orange-8: #5a1e02;
+ --color-scale-orange-9: #3d1300;
+ --color-scale-red-0: #ffdcd7;
+ --color-scale-red-1: #ffc1ba;
+ --color-scale-red-2: #ffa198;
+ --color-scale-red-3: #ff7b72;
+ --color-scale-red-4: #f85149;
+ --color-scale-red-5: #da3633;
+ --color-scale-red-6: #b62324;
+ --color-scale-red-7: #8e1519;
+ --color-scale-red-8: #67060c;
+ --color-scale-red-9: #490202;
+ --color-scale-purple-0: #eddeff;
+ --color-scale-purple-1: #e2c5ff;
+ --color-scale-purple-2: #d2a8ff;
+ --color-scale-purple-3: #bc8cff;
+ --color-scale-purple-4: #a371f7;
+ --color-scale-purple-5: #8957e5;
+ --color-scale-purple-6: #6e40c9;
+ --color-scale-purple-7: #553098;
+ --color-scale-purple-8: #3c1e70;
+ --color-scale-purple-9: #271052;
+ --color-scale-pink-0: #ffdaec;
+ --color-scale-pink-1: #ffbedd;
+ --color-scale-pink-2: #ff9bce;
+ --color-scale-pink-3: #f778ba;
+ --color-scale-pink-4: #db61a2;
+ --color-scale-pink-5: #bf4b8a;
+ --color-scale-pink-6: #9e3670;
+ --color-scale-pink-7: #7d2457;
+ --color-scale-pink-8: #5e103e;
+ --color-scale-pink-9: #42062a;
+ --color-scale-coral-0: #FFDDD2;
+ --color-scale-coral-1: #FFC2B2;
+ --color-scale-coral-2: #FFA28B;
+ --color-scale-coral-3: #F78166;
+ --color-scale-coral-4: #EA6045;
+ --color-scale-coral-5: #CF462D;
+ --color-scale-coral-6: #AC3220;
+ --color-scale-coral-7: #872012;
+ --color-scale-coral-8: #640D04;
+ --color-scale-coral-9: #460701
+}
diff --git a/packages/coverage-report/src/common.css b/packages/coverage-report/src/common.css
new file mode 100644
index 0000000000000..f51e82fb39375
--- /dev/null
+++ b/packages/coverage-report/src/common.css
@@ -0,0 +1,86 @@
+/*
+ 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.
+*/
+
+* {
+ box-sizing: border-box;
+ min-width: 0;
+ min-height: 0;
+}
+
+svg {
+ fill: currentColor;
+}
+
+.form-control, .form-select {
+ padding: 5px 12px;
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--color-fg-default);
+ vertical-align: middle;
+ background-color: var(--color-canvas-default);
+ background-repeat: no-repeat;
+ background-position: right 8px center;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: var(--color-primer-shadow-inset);
+}
+
+.input-contrast {
+ background-color: var(--color-canvas-inset);
+}
+
+.subnav-search {
+ position: relative;
+ flex: auto;
+ display: flex;
+}
+
+.subnav-search-input {
+ flex: auto;
+ padding-left: 32px;
+ color: var(--color-fg-muted);
+}
+
+.subnav-search-icon {
+ position: absolute;
+ top: 9px;
+ left: 8px;
+ display: block;
+ color: var(--color-fg-muted);
+ text-align: center;
+ pointer-events: none;
+}
+
+.octicon {
+ display: inline-block;
+ overflow: visible !important;
+ vertical-align: text-bottom;
+ fill: currentColor;
+ margin-right: 7px;
+ flex: none;
+}
+
+.color-fg-muted {
+ color: var(--color-fg-muted) !important;
+}
+
+#root {
+ color: var(--color-fg-default);
+ font-size: 14px;
+ font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
+ -webkit-font-smoothing: antialiased;
+}
diff --git a/packages/coverage-report/src/coverageModel.ts b/packages/coverage-report/src/coverageModel.ts
new file mode 100644
index 0000000000000..cbcd23b1c07fb
--- /dev/null
+++ b/packages/coverage-report/src/coverageModel.ts
@@ -0,0 +1,198 @@
+/**
+ * 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.
+ */
+
+import { addCoverageSummary, coveragePercent, emptyCoverageSummary, lineCoverage } from '@isomorphic/istanbulCoverage';
+
+import type { CoverageFile, CoverageFileSummary, CoverageMetric, CoverageSummary, IstanbulRange } from '@isomorphic/istanbulCoverage';
+
+export type CoverageTreeNode = {
+ name: string;
+ path: string;
+ fileId?: string;
+ summary: CoverageSummary;
+ children: CoverageTreeNode[];
+};
+
+export type CoverageLevel = 'high' | 'medium' | 'low';
+
+// Same thresholds as nyc.
+export function coverageLevel(metric: CoverageMetric): CoverageLevel {
+ const percent = coveragePercent(metric);
+ if (percent >= 80)
+ return 'high';
+ if (percent >= 50)
+ return 'medium';
+ return 'low';
+}
+
+// File paths are posix, the reporter normalizes them.
+export function buildCoverageTree(files: CoverageFileSummary[]): CoverageTreeNode {
+ const entries = files.map(file => ({ file, segments: file.path.split('/').filter(Boolean) }));
+ let rootSegments = entries.length ? entries[0].segments.slice(0, -1) : [];
+ for (const { segments } of entries) {
+ let i = 0;
+ while (i < rootSegments.length && i < segments.length - 1 && rootSegments[i] === segments[i])
+ ++i;
+ rootSegments = rootSegments.slice(0, i);
+ }
+
+ const absolute = entries.length > 0 && entries.every(({ file }) => file.path.startsWith('/'));
+ const root: CoverageTreeNode = { name: '', path: (absolute ? '/' : '') + rootSegments.join('/'), summary: emptyCoverageSummary(), children: [] };
+ const directories = new Map();
+ for (const { file, segments } of entries) {
+ let node = root;
+ const relative = segments.slice(rootSegments.length);
+ for (let i = 0; i < relative.length - 1; i++) {
+ const dirPath = relative.slice(0, i + 1).join('/');
+ let child = directories.get(dirPath);
+ if (!child) {
+ child = { name: relative[i], path: dirPath, summary: emptyCoverageSummary(), children: [] };
+ directories.set(dirPath, child);
+ node.children.push(child);
+ }
+ node = child;
+ }
+ node.children.push({ name: relative[relative.length - 1], path: relative.join('/'), fileId: file.fileId, summary: file.summary, children: [] });
+ }
+ finalizeNode(root);
+ return root;
+}
+
+function finalizeNode(node: CoverageTreeNode) {
+ for (const child of node.children) {
+ if (!child.fileId) {
+ // Collapse chains of single-directory folders.
+ while (child.children.length === 1 && !child.children[0].fileId) {
+ const only = child.children[0];
+ child.name = child.name + '/' + only.name;
+ child.path = only.path;
+ child.children = only.children;
+ }
+ finalizeNode(child);
+ }
+ addCoverageSummary(node.summary, child.summary);
+ }
+ node.children.sort((a, b) => {
+ if (!!a.fileId !== !!b.fileId)
+ return a.fileId ? 1 : -1;
+ return a.name.localeCompare(b.name);
+ });
+}
+
+export type CoverageSegmentKind = 'statement' | 'function' | 'branch';
+
+export type CoverageSegment = {
+ text: string;
+ kinds: CoverageSegmentKind[];
+};
+
+// 'I' - the if path was not taken, 'E' - the else path was not taken.
+export type CoverageBranchMarker = 'I' | 'E';
+
+export type CoverageLine = {
+ number: number;
+ count?: number;
+ segments: CoverageSegment[];
+ markers: CoverageBranchMarker[];
+};
+
+type Mark = { start: number, end: number, kind: CoverageSegmentKind };
+
+export function annotateCoverage(file: CoverageFile): CoverageLine[] {
+ const source = file.source ?? '';
+ const textLines = source.split(/\r\n|\r|\n/);
+ if (textLines.length > 1 && textLines[textLines.length - 1] === '')
+ textLines.pop();
+ const { coverage } = file;
+ const marksByLine = new Map();
+ const markers = new Map();
+
+ const addRange = (range: IstanbulRange, kind: CoverageSegmentKind) => {
+ if (!range?.start || range.start.line === undefined)
+ return;
+ const endLine = range.end?.line ?? range.start.line;
+ for (let line = range.start.line; line <= endLine; line++) {
+ const text = textLines[line - 1];
+ if (text === undefined)
+ break;
+ const start = line === range.start.line ? range.start.column : 0;
+ const end = line === endLine ? Math.min(range.end?.column ?? text.length, text.length) : text.length;
+ if (start >= end)
+ continue;
+ const marks = marksByLine.get(line) || [];
+ marks.push({ start, end, kind });
+ marksByLine.set(line, marks);
+ }
+ };
+
+ for (const [key, range] of Object.entries(coverage.statementMap)) {
+ if (!coverage.s[key])
+ addRange(range, 'statement');
+ }
+ for (const [key, fn] of Object.entries(coverage.fnMap)) {
+ if (!coverage.f[key])
+ addRange(fn.decl, 'function');
+ }
+ for (const [key, branch] of Object.entries(coverage.branchMap)) {
+ const counts = coverage.b[key] || [];
+ branch.locations.forEach((location, i) => {
+ if (counts[i])
+ return;
+ const implicit = !location?.start || location.start.line === undefined;
+ if (branch.type === 'if' || implicit) {
+ const line = branch.loc.start.line;
+ const list = markers.get(line) || [];
+ list.push(branch.type === 'if' && i === 0 ? 'I' : 'E');
+ markers.set(line, list);
+ return;
+ }
+ addRange(location, 'branch');
+ });
+ }
+
+ const counts = lineCoverage(coverage);
+ return textLines.map((text, index) => {
+ const number = index + 1;
+ return {
+ number,
+ count: counts.get(number),
+ segments: splitLine(text, marksByLine.get(number) || []),
+ markers: markers.get(number) || [],
+ };
+ });
+}
+
+function splitLine(text: string, marks: Mark[]): CoverageSegment[] {
+ if (!marks.length)
+ return [{ text, kinds: [] }];
+ const boundaries = new Set([0, text.length]);
+ for (const mark of marks) {
+ boundaries.add(mark.start);
+ boundaries.add(mark.end);
+ }
+ const points = [...boundaries].sort((a, b) => a - b);
+ const segments: CoverageSegment[] = [];
+ for (let i = 0; i < points.length - 1; i++) {
+ const [start, end] = [points[i], points[i + 1]];
+ const kinds = new Set();
+ for (const mark of marks) {
+ if (mark.start <= start && mark.end >= end)
+ kinds.add(mark.kind);
+ }
+ segments.push({ text: text.slice(start, end), kinds: [...kinds] });
+ }
+ return segments;
+}
diff --git a/packages/coverage-report/src/coverageView.css b/packages/coverage-report/src/coverageView.css
new file mode 100644
index 0000000000000..2b87754950fb0
--- /dev/null
+++ b/packages/coverage-report/src/coverageView.css
@@ -0,0 +1,303 @@
+/*
+ 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.
+*/
+
+
+
+.coverage-view {
+ padding: 12px 8px 0 8px;
+}
+
+.coverage-view a {
+ color: var(--color-accent-fg);
+ text-decoration: none;
+}
+
+.coverage-title {
+ font-weight: 400;
+ font-size: 32px;
+ line-height: 1.25;
+}
+
+.coverage-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 12px 0;
+}
+
+.coverage-metric {
+ flex: 1 1 140px;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ padding: 8px 12px;
+ background-color: var(--color-canvas-subtle);
+}
+
+.coverage-metric-label {
+ color: var(--color-fg-muted);
+ font-size: 12px;
+ text-transform: capitalize;
+}
+
+.coverage-metric .coverage-percent {
+ font-size: 20px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.coverage-metric .coverage-fraction {
+ font-size: 12px;
+ margin-left: 6px;
+}
+
+.coverage-fraction {
+ color: var(--color-fg-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.coverage-bar {
+ height: 6px;
+ border-radius: 3px;
+ background-color: var(--color-neutral-muted);
+ overflow: hidden;
+ min-width: 60px;
+}
+
+.coverage-bar-fill {
+ height: 100%;
+}
+
+.coverage-high .coverage-bar-fill { background-color: var(--color-success-emphasis); }
+.coverage-medium .coverage-bar-fill { background-color: var(--color-attention-emphasis); }
+.coverage-low .coverage-bar-fill { background-color: var(--color-danger-emphasis); }
+.coverage-high .coverage-percent { color: var(--color-success-fg); }
+.coverage-medium .coverage-percent { color: var(--color-attention-fg); }
+.coverage-low .coverage-percent { color: var(--color-danger-fg); }
+
+.coverage-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 12px 0;
+}
+
+.coverage-toolbar .subnav-search {
+ margin: 0;
+ flex: auto;
+ max-width: 400px;
+}
+
+.coverage-breadcrumbs {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin: 12px 0;
+ font-size: 16px;
+}
+
+.coverage-breadcrumbs-separator {
+ color: var(--color-fg-muted);
+}
+
+.coverage-files {
+ width: 100%;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ border-spacing: 0;
+ margin-bottom: 12px;
+ overflow: hidden;
+}
+
+.coverage-files th {
+ text-align: left;
+ font-weight: 600;
+ padding: 8px;
+ background-color: var(--color-canvas-subtle);
+ border-bottom: 1px solid var(--color-border-default);
+ white-space: nowrap;
+}
+
+.coverage-files td {
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--color-border-muted);
+ white-space: nowrap;
+ vertical-align: middle;
+}
+
+.coverage-files tr:last-child td {
+ border-bottom: none;
+}
+
+.coverage-files tbody tr:hover {
+ background-color: var(--color-canvas-subtle);
+}
+
+.coverage-files-name {
+ width: 100%;
+ min-width: 240px;
+ white-space: normal !important;
+ overflow-wrap: anywhere;
+}
+
+.coverage-files-toggle {
+ background: none;
+ border: none;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.coverage-files-toggle:focus-visible {
+ outline: 1px solid var(--color-accent-fg);
+ border-radius: 3px;
+}
+
+.coverage-files-metric {
+ text-align: right;
+}
+
+.coverage-cell {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ justify-content: flex-end;
+}
+
+.coverage-cell .coverage-percent {
+ min-width: 60px;
+ text-align: right;
+}
+
+.coverage-cell .coverage-fraction {
+ min-width: 70px;
+ text-align: right;
+}
+
+.coverage-empty {
+ color: var(--color-fg-muted);
+ padding: 16px;
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+}
+
+.coverage-source {
+ border: 1px solid var(--color-border-default);
+ border-radius: 6px;
+ margin-bottom: 12px;
+ overflow-x: auto;
+ font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
+ font-size: 12px;
+ line-height: 20px;
+ background-color: var(--color-canvas-default);
+}
+
+.coverage-source table {
+ border-spacing: 0;
+ min-width: 100%;
+}
+
+.coverage-source td {
+ padding: 0 8px;
+ vertical-align: top;
+}
+
+.coverage-line-number {
+ text-align: right;
+ color: var(--color-fg-subtle);
+ user-select: none;
+ width: 1%;
+ min-width: 40px;
+}
+
+.coverage-line-count {
+ text-align: right;
+ color: var(--color-fg-muted);
+ user-select: none;
+ width: 1%;
+ min-width: 40px;
+ border-right: 1px solid var(--color-border-muted);
+}
+
+.coverage-line-covered .coverage-line-count {
+ color: var(--color-success-fg);
+ background-color: var(--color-success-subtle);
+}
+
+.coverage-line-uncovered .coverage-line-count {
+ color: var(--color-danger-fg);
+ background-color: var(--color-danger-subtle);
+}
+
+.coverage-line-uncovered .coverage-line-code {
+ background-color: var(--color-danger-subtle);
+}
+
+.coverage-line-code {
+ white-space: pre;
+}
+
+.coverage-segment-statement {
+ background-color: var(--color-danger-muted);
+}
+
+.coverage-segment-function {
+ background-color: var(--color-danger-muted);
+ text-decoration: underline wavy var(--color-danger-fg);
+}
+
+.coverage-segment-branch {
+ background-color: var(--color-attention-muted);
+}
+
+.coverage-branch-marker {
+ display: inline-block;
+ min-width: 14px;
+ padding: 0 3px;
+ margin-right: 4px;
+ border-radius: 3px;
+ background-color: var(--color-attention-emphasis);
+ color: var(--color-fg-on-emphasis);
+ font-size: 10px;
+ line-height: 14px;
+ text-align: center;
+ cursor: default;
+}
+
+.coverage-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ color: var(--color-fg-muted);
+ font-size: 12px;
+ margin: 8px 0 12px;
+}
+
+.coverage-legend span {
+ padding: 0 4px;
+ border-radius: 3px;
+}
+
+.coverage-root-path {
+ color: var(--color-fg-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/packages/coverage-report/src/coverageView.spec.ts b/packages/coverage-report/src/coverageView.spec.ts
new file mode 100644
index 0000000000000..4076495bfd51d
--- /dev/null
+++ b/packages/coverage-report/src/coverageView.spec.ts
@@ -0,0 +1,85 @@
+/**
+ * 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.
+ */
+
+import { expect, test } from '@playwright/test';
+
+test.use({ viewport: { width: 1000, height: 800 } });
+
+test('should render summary and file tree', async ({ mount }) => {
+ const component = await mount('coverage-report/coverageView/Default');
+ await expect(component.getByTestId('coverage-metric-statements')).toContainText('57.14%');
+ await expect(component.getByTestId('coverage-metric-statements')).toContainText('4/7');
+ await expect(component.getByTestId('coverage-metric-branches')).toContainText('25.00%');
+ await expect(component.getByTestId('coverage-metric-functions')).toContainText('66.67%');
+ await expect(component.getByTestId('coverage-metric-lines')).toContainText('57.14%');
+
+ await expect(component.getByTestId('coverage-directory')).toHaveText([/lib/, /src/, /components/]);
+ await expect(component.getByTestId('coverage-file')).toHaveText([/util\.js/, /components\/button\.tsx|button\.tsx/, /app\.js/]);
+ await expect(component.getByTestId('coverage-directory').filter({ hasText: 'src' }).first()).toContainText('66.67%');
+ await expect(component.getByTestId('coverage-directory').filter({ hasText: 'src' }).first()).toContainText('4/6');
+ await expect(component.getByText('/home/user/project')).toBeVisible();
+});
+
+test('should collapse directories', async ({ mount }) => {
+ const component = await mount('coverage-report/coverageView/Default');
+ await component.getByRole('button', { name: 'src', exact: true }).click();
+ await expect(component.getByTestId('coverage-file')).toHaveText([/util\.js/]);
+ await component.getByRole('button', { name: 'src', exact: true }).click();
+ await expect(component.getByTestId('coverage-file')).toHaveCount(3);
+});
+
+test('should filter files', async ({ mount }) => {
+ const component = await mount('coverage-report/coverageView/Default');
+ await component.getByLabel('Filter files').fill('button');
+ await expect(component.getByTestId('coverage-file')).toHaveText([/button\.tsx/]);
+ await expect(component.getByTestId('coverage-directory')).toHaveText([/src/, /components/]);
+ await component.getByLabel('Filter files').fill('nothing');
+ await expect(component.getByText('No files match the filter.')).toBeVisible();
+});
+
+test('should show annotated source', async ({ mount, page }) => {
+ const component = await mount('coverage-report/coverageView/Default');
+ await component.getByRole('link', { name: 'app.js' }).click();
+ await expect(page).toHaveURL(/coverageFile=file-0/);
+ await expect(component.getByText('src/app.js')).toBeVisible();
+ await expect(component.getByTestId('coverage-metric-statements')).toContainText('50.00%');
+
+ const source = component.getByTestId('coverage-source');
+ await expect(source.locator('tr')).toHaveCount(9);
+ await expect(source.locator('.coverage-line-covered')).toHaveCount(2);
+ await expect(source.locator('.coverage-line-uncovered')).toHaveCount(2);
+ await expect(source.locator('[data-line="3"] .coverage-line-count')).toHaveText('3x');
+ await expect(source.locator('[data-line="4"] .coverage-line-count')).toHaveText('0x');
+ await expect(source.locator('[data-line="4"] .coverage-segment-statement')).toHaveText(`return 'Hello';`);
+ await expect(source.locator('[data-line="2"] .coverage-branch-marker')).toHaveText('E');
+ await expect(source.locator('[data-line="7"] .coverage-segment-function')).toHaveText('unused');
+ await expect(source.locator('[data-line="8"] .coverage-segment-branch')).toHaveText([`'yes'`, `'no'`]);
+
+ await component.getByRole('link', { name: 'All files' }).click();
+ await expect(component.getByTestId('coverage-files')).toBeVisible();
+});
+
+test('should report missing source', async ({ mount }) => {
+ const component = await mount('coverage-report/coverageView/Default');
+ await component.getByRole('link', { name: 'util.js' }).click();
+ await expect(component.getByText('Source is not available for /home/user/project/lib/util.js.')).toBeVisible();
+});
+
+test('should render empty report', async ({ mount }) => {
+ const component = await mount('coverage-report/coverageView/Empty');
+ await expect(component.getByTestId('coverage-metric-statements')).toContainText('100.00%');
+ await expect(component.getByText('No files match the filter.')).toBeVisible();
+});
diff --git a/packages/coverage-report/src/coverageView.story.tsx b/packages/coverage-report/src/coverageView.story.tsx
new file mode 100644
index 0000000000000..7e95151a2a06a
--- /dev/null
+++ b/packages/coverage-report/src/coverageView.story.tsx
@@ -0,0 +1,103 @@
+/**
+ * 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.
+ */
+
+import * as React from 'react';
+import { CoverageView } from './coverageView';
+import { createMemoryCoverage } from './loadedCoverage';
+
+import type { CoverageFile } from '@isomorphic/istanbulCoverage';
+
+const appSource = [
+ `export function greet(name) {`,
+ ` if (name)`,
+ ` return 'Hello, ' + name;`,
+ ` return 'Hello';`,
+ `}`,
+ ``,
+ `export function unused(value) {`,
+ ` return value ? 'yes' : 'no';`,
+ `}`,
+].join('\n');
+
+const app: CoverageFile = {
+ path: '/home/user/project/src/app.js',
+ source: appSource,
+ coverage: {
+ path: '/home/user/project/src/app.js',
+ statementMap: {
+ '0': { start: { line: 2, column: 2 }, end: { line: 4, column: 17 } },
+ '1': { start: { line: 3, column: 4 }, end: { line: 3, column: 28 } },
+ '2': { start: { line: 4, column: 2 }, end: { line: 4, column: 17 } },
+ '3': { start: { line: 8, column: 2 }, end: { line: 8, column: 30 } },
+ },
+ fnMap: {
+ '0': { name: 'greet', decl: { start: { line: 1, column: 16 }, end: { line: 1, column: 21 } }, loc: { start: { line: 1, column: 28 }, end: { line: 5, column: 1 } }, line: 1 },
+ '1': { name: 'unused', decl: { start: { line: 7, column: 16 }, end: { line: 7, column: 22 } }, loc: { start: { line: 7, column: 30 }, end: { line: 9, column: 1 } }, line: 7 },
+ },
+ branchMap: {
+ '0': { type: 'if', loc: { start: { line: 2, column: 2 }, end: { line: 4, column: 17 } }, locations: [{ start: { line: 2, column: 2 }, end: { line: 4, column: 17 } }, { start: {} as any, end: {} as any }], line: 2 },
+ '1': { type: 'cond-expr', loc: { start: { line: 8, column: 9 }, end: { line: 8, column: 29 } }, locations: [{ start: { line: 8, column: 17 }, end: { line: 8, column: 22 } }, { start: { line: 8, column: 25 }, end: { line: 8, column: 29 } }], line: 8 },
+ },
+ s: { '0': 3, '1': 3, '2': 0, '3': 0 },
+ f: { '0': 3, '1': 0 },
+ b: { '0': [3, 0], '1': [0, 0] },
+ },
+};
+
+const button: CoverageFile = {
+ path: '/home/user/project/src/components/button.tsx',
+ source: [
+ `export const Button = ({ label }) => {`,
+ ` return {label} ;`,
+ `};`,
+ ].join('\n'),
+ coverage: {
+ path: '/home/user/project/src/components/button.tsx',
+ statementMap: {
+ '0': { start: { line: 1, column: 22 }, end: { line: 3, column: 1 } },
+ '1': { start: { line: 2, column: 2 }, end: { line: 2, column: 34 } },
+ },
+ fnMap: {
+ '0': { name: 'Button', decl: { start: { line: 1, column: 13 }, end: { line: 1, column: 19 } }, loc: { start: { line: 1, column: 22 }, end: { line: 3, column: 1 } }, line: 1 },
+ },
+ branchMap: {},
+ s: { '0': 1, '1': 5 },
+ f: { '0': 5 },
+ b: {},
+ },
+};
+
+const util: CoverageFile = {
+ path: '/home/user/project/lib/util.js',
+ coverage: {
+ path: '/home/user/project/lib/util.js',
+ statementMap: {
+ '0': { start: { line: 1, column: 0 }, end: { line: 1, column: 10 } },
+ },
+ fnMap: {},
+ branchMap: {},
+ s: { '0': 0 },
+ f: {},
+ b: {},
+ },
+};
+
+const defaultCoverage = createMemoryCoverage([app, button, util]);
+const emptyCoverage = createMemoryCoverage([]);
+
+export const Default = () => ;
+
+export const Empty = () => ;
diff --git a/packages/coverage-report/src/coverageView.tsx b/packages/coverage-report/src/coverageView.tsx
new file mode 100644
index 0000000000000..3a9026b3355ef
--- /dev/null
+++ b/packages/coverage-report/src/coverageView.tsx
@@ -0,0 +1,242 @@
+/**
+ * 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.
+ */
+
+import * as React from 'react';
+import { coveragePercent, formatCoveragePercent } from '@isomorphic/istanbulCoverage';
+import { toTitleCase } from '@isomorphic/stringUtils';
+import { clsx, useAsyncMemo } from '@web/uiUtils';
+import './colors.css';
+import './common.css';
+import './coverageView.css';
+import * as icons from './icons';
+import { hashHref, useHashParams } from './links';
+import { annotateCoverage, buildCoverageTree, coverageLevel } from './coverageModel';
+import { coverageFileEntry } from './loadedCoverage';
+
+import type { CoverageBranchMarker, CoverageLine, CoverageSegmentKind, CoverageTreeNode } from './coverageModel';
+import type { LoadedCoverage } from './loadedCoverage';
+import type { CoverageFile, CoverageFileSummary, CoverageMetric, CoverageSummary } from '@isomorphic/istanbulCoverage';
+
+export const kCoverageFileParam = 'coverageFile';
+
+const kMetrics = ['statements', 'branches', 'functions', 'lines'] as const;
+
+const kSegmentTitles: Record = {
+ statement: 'statement not covered',
+ function: 'function not called',
+ branch: 'branch not taken',
+};
+
+const kMarkerTitles: Record = {
+ I: 'if path not taken',
+ E: 'else path not taken',
+};
+
+export const CoverageView: React.FC<{
+ coverage: LoadedCoverage | undefined,
+ title?: string,
+}> = ({ coverage, title }) => {
+ const params = useHashParams();
+ const fileId = params.get(kCoverageFileParam);
+ const report = coverage?.json();
+ const file = React.useMemo(() => report?.files.find(f => f.fileId === fileId), [report, fileId]);
+ const tree = React.useMemo(() => buildCoverageTree(report?.files || []), [report]);
+
+ return
+ {title &&
{title}
}
+ {coverage && report && (file ?
+
:
+ <>
+
+
+ >)}
+
;
+};
+
+export const CoverageSummaryView: React.FC<{
+ summary: CoverageSummary,
+}> = ({ summary }) => {
+ return
+ {kMetrics.map(name => {
+ const metric = summary[name];
+ return
+
{name}
+
+ {formatCoveragePercent(metric)}
+ {metric.covered}/{metric.total}
+
+
+
;
+ })}
+
;
+};
+
+const CoverageBar: React.FC<{ metric: CoverageMetric }> = ({ metric }) => {
+ return ;
+};
+
+const CoverageCell: React.FC<{ metric: CoverageMetric }> = ({ metric }) => {
+ return
+
+ {formatCoveragePercent(metric)}
+
+ {metric.covered}/{metric.total}
+
+ ;
+};
+
+export const CoverageFilesView: React.FC<{
+ root: CoverageTreeNode,
+ params: URLSearchParams,
+}> = ({ root, params }) => {
+ const [filterText, setFilterText] = React.useState('');
+ const [collapsed, setCollapsed] = React.useState>(new Set());
+ const filtered = React.useMemo(() => filterTree(root, filterText.trim().toLowerCase()), [root, filterText]);
+ const toggle = React.useCallback((path: string) => setCollapsed(collapsed => {
+ const next = new Set(collapsed);
+ if (next.has(path))
+ next.delete(path);
+ else
+ next.add(path);
+ return next;
+ }), []);
+
+ const rows: React.JSX.Element[] = [];
+ const visit = (node: CoverageTreeNode, depth: number) => {
+ for (const child of node.children) {
+ const isCollapsed = !filterText && collapsed.has(child.path);
+ rows.push( );
+ if (!child.fileId && !isCollapsed)
+ visit(child, depth + 1);
+ }
+ };
+ visit(filtered, 0);
+
+ return <>
+
+
+ {root.path && {root.path} }
+
+ {rows.length ?
+
+
+ File
+ {kMetrics.map(name => {toTitleCase(name)} )}
+
+
+ {rows}
+
: No files match the filter.
}
+ >;
+};
+
+const CoverageRow: React.FC<{
+ node: CoverageTreeNode,
+ depth: number,
+ collapsed: boolean,
+ toggle: (path: string) => void,
+ params: URLSearchParams,
+}> = ({ node, depth, collapsed, toggle, params }) => {
+ return
+
+ {node.fileId ?
+ {node.name} :
+ toggle(node.path)}>
+ {collapsed ? icons.rightArrow() : icons.downArrow()}
+ {node.name}
+ }
+
+ {kMetrics.map(name => )}
+ ;
+};
+
+export const CoverageFileView: React.FC<{
+ coverage: LoadedCoverage,
+ file: CoverageFileSummary,
+ rootPath: string,
+ params: URLSearchParams,
+}> = ({ coverage, file, rootPath, params }) => {
+ const content = useAsyncMemo(() => coverage.entry(coverageFileEntry(file.fileId)) as Promise, [coverage, file.fileId], undefined);
+ const lines = React.useMemo(() => content ? annotateCoverage(content) : [], [content]);
+ const relativePath = rootPath && file.path.startsWith(rootPath + '/') ? file.path.slice(rootPath.length + 1) : file.path;
+
+ const listParams = new URLSearchParams(params);
+ listParams.delete(kCoverageFileParam);
+
+ return <>
+
+
+ {content && !content.source && Source is not available for {file.path}.
}
+ {content && content.source && <>
+
+ {Object.entries(kSegmentTitles).map(([kind, title]) => {title} )}
+ {Object.entries(kMarkerTitles).map(([marker, title]) => {marker} {title} )}
+
+
+
+
+ {lines.map(line => )}
+
+
+
+ >}
+ >;
+};
+
+const CoverageLineView: React.FC<{ line: CoverageLine }> = ({ line }) => {
+ const status = line.count === undefined ? 'neutral' : line.count > 0 ? 'covered' : 'uncovered';
+ return
+ {line.number}
+ {line.count === undefined ? '' : line.count + 'x'}
+
+ {line.markers.map((marker, index) => {marker} )}
+ {line.segments.map((segment, index) => segment.kinds.length ?
+ 'coverage-segment-' + kind).join(' ')} title={segment.kinds.map(kind => kSegmentTitles[kind]).join(', ')}>{segment.text} :
+ {segment.text} )}
+
+ ;
+};
+
+export function coverageFileHref(params: URLSearchParams, fileId: string): string {
+ const fileParams = new URLSearchParams(params);
+ fileParams.set(kCoverageFileParam, fileId);
+ return hashHref(fileParams);
+}
+
+function filterTree(node: CoverageTreeNode, filter: string): CoverageTreeNode {
+ if (!filter)
+ return node;
+ const children: CoverageTreeNode[] = [];
+ for (const child of node.children) {
+ if (child.fileId) {
+ if (child.path.toLowerCase().includes(filter))
+ children.push(child);
+ continue;
+ }
+ const filtered = filterTree(child, filter);
+ if (filtered.children.length)
+ children.push(filtered);
+ }
+ return { ...node, children };
+}
diff --git a/packages/coverage-report/src/icons.tsx b/packages/coverage-report/src/icons.tsx
new file mode 100644
index 0000000000000..c2ac891f3ab74
--- /dev/null
+++ b/packages/coverage-report/src/icons.tsx
@@ -0,0 +1,36 @@
+/*
+ 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.
+*/
+
+import './colors.css';
+import './common.css';
+
+export const search = () => {
+ return
+
+ ;
+};
+
+export const downArrow = () => {
+ return
+
+ ;
+};
+
+export const rightArrow = () => {
+ return
+
+ ;
+};
diff --git a/packages/coverage-report/src/index.tsx b/packages/coverage-report/src/index.tsx
new file mode 100644
index 0000000000000..23d6606790b65
--- /dev/null
+++ b/packages/coverage-report/src/index.tsx
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+
+import * as React from 'react';
+import * as ReactDOM from 'react-dom/client';
+import './colors.css';
+import './common.css';
+import { CoverageView } from './coverageView';
+import { ZipReport } from '@web/zipReport';
+import { applyTheme } from '@web/theme';
+import logo from '@web/assets/playwright-logo.svg';
+
+import type { LoadedCoverage } from './loadedCoverage';
+import type { CoverageReport } from '@isomorphic/istanbulCoverage';
+
+const link = document.createElement('link');
+link.rel = 'shortcut icon';
+link.href = logo;
+document.head.appendChild(link);
+
+const CoverageLoader: React.FC = () => {
+ const [coverage, setCoverage] = React.useState();
+ React.useEffect(() => {
+ const zipCoverage = new ZipReport('playwrightCoverageBase64', 'coverage.json');
+ zipCoverage.load().then(() => setCoverage(zipCoverage));
+ }, []);
+ return ;
+};
+
+window.onload = () => {
+ applyTheme();
+ ReactDOM.createRoot(document.querySelector('#root')!).render( );
+};
diff --git a/packages/coverage-report/src/links.tsx b/packages/coverage-report/src/links.tsx
new file mode 100644
index 0000000000000..499b5bd766f98
--- /dev/null
+++ b/packages/coverage-report/src/links.tsx
@@ -0,0 +1,37 @@
+/**
+ * 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.
+ */
+
+import * as React from 'react';
+
+// Navigation state lives in the hash, e.g. #?coverageFile=, so that the
+// report can be opened from a file:// url and links stay shareable.
+export function useHashParams(): URLSearchParams {
+ const [params, setParams] = React.useState(() => currentHashParams());
+ React.useEffect(() => {
+ const listener = () => setParams(currentHashParams());
+ window.addEventListener('hashchange', listener);
+ return () => window.removeEventListener('hashchange', listener);
+ }, []);
+ return params;
+}
+
+export function hashHref(params: URLSearchParams): string {
+ return '#?' + params.toString();
+}
+
+function currentHashParams(): URLSearchParams {
+ return new URLSearchParams(window.location.hash.slice(1).replace(/^\?/, ''));
+}
diff --git a/packages/coverage-report/src/loadedCoverage.ts b/packages/coverage-report/src/loadedCoverage.ts
new file mode 100644
index 0000000000000..402cec5b7bc56
--- /dev/null
+++ b/packages/coverage-report/src/loadedCoverage.ts
@@ -0,0 +1,44 @@
+/**
+ * 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.
+ */
+
+import { fileCoverageSummary, addCoverageSummary, emptyCoverageSummary } from '@isomorphic/istanbulCoverage';
+
+import type { CoverageFile, CoverageReport } from '@isomorphic/istanbulCoverage';
+
+export interface LoadedCoverage {
+ json(): CoverageReport;
+ entry(name: string): Promise;
+}
+
+export function coverageFileEntry(fileId: string): string {
+ return `coverage/${fileId}.json`;
+}
+
+export function createMemoryCoverage(files: CoverageFile[]): LoadedCoverage {
+ const report: CoverageReport = { files: [], summary: emptyCoverageSummary() };
+ const entries = new Map();
+ files.forEach((file, index) => {
+ const fileId = 'file-' + index;
+ const summary = fileCoverageSummary(file.coverage);
+ entries.set(coverageFileEntry(fileId), file);
+ report.files.push({ fileId, path: file.path, summary });
+ addCoverageSummary(report.summary, summary);
+ });
+ return {
+ json: () => report,
+ entry: async name => entries.get(name),
+ };
+}
diff --git a/packages/coverage-report/tests/index.html b/packages/coverage-report/tests/index.html
new file mode 100644
index 0000000000000..0279894a6dfd7
--- /dev/null
+++ b/packages/coverage-report/tests/index.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+ Component Gallery
+
+
+
+
+ Select a story…
+
+
+
+
+
diff --git a/packages/coverage-report/tests/main.tsx b/packages/coverage-report/tests/main.tsx
new file mode 100644
index 0000000000000..a7395d304e974
--- /dev/null
+++ b/packages/coverage-report/tests/main.tsx
@@ -0,0 +1,79 @@
+/**
+ * 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.
+ */
+
+import { flushSync } from 'react-dom';
+import { createRoot, type Root } from 'react-dom/client';
+import '../src/common.css';
+import packageJSON from '../package.json';
+
+const stories = import.meta.glob('../src/**/*.story.{tsx,jsx}');
+const storyId = (file: string) => packageJSON.name + '/' + file.replace(/^(\.\.\/)+src\//, '').replace(/\.story\.\w+$/, '');
+
+async function resolveStory(id: string): Promise | undefined> {
+ const sep = id.lastIndexOf('/');
+ const [path, name] = [id.slice(0, sep), id.slice(sep + 1)];
+ const file = Object.keys(stories).find(f => storyId(f) === path || storyId(f).endsWith('/' + path));
+ const mod = (file && await stories[file]()) as Record | undefined;
+ return mod?.[name] ?? mod?.default;
+}
+
+const wrapperElement = document.getElementById('wrapper')!;
+let root: Root | undefined;
+
+(window as any).mount = async ({ story, props }: { story: string, props?: Record }) => {
+ const Story = await resolveStory(story);
+ if (!Story)
+ throw new Error(`Unknown story: ${story}`);
+ // Reuse the root so that update() reconciles and preserves state.
+ root ??= createRoot(wrapperElement);
+ // flushSync so that a render error rejects the promise instead of being swallowed.
+ flushSync(() => root!.render(
+
+
+
+ ));
+};
+
+(window as any).unmount = async () => {
+ root?.unmount();
+ root = undefined;
+};
+
+async function listStories(): Promise {
+ const lists = await Promise.all(Object.entries(stories).map(async ([file, loadModule]) => {
+ const mod = await loadModule() as Record;
+ return Object.keys(mod).filter(name => typeof mod[name] === 'function').map(name => `${storyId(file)}/${name}`);
+ }));
+ return lists.flat().sort();
+}
+
+const pickerElement = document.getElementById('picker') as HTMLSelectElement;
+let pickerPopulated = false;
+async function populatePicker() {
+ if (pickerPopulated)
+ return;
+ pickerPopulated = true;
+ for (const id of await listStories())
+ pickerElement.add(new Option(id, id));
+}
+// Populate on mouseenter/focus rather than on click, because an already-open
+// select popup does not refresh when options are added.
+pickerElement.addEventListener('mouseenter', () => void populatePicker());
+pickerElement.addEventListener('focus', () => void populatePicker());
+pickerElement.addEventListener('change', () => {
+ if (pickerElement.value)
+ void (window as any).mount({ story: pickerElement.value });
+});
diff --git a/packages/coverage-report/tests/stories.d.ts b/packages/coverage-report/tests/stories.d.ts
new file mode 100644
index 0000000000000..e1cc84e946f77
--- /dev/null
+++ b/packages/coverage-report/tests/stories.d.ts
@@ -0,0 +1,25 @@
+/**
+ * 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.
+ */
+// This file is generated by ../../html-reporter/tests/storyTypes.ts, do not edit manually.
+
+type StoriesOf = { [K in keyof Mod & string as `${Prefix}/${K}`]: Mod[K] };
+
+declare module '@playwright/test' {
+ interface Stories extends
+ StoriesOf<'coverage-report/coverageView', typeof import('../src/coverageView.story')> {}
+}
+
+export {};
diff --git a/packages/coverage-report/vite.config.ts b/packages/coverage-report/vite.config.ts
new file mode 100644
index 0000000000000..9931b96f05cf5
--- /dev/null
+++ b/packages/coverage-report/vite.config.ts
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { bundle } from '../html-reporter/bundle';
+import { storyTypes } from '../html-reporter/tests/storyTypes';
+import packageJSON from './package.json';
+import path from 'path';
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ base: '',
+ plugins: [
+ react(),
+ bundle(),
+ storyTypes({ prefix: packageJSON.name, src: path.resolve(__dirname, 'src'), outFile: path.resolve(__dirname, 'tests/stories.d.ts') }),
+ ],
+ resolve: {
+ alias: {
+ '@web': path.resolve(__dirname, '../web/src'),
+ '@isomorphic': path.resolve(__dirname, '../isomorphic'),
+ },
+ },
+ build: {
+ outDir: path.resolve(__dirname, '../playwright-core/lib/vite/coverageReport'),
+ emptyOutDir: true,
+ assetsInlineLimit: 100000000,
+ chunkSizeWarningLimit: 100000000,
+ cssCodeSplit: false,
+ rollupOptions: {
+ output: {
+ manualChunks: undefined,
+ inlineDynamicImports: true,
+ entryFileNames: 'report.js',
+ assetFileNames: (assetInfo) => assetInfo.names.some(n => n.endsWith('.css')) ? 'report.css' : '[name][extname]',
+ },
+ },
+ },
+});
diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx
index e113660e21af5..7038b76970fec 100644
--- a/packages/html-reporter/src/index.tsx
+++ b/packages/html-reporter/src/index.tsx
@@ -15,16 +15,12 @@
*/
import type { HTMLReport } from './types';
-import type * as zip from '@zip.js/zip.js';
-// @ts-ignore
-import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import './colors.css';
import type { LoadedReport } from './loadedReport';
import { ReportView } from './reportView';
-// @ts-ignore
-const zipjs = zipImport as typeof zip;
+import { ZipReport } from '@web/zipReport';
import logo from '@web/assets/playwright-logo.svg';
import { SearchParamsProvider } from './links';
@@ -38,12 +34,8 @@ document.head.appendChild(link);
const ReportLoader: React.FC = () => {
const [report, setReport] = React.useState();
React.useEffect(() => {
- const zipReport = new ZipReport();
- zipReport.load().then(() => {
- // Drop node after consumption
- document.getElementById('playwrightReportBase64')?.remove();
- setReport(zipReport);
- });
+ const zipReport = new ZipReport('playwrightReportBase64', 'report.json');
+ zipReport.load().then(() => setReport(zipReport));
}, []);
return
@@ -54,27 +46,3 @@ window.onload = () => {
applyTheme();
ReactDOM.createRoot(document.querySelector('#root')!).render( );
};
-
-class ZipReport implements LoadedReport {
- private _entries = new Map();
- private _json!: HTMLReport;
-
- async load() {
- const zipURI = (document.getElementById('playwrightReportBase64') as HTMLTemplateElement).content.textContent;
- const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(zipURI), { useWebWorkers: false });
- for (const entry of await zipReader.getEntries())
- this._entries.set(entry.filename, entry);
- this._json = await this.entry('report.json') as HTMLReport;
- }
-
- json(): HTMLReport {
- return this._json;
- }
-
- async entry(name: string): Promise {
- const reportEntry = this._entries.get(name);
- const writer = new zipjs.TextWriter() as zip.TextWriter;
- await reportEntry!.getData!(writer);
- return JSON.parse(await writer.getData());
- }
-}
diff --git a/packages/isomorphic/istanbulCoverage.ts b/packages/isomorphic/istanbulCoverage.ts
index 7c0111c6f4371..ddaceda564f6c 100644
--- a/packages/isomorphic/istanbulCoverage.ts
+++ b/packages/isomorphic/istanbulCoverage.ts
@@ -99,3 +99,82 @@ export function mergeIstanbulCoverage(into: Map, d
}
}
}
+
+export type CoverageMetric = { covered: number, total: number };
+
+export type CoverageSummary = {
+ statements: CoverageMetric;
+ branches: CoverageMetric;
+ functions: CoverageMetric;
+ lines: CoverageMetric;
+};
+
+export function emptyCoverageSummary(): CoverageSummary {
+ return {
+ statements: { covered: 0, total: 0 },
+ branches: { covered: 0, total: 0 },
+ functions: { covered: 0, total: 0 },
+ lines: { covered: 0, total: 0 },
+ };
+}
+
+export function fileCoverageSummary(fileCov: IstanbulFileCoverage): CoverageSummary {
+ const summary = emptyCoverageSummary();
+ countHits(summary.statements, Object.values(fileCov.s));
+ countHits(summary.functions, Object.values(fileCov.f));
+ for (const counts of Object.values(fileCov.b))
+ countHits(summary.branches, counts);
+ countHits(summary.lines, lineCoverage(fileCov).values());
+ return summary;
+}
+
+export function addCoverageSummary(into: CoverageSummary, summary: CoverageSummary) {
+ for (const key of ['statements', 'branches', 'functions', 'lines'] as const) {
+ into[key].covered += summary[key].covered;
+ into[key].total += summary[key].total;
+ }
+}
+
+export function coveragePercent(metric: CoverageMetric): number {
+ return metric.total ? metric.covered / metric.total * 100 : 100;
+}
+
+export function formatCoveragePercent(metric: CoverageMetric): string {
+ return coveragePercent(metric).toFixed(2) + '%';
+}
+
+// Line coverage is derived from statements, as nyc does.
+export function lineCoverage(fileCov: IstanbulFileCoverage): Map {
+ const lines = new Map();
+ for (const [key, statement] of Object.entries(fileCov.statementMap)) {
+ const line = statement.start.line;
+ const count = fileCov.s[key] || 0;
+ lines.set(line, Math.max(lines.get(line) || 0, count));
+ }
+ return lines;
+}
+
+function countHits(metric: CoverageMetric, counts: Iterable) {
+ for (const count of counts) {
+ ++metric.total;
+ if (count > 0)
+ ++metric.covered;
+ }
+}
+
+export type CoverageFileSummary = {
+ fileId: string;
+ path: string;
+ summary: CoverageSummary;
+};
+
+export type CoverageReport = {
+ files: CoverageFileSummary[];
+ summary: CoverageSummary;
+};
+
+export type CoverageFile = {
+ path: string;
+ source?: string;
+ coverage: IstanbulFileCoverage;
+};
diff --git a/packages/playwright/src/reporters/coverage.ts b/packages/playwright/src/reporters/coverage.ts
index f392f5b18d06f..b4ec7868dbd52 100644
--- a/packages/playwright/src/reporters/coverage.ts
+++ b/packages/playwright/src/reporters/coverage.ts
@@ -17,12 +17,16 @@
import fs from 'fs';
import path from 'path';
-import { mergeIstanbulCoverage, sortedIstanbulCoverage } from '@isomorphic/istanbulCoverage';
+import * as yazl from 'yazl';
+import { addCoverageSummary, emptyCoverageSummary, fileCoverageSummary, formatCoveragePercent, lineCoverage, mergeIstanbulCoverage, sortedIstanbulCoverage } from '@isomorphic/istanbulCoverage';
+import { calculateSha1 } from '@utils/crypto';
+import { toPosixPath } from '@utils/fileUtils';
import { ZipFile } from '@utils/zipFile';
import { terminalScreen } from './base';
+import { appendZipDataTemplate, inlineViteApp } from './htmlUtils';
import { resolveReporterOutputPath } from '../util';
-import type { IstanbulCoverage, IstanbulFileCoverage } from '@isomorphic/istanbulCoverage';
+import type { CoverageFile, CoverageMetric, CoverageReport, IstanbulCoverage, IstanbulFileCoverage } from '@isomorphic/istanbulCoverage';
import type { CommonReporterOptions } from './base';
import type { ReporterV2 } from './reporterV2';
import type { TestCase, TestResult } from '../../types/testReporter';
@@ -96,80 +100,48 @@ class CoverageReporter implements ReporterV2 {
const mergedJson = sortedIstanbulCoverage(coverage);
await fs.promises.writeFile(path.join(outputDir, 'coverage-final.json'), JSON.stringify(mergedJson));
await fs.promises.writeFile(path.join(outputDir, 'lcov.info'), lcovReport(mergedJson));
- const hasHtml = this._tryWriteHtmlReport(mergedJson, outputDir);
+ const report = await this._writeHtmlReport(mergedJson, outputDir);
- const summary = computeSummary(mergedJson);
- const lines = [
+ const { summary } = report;
+ writeLine([
``,
- `Code coverage (${coverage.size} files):`,
+ `Code coverage (${report.files.length} files):`,
` statements: ${formatMetric(summary.statements)}`,
` branches: ${formatMetric(summary.branches)}`,
` functions: ${formatMetric(summary.functions)}`,
` lines: ${formatMetric(summary.lines)}`,
`Coverage report written to ${path.relative(process.cwd(), outputDir)}`,
- ];
- if (!hasHtml)
- lines.push(`For an HTML report, install istanbul-lib-coverage, istanbul-lib-report and istanbul-reports, or run 'npx nyc report --temp-dir=${path.relative(process.cwd(), outputDir)} --reporter=html'.`);
- writeLine(lines.join('\n'));
+ ].join('\n'));
}
- // Istanbul report libraries are not shipped with Playwright, use the project's if any.
- private _tryWriteHtmlReport(mergedJson: IstanbulCoverage, outputDir: string): boolean {
- try {
- const resolveFrom = (name: string) => require.resolve(name, { paths: [this._options.configDir] });
- const libCoverage = require(resolveFrom('istanbul-lib-coverage'));
- const libReport = require(resolveFrom('istanbul-lib-report'));
- const reports = require(resolveFrom('istanbul-reports'));
- const context = libReport.createContext({
- dir: outputDir,
- coverageMap: libCoverage.createCoverageMap(mergedJson),
- });
- reports.create('html').execute(context);
- return true;
- } catch {
- return false;
- }
- }
-}
-
-type Metric = { covered: number, total: number };
-
-function computeSummary(mergedJson: IstanbulCoverage) {
- const statements: Metric = { covered: 0, total: 0 };
- const branches: Metric = { covered: 0, total: 0 };
- const functions: Metric = { covered: 0, total: 0 };
- const lines: Metric = { covered: 0, total: 0 };
- for (const fileCov of Object.values(mergedJson)) {
- countHits(statements, Object.values(fileCov.s));
- countHits(functions, Object.values(fileCov.f));
- for (const counts of Object.values(fileCov.b))
- countHits(branches, counts);
- countHits(lines, lineCoverage(fileCov).values());
+ private async _writeHtmlReport(mergedJson: IstanbulCoverage, outputDir: string): Promise {
+ const report: CoverageReport = { files: [], summary: emptyCoverageSummary() };
+ const dataZipFile = new yazl.ZipFile();
+ const sources = await Promise.all(Object.keys(mergedJson).map(filePath => this._readSource(filePath)));
+ Object.entries(mergedJson).forEach(([filePath, coverage], index) => {
+ const fileId = calculateSha1(filePath).slice(0, 20);
+ const summary = fileCoverageSummary(coverage);
+ report.files.push({ fileId, path: toPosixPath(filePath), summary });
+ addCoverageSummary(report.summary, summary);
+ const file: CoverageFile = { path: toPosixPath(filePath), source: sources[index], coverage };
+ dataZipFile.addBuffer(Buffer.from(JSON.stringify(file)), `coverage/${fileId}.json`);
+ });
+ dataZipFile.addBuffer(Buffer.from(JSON.stringify(report)), 'coverage.json');
+
+ const appFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'vite', 'coverageReport');
+ const reportIndexFile = path.join(outputDir, 'index.html');
+ await fs.promises.writeFile(reportIndexFile, await inlineViteApp(appFolder));
+ await appendZipDataTemplate(reportIndexFile, dataZipFile, 'playwrightCoverageBase64');
+ return report;
}
- return { statements, branches, functions, lines };
-}
-
-function countHits(metric: Metric, counts: Iterable) {
- for (const count of counts) {
- ++metric.total;
- if (count > 0)
- ++metric.covered;
- }
-}
-function lineCoverage(fileCov: IstanbulFileCoverage): Map {
- const lines = new Map();
- for (const [key, statement] of Object.entries(fileCov.statementMap)) {
- const line = statement.start.line;
- const count = fileCov.s[key] || 0;
- lines.set(line, Math.max(lines.get(line) || 0, count));
+ private async _readSource(filePath: string): Promise {
+ return fs.promises.readFile(path.resolve(this._options.configDir, filePath), 'utf8').catch(() => undefined);
}
- return lines;
}
-function formatMetric(metric: Metric) {
- const percent = metric.total ? (metric.covered / metric.total * 100).toFixed(2) : '100.00';
- return `${percent}% (${metric.covered}/${metric.total})`;
+function formatMetric(metric: CoverageMetric) {
+ return `${formatCoveragePercent(metric)} (${metric.covered}/${metric.total})`;
}
function lcovReport(mergedJson: IstanbulCoverage): string {
diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts
index 30abcc8254e21..8925d27ae3ef3 100644
--- a/packages/playwright/src/reporters/html.ts
+++ b/packages/playwright/src/reporters/html.ts
@@ -17,7 +17,6 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
-import { Transform } from 'stream';
import colors from 'colors/safe';
import mime from 'mime';
@@ -37,6 +36,7 @@ import { extractZip } from '@utils/third_party/extractZip';
declare const __PW_HMR__: boolean;
import { CommonReporterOptions, formatError, formatResultFailure, internalScreen } from './base';
+import { appendZipDataTemplate, inlineViteApp } from './htmlUtils';
import * as babel from '../transform/babelBundle';
import { resolveReporterOutputPath, stripAnsiEscapes } from '../util';
@@ -44,7 +44,6 @@ import type { ReportConfigureParams, ReportEndParams, ReporterV2 } from './repor
import type { HtmlReporterOptions as HtmlReporterConfigOptions, Metadata, TestAnnotation } from '../../types/test';
import type * as api from '../../types/testReporter';
import type { HTMLReport, HTMLReportOptions, Location, Stats, TestAttachment, TestCase, TestCaseSummary, TestFile, TestFileSummary, TestResult, TestStep } from '@html-reporter/types';
-import type { TransformCallback } from 'stream';
import type { ZipFile } from 'yazl';
type TestEntry = {
@@ -452,28 +451,13 @@ class HtmlBuilder {
fs.promises.copyFile(path.join(appFolder, 'report.css'), path.join(this._reportFolder, 'report.css')),
]);
} else {
- let html = await fs.promises.readFile(path.join(appFolder, 'index.html'), 'utf-8');
- const [js, css] = await Promise.all([
- fs.promises.readFile(path.join(appFolder, 'report.js'), 'utf-8'),
- fs.promises.readFile(path.join(appFolder, 'report.css'), 'utf-8'),
- ]);
- html = html.replace(/`);
- html = html.replace(/ ]*>/, () => ``);
- await fs.promises.writeFile(reportIndexFile, html);
+ await fs.promises.writeFile(reportIndexFile, await inlineViteApp(appFolder));
}
return reportIndexFile;
}
private async _writeReportData(filePath: string) {
- fs.appendFileSync(filePath, 'data:application/zip;base64,');
- await new Promise((resolve, reject) => {
- this._dataZipFile.end(undefined, () => {
- this._dataZipFile.outputStream
- .pipe(new Base64Encoder())
- .pipe(fs.createWriteStream(filePath, { flags: 'a' })).on('close', resolve).on('error', reject);
- });
- });
- fs.appendFileSync(filePath, ' ');
+ await appendZipDataTemplate(filePath, this._dataZipFile, 'playwrightReportBase64');
}
private _addDataFile(fileName: string, data: any) {
@@ -722,32 +706,6 @@ const addStats = (stats: Stats, delta: Stats): Stats => {
return stats;
};
-class Base64Encoder extends Transform {
- private _remainder: Buffer | undefined;
-
- override _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {
- if (this._remainder) {
- chunk = Buffer.concat([this._remainder, chunk]);
- this._remainder = undefined;
- }
-
- const remaining = chunk.length % 3;
- if (remaining) {
- this._remainder = chunk.slice(chunk.length - remaining);
- chunk = chunk.slice(0, chunk.length - remaining);
- }
- chunk = chunk.toString('base64');
- this.push(Buffer.from(chunk));
- callback();
- }
-
- override _flush(callback: TransformCallback): void {
- if (this._remainder)
- this.push(Buffer.from(this._remainder.toString('base64')));
- callback();
- }
-}
-
function isTextContentType(contentType: string) {
return contentType.startsWith('text/') || contentType.startsWith('application/json');
}
diff --git a/packages/playwright/src/reporters/htmlUtils.ts b/packages/playwright/src/reporters/htmlUtils.ts
new file mode 100644
index 0000000000000..6da387e8fb0ab
--- /dev/null
+++ b/packages/playwright/src/reporters/htmlUtils.ts
@@ -0,0 +1,71 @@
+/**
+ * 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.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { Transform } from 'stream';
+
+import type { TransformCallback } from 'stream';
+import type { ZipFile } from 'yazl';
+
+export async function inlineViteApp(appFolder: string): Promise {
+ let html = await fs.promises.readFile(path.join(appFolder, 'index.html'), 'utf-8');
+ const [js, css] = await Promise.all([
+ fs.promises.readFile(path.join(appFolder, 'report.js'), 'utf-8'),
+ fs.promises.readFile(path.join(appFolder, 'report.css'), 'utf-8'),
+ ]);
+ html = html.replace(/`);
+ html = html.replace(/ ]*>/, () => ``);
+ return html;
+}
+
+export async function appendZipDataTemplate(filePath: string, zipFile: ZipFile, templateId: string) {
+ fs.appendFileSync(filePath, `data:application/zip;base64,`);
+ await new Promise((resolve, reject) => {
+ zipFile.end(undefined, () => {
+ zipFile.outputStream
+ .pipe(new Base64Encoder())
+ .pipe(fs.createWriteStream(filePath, { flags: 'a' })).on('close', resolve).on('error', reject);
+ });
+ });
+ fs.appendFileSync(filePath, ' ');
+}
+
+class Base64Encoder extends Transform {
+ private _remainder: Buffer | undefined;
+
+ override _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void {
+ if (this._remainder) {
+ chunk = Buffer.concat([this._remainder, chunk]);
+ this._remainder = undefined;
+ }
+
+ const remaining = chunk.length % 3;
+ if (remaining) {
+ this._remainder = chunk.slice(chunk.length - remaining);
+ chunk = chunk.slice(0, chunk.length - remaining);
+ }
+ chunk = chunk.toString('base64');
+ this.push(Buffer.from(chunk));
+ callback();
+ }
+
+ override _flush(callback: TransformCallback): void {
+ if (this._remainder)
+ this.push(Buffer.from(this._remainder.toString('base64')));
+ callback();
+ }
+}
diff --git a/packages/web/src/zipReport.ts b/packages/web/src/zipReport.ts
new file mode 100644
index 0000000000000..644f2e7a4498b
--- /dev/null
+++ b/packages/web/src/zipReport.ts
@@ -0,0 +1,56 @@
+/**
+ * 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.
+ */
+
+// @ts-ignore
+import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js';
+
+import type * as zip from '@zip.js/zip.js';
+// @ts-ignore
+const zipjs = zipImport as typeof zip;
+
+// Reads the report data from the base64 zip embedded into the page.
+export class ZipReport {
+ private _entries = new Map();
+ private _json!: T;
+ private _templateId: string;
+ private _jsonName: string;
+
+ constructor(templateId: string, jsonName: string) {
+ this._templateId = templateId;
+ this._jsonName = jsonName;
+ }
+
+ async load() {
+ const template = document.getElementById(this._templateId) as HTMLTemplateElement;
+ const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(template.content.textContent), { useWebWorkers: false });
+ for (const entry of await zipReader.getEntries())
+ this._entries.set(entry.filename, entry);
+ this._json = await this.entry(this._jsonName) as T;
+ // Drop node after consumption
+ template.remove();
+ }
+
+ json(): T {
+ return this._json;
+ }
+
+ async entry(name: string): Promise {
+ const reportEntry = this._entries.get(name);
+ const writer = new zipjs.TextWriter() as zip.TextWriter;
+ await reportEntry!.getData!(writer);
+ return JSON.parse(await writer.getData());
+ }
+}
diff --git a/tests/playwright-test/coverage.spec.ts b/tests/playwright-test/coverage.spec.ts
index f00a17b582ef1..05e801aae16b6 100644
--- a/tests/playwright-test/coverage.spec.ts
+++ b/tests/playwright-test/coverage.spec.ts
@@ -17,6 +17,7 @@
import fs from 'fs';
import { test, expect } from './playwright-test-fixtures';
import { parseTraceCoverage } from '../config/utils';
+import { utils } from '../../packages/playwright-core/lib/coreBundle';
const coverageHelper = `
const makeCoverage = (file, s0) => ({
@@ -127,6 +128,45 @@ test('should aggregate coverage with the coverage reporter', async ({ runInlineT
expect(result.output).toContain('Code coverage (2 files)');
expect(result.output).toContain('statements: 50.00% (2/4)');
+ expect(fs.existsSync(test.info().outputPath('coverage-report', 'index.html'))).toBe(true);
+});
+
+test('should generate html coverage report', async ({ runInlineTest, page }) => {
+ const result = await runInlineTest({
+ 'playwright.config.ts': `
+ module.exports = {
+ use: { trace: { mode: 'on', coverage: true } },
+ reporter: [['dot'], ['coverage', { outputDir: 'coverage-report' }]],
+ };
+ `,
+ 'src/app.js': `function foo() {\nif (a) { b(); }\n}\n`,
+ 'a.test.ts': `
+ import { test, expect } from '@playwright/test';
+ ${coverageHelper}
+ test('first', async ({ page }) => {
+ await page.setContent(coverageScript('src/app.js', 5));
+ });
+ `,
+ });
+ expect(result.exitCode).toBe(0);
+
+ const server = utils.serveFolder(test.info().outputPath('coverage-report'));
+ await server.start();
+ try {
+ await page.goto(server.urlPrefix('precise'));
+ await expect(page.getByText('Coverage report')).toBeVisible();
+ await expect(page.getByTestId('coverage-metric-statements')).toContainText('50.00%');
+ await expect(page.getByTestId('coverage-file')).toHaveText([/app\.js/]);
+ await page.getByRole('link', { name: 'app.js' }).click();
+ const source = page.getByTestId('coverage-source');
+ await expect(source.locator('tr')).toHaveCount(3);
+ await expect(source.locator('[data-line="1"] .coverage-line-count')).toHaveText('5x');
+ await expect(source.locator('[data-line="2"] .coverage-line-count')).toHaveText('0x');
+ await expect(source.locator('[data-line="2"] .coverage-branch-marker')).toHaveText('E');
+ await expect(source.locator('[data-line="2"] .coverage-segment-statement')).toHaveText('if (a) { b(); }');
+ } finally {
+ await server.stop();
+ }
});
test('should warn when no coverage was collected', async ({ runInlineTest }) => {
diff --git a/tsconfig.json b/tsconfig.json
index 6526645a8fbd5..51d422cee3a28 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -38,7 +38,7 @@
"skipLibCheck": true,
},
"compileOnSave": true,
- "files": ["packages/html-reporter/tests/stories.d.ts"],
+ "files": ["packages/html-reporter/tests/stories.d.ts", "packages/coverage-report/tests/stories.d.ts"],
"include": ["packages"],
"exclude": [
"packages/*/lib",
diff --git a/utils/build/build.js b/utils/build/build.js
index 68de8fbcf6fae..8397a9a654065 100644
--- a/utils/build/build.js
+++ b/utils/build/build.js
@@ -955,7 +955,7 @@ const pkgSizePlugin = {
// in other code paths (e.g. HtmlBuilder.build() reads lib/vite/htmlReport/
// and lib/vite/traceViewer/), so we always keep the static build alongside
// HMR. Recorder is not yet HMR'd.
-const webPackages = ['html-reporter', 'recorder', 'trace-viewer', 'dashboard'];
+const webPackages = ['html-reporter', 'coverage-report', 'recorder', 'trace-viewer', 'dashboard'];
for (const webPackage of webPackages) {
steps.push(new ProgramStep({
command: process.execPath,
diff --git a/utils/check_deps.js b/utils/check_deps.js
index dda7ea3836785..e73d0bf31166c 100644
--- a/utils/check_deps.js
+++ b/utils/check_deps.js
@@ -39,6 +39,7 @@ const depsCache = {};
async function checkDeps() {
await innerCheckDeps(path.join(packagesDir, 'html-reporter'));
+ await innerCheckDeps(path.join(packagesDir, 'coverage-report'));
await innerCheckDeps(path.join(packagesDir, 'protocol'));
await innerCheckDeps(path.join(packagesDir, 'recorder'));
await innerCheckDeps(path.join(packagesDir, 'trace-viewer'));