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`
+
+
+ ${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");
+});