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 ;`, + `};`, + ].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 <> +
+
e.preventDefault()}> + {icons.search()} + setFilterText(e.target.value)}> +
+ {root.path && {root.path}} +
+ {rows.length ? + + + + {kMetrics.map(name => )} + + + {rows} +
File{toTitleCase(name)}
:
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} : + } + + {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 <> +
+ All files + / + {relativePath} +
+ + {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 + + + + +
+ + + 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, ''); + 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, `'); +} + +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'));