From b99fd2d2b76a6536ecc3a82a6cc65bed58e43e47 Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Mon, 14 Sep 2026 20:45:48 +0200 Subject: [PATCH] refactor(interface): migrate scorecard to lit --- .../components/npm-avatar/npm-avatar.js | 1 - .../cli/public/components/package/package.css | 1 - .../public/components/package/package.html | 19 +- .../cli/public/components/package/package.js | 21 +- .../package/pannels/scorecard/scorecard.css | 129 ------ .../package/pannels/scorecard/scorecard.js | 377 +++++++++++------- workspaces/cli/public/global.d.ts | 2 + workspaces/cli/test/e2e/scorecard.spec.js | 106 +++++ 8 files changed, 368 insertions(+), 288 deletions(-) delete mode 100644 workspaces/cli/public/components/package/pannels/scorecard/scorecard.css create mode 100644 workspaces/cli/test/e2e/scorecard.spec.js diff --git a/workspaces/cli/public/components/npm-avatar/npm-avatar.js b/workspaces/cli/public/components/npm-avatar/npm-avatar.js index 74e69af6..6d862c73 100644 --- a/workspaces/cli/public/components/npm-avatar/npm-avatar.js +++ b/workspaces/cli/public/components/npm-avatar/npm-avatar.js @@ -1,4 +1,3 @@ -/* eslint-disable @openally/imports */ // Import Third-party Dependencies import { LitElement, html } from "lit"; import { when } from "lit/directives/when.js"; diff --git a/workspaces/cli/public/components/package/package.css b/workspaces/cli/public/components/package/package.css index 3513f741..01355343 100644 --- a/workspaces/cli/public/components/package/package.css +++ b/workspaces/cli/public/components/package/package.css @@ -1,6 +1,5 @@ @import url("./header/header.css"); @import url("./pannels/overview/overview.css"); -@import url("./pannels/scorecard/scorecard.css"); @import url("./pannels/warnings/warnings.css"); section#package-info { diff --git a/workspaces/cli/public/components/package/package.html b/workspaces/cli/public/components/package/package.html index d2366b20..645f0052 100644 --- a/workspaces/cli/public/components/package/package.html +++ b/workspaces/cli/public/components/package/package.html @@ -193,22 +193,5 @@ - + diff --git a/workspaces/cli/public/components/package/package.js b/workspaces/cli/public/components/package/package.js index 40ffa459..f84f0aac 100644 --- a/workspaces/cli/public/components/package/package.js +++ b/workspaces/cli/public/components/package/package.js @@ -1,3 +1,6 @@ +// Import Third-party Dependencies +import { getScoreColor } from "@nodesecure/utils"; + // Import Internal Dependencies import "../bundlephobia/bundlephobia.js"; import { PackageHeader } from "./header/header.js"; @@ -211,7 +214,23 @@ export class PackageInfo { this.addNavigationSignal(/** @type {HTMLElement} */ (clone.getElementById("vulnerabilities-nav-menu")), this.dependency.vulnerabilities.length); if (utils.getSettingsConfig().disableExternalRequests === false) { - new Pannels.Scorecard(this).generate(clone); + const scorecard = new Pannels.Scorecard(); + scorecard.repository = this.links.github.href ?? ""; + scorecard.id = "pan-scorecard"; + scorecard.classList.add("package-container", "hidden"); + const menu = /** @type {HTMLElement} */ (clone.getElementById("scorecard-menu")); + const score = /** @type {HTMLElement} */ (clone.getElementById("ossf-score")); + + scorecard.addEventListener("scorecard-loaded", (event) => { + const { detail: data } = /** @type {CustomEvent} */ (event); + menu.style.display = data ? "flex" : "none"; + if (data) { + menu.classList.add(getScoreColor(data.score)); + score.textContent = String(data.score); + } + }); + + /** @type {HTMLElement} */ (clone.getElementById("pan-scorecard")).replaceWith(scorecard); } return clone; diff --git a/workspaces/cli/public/components/package/pannels/scorecard/scorecard.css b/workspaces/cli/public/components/package/pannels/scorecard/scorecard.css deleted file mode 100644 index 1428ca28..00000000 --- a/workspaces/cli/public/components/package/pannels/scorecard/scorecard.css +++ /dev/null @@ -1,129 +0,0 @@ -#pan-scorecard { - padding: 10px; - border-radius: 8px 8px 0 0; - background: linear-gradient(to bottom, rgb(76 50 223 / 20%) 0%, rgb(0 0 0 / 0%) 15%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#334c32df', endColorstr='#00000000', GradientType=0); -} - -#pan-scorecard .container-title { - display: flex; - justify-content: center; - width: 100%; - color: var(--secondary); - margin-bottom: 10px; - font-family: mononoki; - letter-spacing: 1px; - text-shadow: 1px 1px 5px #2a6f99; - position: static; -} - -#pan-scorecard .score-header { - height: inherit; - flex-grow: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - margin-bottom: 5px; -} - -#pan-scorecard .score-header .score-text { - font-family: mononoki; - font-size: 16px; - margin-top: 5px; -} - -#pan-scorecard .score-header .score-value { - font-weight: 800; - color: var(--secondary); - margin-top: 6px; - font-size: 18px; -} - -#pan-scorecard .score-header .visualizer a { - color: #cfd8dc; - margin-top: 6px; -} - -#pan-scorecard .score-header .visualizer .logo { - vertical-align: sub; - width: 21px; - - /* https://codepen.io/sosuke/pen/Pjoqqp */ - filter: invert(99%) sepia(36%) saturate(748%) hue-rotate(170deg) brightness(93%) contrast(84%); -} - -#pan-scorecard .check { - display: flex; - height: 26px; - align-items: center; - cursor: pointer; - flex-wrap: wrap; - white-space: initial; - overflow: hidden; -} - -#pan-scorecard .check.visible { - height: auto; -} - -#pan-scorecard .check::before { - content: '▶'; - padding: 4px; - width: 20px; - text-align: center; - font-size: 11px; -} - -#pan-scorecard .check.visible::before { - content: '▼'; -} - -#pan-scorecard .check:hover { - background: #3722af; -} - -#pan-scorecard .check .info { - flex: 1 1 100%; - margin-left: 28px; - visibility: hidden; - font-size: 14px; -} - -#pan-scorecard .check .info.visible { - visibility: visible; -} - -#pan-scorecard .check .info strong { - font-weight: 500; -} - -#pan-scorecard .check .description, -#pan-scorecard .check .reason, -#pan-scorecard .check .detail { - margin-bottom: 10px; - overflow-wrap: break-word; -} - -#pan-scorecard .check .detail { - font-size: small; - margin-bottom: 6px; -} - -#pan-scorecard .name, -#pan-scorecard .score { - height: 26px; - line-height: 26px; - font-size: 16px; -} - -#pan-scorecard .name { - font-weight: 500; - color: #BBDEFB; - font-family: system-ui; -} - -#pan-scorecard .score { - margin-left: auto; - font-family: mononoki; -} diff --git a/workspaces/cli/public/components/package/pannels/scorecard/scorecard.js b/workspaces/cli/public/components/package/pannels/scorecard/scorecard.js index bf46e13d..e1503c2d 100644 --- a/workspaces/cli/public/components/package/pannels/scorecard/scorecard.js +++ b/workspaces/cli/public/components/package/pannels/scorecard/scorecard.js @@ -1,159 +1,260 @@ // Import Third-party Dependencies -import { getScoreColor, getVCSRepositoryPathAndPlatform } from "@nodesecure/utils"; +import { LitElement, html, css, nothing } from "lit"; +import { Task } from "@lit/task"; +import { getVCSRepositoryPathAndPlatform } from "@nodesecure/utils"; // Import Internal Dependencies -import * as utils from "../../../../common/utils.js"; +import { getI18n } from "../../../../common/utils.js"; import { fetchScorecardData, getScorecardLink } from "../../../../common/scorecard.js"; +import { scrollbarStyle } from "../../../../common/scrollbar-style.js"; +import "../../../icon/icon.js"; -export class Scorecard { - /** - * @param {import("../../package.js").PackageInfo} pkg - */ - constructor(pkg) { - this.package = pkg; - } +export class Scorecard extends LitElement { + static styles = [scrollbarStyle, css` +:host { + display: block; + overflow: hidden auto; + height: calc(100vh - 315px); + box-sizing: border-box; +} - hide() { - const scorecardMenu = /** @type {HTMLElement | null} */ (document.getElementById("scorecard-menu")); - if (scorecardMenu) { - scorecardMenu.style.display = "none"; - } - } +p { + margin: 0; +} - /** - * @param {!DocumentFragment} clone - */ - generate(clone) { - // Note: links.github.href can be a gitlab link - // Both links.github & links.gitlab are same, the showInHeader defines wheither its a gitlab or github link - const [repoNameRaw, platformRaw] = getVCSRepositoryPathAndPlatform( - /** @type {string} */ (this.package.links.github.href) - ) ?? []; - const repoName = /** @type {string} */ (repoNameRaw); - const platform = /** @type {string} */ (platformRaw); - const pannel = /** @type {HTMLElement} */ (clone.getElementById("pan-scorecard")); - - fetchScorecardData(repoName, platform).then((data) => { - if (!data) { - return this.hide(); - } +.score-header { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 5px; +} - pannel.appendChild(this.renderScorecard(data, repoName, platform)); - /** @type {HTMLElement} */ (document.getElementById("scorecard-menu")).style.display = "flex"; +.score-header .score-text { + font-family: mononoki; + font-size: 16px; + margin-top: 5px; +} - return void 0; - }); - } +.score-header .score-value { + font-weight: 800; + color: var(--secondary); + margin-top: 6px; + font-size: 18px; +} - /** - * @param {import("../../../../common/scorecard.js").ScorecardData} data - * @param {string} repoName - * @param {string} platform - */ - renderScorecard(data, repoName, platform) { - const { score, checks } = data; +.score-header .visualizer a { + color: #cfd8dc; + margin-top: 6px; +} - const container = utils.createDOMElement("div", { - classList: ["checks"] - }); +.score-header .visualizer .logo { + vertical-align: sub; + width: 21px; - for (const check of checks) { - container.append(generateCheckElement(check)); - } - - /** @type {HTMLElement} */ (document.getElementById("ossf-score")).innerText = String(score); - /** @type {HTMLElement} */ (document.getElementById("scorecard-menu")).classList.add( - getScoreColor(score) - ); - /** @type {HTMLElement} */ (document.getElementById("head-score")).innerText = String(score); - /** @type {HTMLElement} */ (document - .querySelector(".score-header .visualizer a")) - .setAttribute("href", getScorecardLink(repoName, platform)); - - container.childNodes.forEach((checkNode, checkKey) => { - const check = /** @type {HTMLElement} */ (checkNode); - check.addEventListener("click", () => { - if (check.children[2].classList.contains("visible")) { - check.children[2].classList.remove("visible"); - check.classList.remove("visible"); - - return; - } + /* https://codepen.io/sosuke/pen/Pjoqqp */ + filter: invert(99%) sepia(36%) saturate(748%) hue-rotate(170deg) brightness(93%) contrast(84%); +} - check.classList.add("visible"); - check.children[2].classList.add("visible"); - - container.childNodes.forEach((otherCheckNode, key) => { - const otherCheck = /** @type {HTMLElement} */ (otherCheckNode); - if (checkKey !== key) { - otherCheck.classList.remove("visible"); - otherCheck.children[2].classList.remove("visible"); - } - }); - }); - }); +.check { + display: flex; + height: 26px; + align-items: center; + cursor: pointer; + flex-wrap: wrap; + white-space: initial; + overflow: hidden; +} - return container; - } +.check.visible { + height: auto; +} + +.check::before { + content: '▶'; + padding: 4px; + width: 20px; + text-align: center; + font-size: 11px; +} + +.check.visible::before { + content: '▼'; +} + +.check:hover { + background: #3722af; +} + +.check .info { + flex: 1 1 100%; + margin-left: 28px; + visibility: hidden; + font-size: 14px; +} + +.check .info.visible { + visibility: visible; +} + +.check .info strong { + font-weight: 500; } -/** - * @param {import("../../../../common/scorecard.js").ScorecardCheck} check - */ -function generateCheckElement(check) { - if (!check.score || check.score < 0) { - check.score = 0; +.check .description, +.check .reason, +.check .detail { + margin-bottom: 10px; + overflow-wrap: break-word; +} + +.check .detail { + font-size: small; + margin-bottom: 6px; +} + +.name, +.score { + height: 26px; + line-height: 26px; + font-size: 16px; +} + +.name { + font-weight: 500; + color: #BBDEFB; + font-family: system-ui; +} + +.score { + margin-left: auto; + font-family: mononoki; +} + +.help-dialog { + display: flex; + padding: 10px; + border-radius: 8px; + margin-bottom: 10px; + border: 2px dashed #57e1bf4a; + color: #9de157; + letter-spacing: 0.5px; + align-items: center; +} + +.help-dialog> nsecure-icon { + margin-right: 11px; + font-size: 28px; +} + +.help-dialog>p { + font-size: 14px; + font-style: italic; +} + +.help-dialog>p b { + background: #9de157; + padding: 2px 5px; + color: #000; + border-radius: 4px; + font-style: normal; + font-weight: bold; + cursor: pointer; +} + +.help-dialog>p b:hover { + background: var(--secondary); +} + +.help-dialog>p a { + color: inherit; + cursor: pointer; + text-decoration: underline; + font-weight: bold; +} +`]; + + static properties = { + repository: { type: String }, + expandedCheck: { state: true } + }; + + constructor() { + super(); + this.repository = ""; + /** @type {string | null} */ + this.expandedCheck = null; } - const fragment = document.createDocumentFragment(); - fragment.appendChild( - utils.createDOMElement("div", { - classList: ["check"], - childs: [ - utils.createDOMElement("span", { - classList: ["name"], - text: check.name - }), - utils.createDOMElement("div", { - classList: ["score"], - text: `${check.score}/10` - }), - utils.createDOMElement("div", { - classList: ["info"], - childs: [ - utils.createDOMElement("div", { - classList: ["description"], - text: check.documentation.short - }), - utils.createDOMElement("div", { - classList: ["reason"], - childs: [ - utils.createDOMElement("p", { - childs: [ - utils.createDOMElement("strong", { - text: "Reasoning" - }) - ] - }), - utils.createDOMElement("span", { - text: check.reason - }) - ] - }) - ] - }) - ] - }) - ); - - for (const detail of check.details ?? []) { - /** @type {HTMLElement} */ (fragment.querySelector(".info")).appendChild( - utils.createDOMElement("div", { - classList: ["detail"], - text: detail - }) - ); + #scorecardTask = new Task(this, { + task: async([repository], { signal }) => { + this.expandedCheck = null; + const [repoName, platform] = getVCSRepositoryPathAndPlatform(repository) ?? []; + const data = repoName ? await fetchScorecardData(repoName, platform) : null; + if (!signal.aborted) { + this.dispatchEvent(new CustomEvent("scorecard-loaded", { detail: data })); + } + + return data ? { data, link: getScorecardLink(repoName, platform) } : null; + }, + args: () => [this.repository] + }); + + render() { + return this.#scorecardTask.render({ + complete: (result) => { + if (!result) { + return nothing; + } + const { data, link } = result; + const { package_info } = getI18n(); + const helpers = /** @type {Record} */ (/** @type {unknown} */ (package_info.helpers)); + + return html` +
+ +

${helpers.openSsf} ${helpers.here}

+
+
+ SCORE + ${data.score}/10 + + + OpenSSF Scorecard Monitor Visualizer + + +
+
${data.checks.map((check) => this.#renderCheck(check))}
+ `; + } + }); } - return fragment; + /** + * @param {import("../../../../common/scorecard.js").ScorecardCheck} check + */ + #renderCheck(check) { + const expanded = this.expandedCheck === check.name; + const toggle = () => { + this.expandedCheck = expanded ? null : check.name; + }; + + return html` +
+ ${check.name} +
${Math.max(0, check.score || 0)}/10
+
+
${check.documentation.short}
+
+

Reasoning

+ ${check.reason} +
+ ${(check.details ?? []).map((detail) => html`
${detail}
`)} +
+
+ `; + } } + +customElements.define("package-scorecard", Scorecard); diff --git a/workspaces/cli/public/global.d.ts b/workspaces/cli/public/global.d.ts index 61bcbe41..517e66fb 100644 --- a/workspaces/cli/public/global.d.ts +++ b/workspaces/cli/public/global.d.ts @@ -26,6 +26,7 @@ import type { PopupReport } from "./components/views/home/report/report.js"; import type { FileBox } from "./components/file-box/file-box.js"; import type { Gauge } from "./components/gauge/gauge.js"; import type { Expandable } from "./components/expandable/expandable.js"; +import type { Scorecard } from "./components/package/pannels/scorecard/scorecard.js"; declare global { interface HTMLElementTagNameMap { @@ -35,6 +36,7 @@ declare global { "settings-view": SettingsView; "network-breadcrumb": NetworkBreadcrumb; "command-palette": CommandPalette; + "package-scorecard": Scorecard; "package-files": Files; "package-licenses": Licenses; "package-vulnerabilities": Vulnerabilities; diff --git a/workspaces/cli/test/e2e/scorecard.spec.js b/workspaces/cli/test/e2e/scorecard.spec.js new file mode 100644 index 00000000..21b11cc5 --- /dev/null +++ b/workspaces/cli/test/e2e/scorecard.spec.js @@ -0,0 +1,106 @@ +// Import Third-party Dependencies +import { test, expect } from "@playwright/test"; + +// CONSTANTS +const kScorecard = { + score: 7.5, + checks: [ + { + name: "Code-Review", score: 8, reason: "Changes are reviewed", + documentation: { short: "Checks code reviews" }, details: ["Review detail"] + }, + { + name: "Maintained", score: -1, reason: "Not available", + documentation: { short: "Checks maintenance" }, details: null + } + ] +}; + +async function openPackage(page, nodeId = 0) { + await page.evaluate((id) => { + window.dispatchEvent(new CustomEvent("tree-node-click", { detail: { nodeId: id } })); + }, nodeId); + await expect(page.locator("package-scorecard")).toBeAttached(); +} + +test.beforeEach(async({ page }) => { + await page.route("**/config", async(route) => { + const response = await route.fetch(); + const config = await response.json(); + await route.fulfill({ json: { ...config, disableExternalRequests: false } }); + }); + await page.route("**/scorecard/**", (route) => route.fulfill({ json: { data: kScorecard } })); + await page.goto("/"); + await page.waitForFunction(() => window.networkNav); +}); + +test("renders the score, links and exclusive expandable checks", async({ page }) => { + await openPackage(page); + await expect(page.locator("#ossf-score")).toHaveText("7.5"); + await page.locator("#scorecard-menu").click(); + const panel = page.locator("package-scorecard"); + await expect(panel.locator(".score-value")).toHaveText("7.5/10"); + await expect(panel.locator(".visualizer a")).toHaveAttribute("href", /github.com\/debug-js\/debug$/); + const checks = panel.locator(".check"); + await expect(checks.nth(1).locator(".score")).toHaveText("0/10"); + await expect(checks.nth(1)).toBeInViewport({ ratio: 1 }); + await checks.nth(0).click(); + await expect(checks.nth(0).locator(".detail")).toHaveText("Review detail"); + await expect(checks.nth(0).locator(".info")).toBeVisible(); + await checks.nth(1).click(); + await expect(checks.nth(0).locator(".info")).not.toBeVisible(); + await expect(checks.nth(1).locator(".info")).toBeVisible(); + await checks.nth(1).click(); + await expect(checks.nth(1).locator(".info")).not.toBeVisible(); + await page.locator('[data-menu="info"]').click(); + await expect(panel).not.toBeVisible(); +}); + +test("hides the tab when the API has no scorecard", async({ page }) => { + await page.route("**/scorecard/**", (route) => route.fulfill({ json: { data: null } })); + const response = page.waitForResponse(/\/scorecard\//); + await openPackage(page); + await response; + await expect(page.locator("#scorecard-menu")).not.toBeVisible(); + await expect(page.locator("package-scorecard .checks")).toHaveCount(0); +}); + +test("does not fetch scorecards when external requests are disabled", async({ page }) => { + await page.evaluate(() => { + window.settings.config.disableExternalRequests = true; + window.dispatchEvent(new CustomEvent("tree-node-click", { detail: { nodeId: 0 } })); + }); + await expect(page.locator("#package-info")).toHaveClass("slide-in"); + await expect(page.locator("package-scorecard")).toHaveCount(0); + await expect(page.locator("#scorecard-menu")).not.toBeVisible(); +}); + +test("a late response cannot overwrite the next package's score", async({ page }) => { + let releaseFirst; + const firstResponse = new Promise((resolve) => { + releaseFirst = resolve; + }); + let first = true; + await page.route("**/scorecard/**", async(route) => { + if (first) { + first = false; + await firstResponse; + await route.fulfill({ json: { data: { ...kScorecard, score: 1 } } }); + } + else { + await route.fulfill({ json: { data: kScorecard } }); + } + }); + const firstRequest = page.waitForRequest(/\/scorecard\//); + await openPackage(page); + await firstRequest; + await openPackage(page, 1); + await expect(page.locator("#ossf-score")).toHaveText("7.5"); + const response = page.waitForResponse(/\/scorecard\//); + releaseFirst(); + await response; + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + })); + await expect(page.locator("#ossf-score")).toHaveText("7.5"); +});