From a9e95b5b03c0cfac217d81d26182217e7c155329 Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Mon, 27 Apr 2026 20:44:15 +0200 Subject: [PATCH] feat(interface): add clear cache action to command palette --- .changeset/chilly-parts-cross.md | 5 + .changeset/famous-plants-lay.md | 5 + .changeset/many-clowns-deny.md | 5 + i18n/english.js | 1 + i18n/french.js | 1 + playwright.config.js | 13 ++- .../command-palette/command-palette.js | 9 +- public/main.js | 3 +- public/websocket.js | 3 +- test/e2e/command-palette.spec.js | 107 +++++++++++++++++- test/e2e/global-teardown.js | 11 ++ workspaces/cache/docs/PayloadCache.md | 2 +- workspaces/cache/src/PayloadCache.ts | 3 +- workspaces/cache/test/PayloadCache.test.ts | 17 +++ .../server/src/websocket/commands/clear.ts | 15 +++ workspaces/server/src/websocket/index.ts | 5 +- .../server/src/websocket/websocket.types.ts | 8 +- 17 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 .changeset/chilly-parts-cross.md create mode 100644 .changeset/famous-plants-lay.md create mode 100644 .changeset/many-clowns-deny.md create mode 100644 test/e2e/global-teardown.js create mode 100644 workspaces/server/src/websocket/commands/clear.ts diff --git a/.changeset/chilly-parts-cross.md b/.changeset/chilly-parts-cross.md new file mode 100644 index 00000000..7c6fe23b --- /dev/null +++ b/.changeset/chilly-parts-cross.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/server": minor +--- + +Add `CLEAR` WebSocket command to clear the payload cache diff --git a/.changeset/famous-plants-lay.md b/.changeset/famous-plants-lay.md new file mode 100644 index 00000000..65403922 --- /dev/null +++ b/.changeset/famous-plants-lay.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/cache": minor +--- + +Add support for `NODESECURE_PAYLOADS_PATH` env diff --git a/.changeset/many-clowns-deny.md b/.changeset/many-clowns-deny.md new file mode 100644 index 00000000..08eedc12 --- /dev/null +++ b/.changeset/many-clowns-deny.md @@ -0,0 +1,5 @@ +--- +"@nodesecure/cache": patch +--- + +Reset `currentSpec` to `null` when manifest load fails diff --git a/i18n/english.js b/i18n/english.js index e4b26546..5caed500 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -284,6 +284,7 @@ const ui = { action_reset_view: "Reset view", action_copy_packages: "Copy packages", action_export_payload: "Export payload", + action_clear_cache: "Clear all cached packages", section_presets: "Quick filters", preset_has_vulnerabilities: "Has vulnerabilities", preset_has_scripts: "Has install scripts", diff --git a/i18n/french.js b/i18n/french.js index 09d25908..5ef17116 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -284,6 +284,7 @@ const ui = { action_reset_view: "Réinitialiser la vue", action_copy_packages: "Copier les packages", action_export_payload: "Exporter le payload", + action_clear_cache: "Vider tous les packages en cache", section_presets: "Filtres rapides", preset_has_vulnerabilities: "Contient des vulnérabilités", preset_has_scripts: "Scripts d'installation", diff --git a/playwright.config.js b/playwright.config.js index 88b0a8a8..ac119c9f 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -1,14 +1,25 @@ +// Import Node.js Dependencies +import os from "node:os"; +import path from "node:path"; + // Import Third-party Dependencies import { defineConfig } from "@playwright/test"; +// CONSTANTS +const kE2ECachePath = path.join(os.tmpdir(), "nsecure-e2e-payloads"); + export default defineConfig({ testDir: "./test/e2e", + globalTeardown: "./test/e2e/global-teardown.js", use: { baseURL: "http://localhost:3000" }, webServer: { command: "node . open ./test/e2e/fixtures/nsecure-result.json --port 3000 --ws-port 1339", - env: { NODESECURE_NO_OPEN: true }, + env: { + NODESECURE_NO_OPEN: true, + NODESECURE_PAYLOADS_PATH: kE2ECachePath + }, port: 3000, timeout: 15_000 } diff --git a/public/components/command-palette/command-palette.js b/public/components/command-palette/command-palette.js index 36e2b47a..fa549eef 100644 --- a/public/components/command-palette/command-palette.js +++ b/public/components/command-palette/command-palette.js @@ -34,7 +34,8 @@ const kActions = [ { id: "toggle_theme", shortcut: "t" }, { id: "reset_view", shortcut: "r" }, { id: "copy_packages", shortcut: "c" }, - { id: "export_payload", shortcut: "e" } + { id: "export_payload", shortcut: "e" }, + { id: "clear_cache", shortcut: "x" } ]; const kWarningItems = Object.keys(warnings) .map((id) => { @@ -432,6 +433,9 @@ class CommandPalette extends LitElement { } break; } + case "clear_cache": + window.socket.commands.clear(); + break; } this.#close(); @@ -577,6 +581,9 @@ class CommandPalette extends LitElement { case "export_payload": label = i18n.action_export_payload; break; + case "clear_cache": + label = i18n.action_clear_cache; + break; default: label = action.id; } diff --git a/public/main.js b/public/main.js index 604cf8fa..dcb6b749 100644 --- a/public/main.js +++ b/public/main.js @@ -224,9 +224,10 @@ async function onSocketInitOrReload(event) { searchview.cachedSpecs = cache; searchview.reset(); - if (data.status === "RELOAD" && cache.length === 0) { + if (cache.length === 0) { window.navigation.hideMenu("network--view"); window.navigation.hideMenu("home--view"); + window.navigation.hideMenu("tree--view"); window.navigation.hideMenu("warnings--view"); window.navigation.setNavByName("search--view"); } diff --git a/public/websocket.js b/public/websocket.js index 195aef07..f7591be7 100644 --- a/public/websocket.js +++ b/public/websocket.js @@ -19,7 +19,8 @@ export class WebSocketClient extends EventTarget { */ this.commands = { search: (spec) => this.send({ commandName: "SEARCH", spec }), - remove: (spec) => this.send({ commandName: "REMOVE", spec }) + remove: (spec) => this.send({ commandName: "REMOVE", spec }), + clear: () => this.send({ commandName: "CLEAR" }) }; window.socket = this; diff --git a/test/e2e/command-palette.spec.js b/test/e2e/command-palette.spec.js index 5afc4a98..3fbd3ccb 100644 --- a/test/e2e/command-palette.spec.js +++ b/test/e2e/command-palette.spec.js @@ -16,7 +16,7 @@ test.describe("[command-palette] presets and actions", () => { test.beforeEach(async({ page }) => { await page.goto("/"); - await page.waitForSelector(`[data-menu="network--view"].active`); + await page.waitForFunction(() => window.cachedSpecs?.length > 0); i18n = await page.evaluate(() => { const lang = document.getElementById("lang").dataset.lang; @@ -40,9 +40,9 @@ test.describe("[command-palette] presets and actions", () => { await expect(presetsSection.locator(".range-preset")).toHaveCount(5); }); - test("renders all four action buttons", async({ page }) => { + test("renders all five action buttons", async({ page }) => { const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); - await expect(actionsSection.locator(".range-preset")).toHaveCount(4); + await expect(actionsSection.locator(".range-preset")).toHaveCount(5); }); test("clicking a preset adds a chip and hides the presets section", async({ page }) => { @@ -186,7 +186,7 @@ test.describe("[command-palette] dep filter", () => { test.beforeEach(async({ page }) => { await page.goto("/"); - await page.waitForSelector(`[data-menu="network--view"].active`); + await page.waitForFunction(() => window.cachedSpecs?.length > 0); i18n = await page.evaluate(() => { const lang = document.getElementById("lang").dataset.lang; @@ -268,7 +268,7 @@ test.describe("[command-palette] ignore flags and warnings", () => { test.beforeEach(async({ page }) => { await page.goto("/"); - await page.waitForSelector(`[data-menu="network--view"].active`); + await page.waitForFunction(() => window.cachedSpecs?.length > 0); i18n = await page.evaluate(() => { const lang = document.getElementById("lang").dataset.lang; @@ -369,3 +369,100 @@ test.describe("[command-palette] ignore flags and warnings", () => { await expect(page.locator(".section").filter({ hasText: i18n.section_ignore_warnings })).not.toBeVisible(); }); }); + +test.describe("[command-palette] clear cache action", () => { + async function loadI18n(page) { + return page.evaluate(() => { + const lang = document.getElementById("lang").dataset.lang; + const activeLang = lang in window.i18n ? lang : "english"; + + return window.i18n[activeLang].search_command; + }); + } + + async function openPalette(page) { + await page.goto("/"); + await page.waitForFunction(() => window.cachedSpecs?.length > 0); + await page.locator(`[data-menu="network--view"].active`).click(); + await page.keyboard.press("Control+k"); + await expect(page.locator(".backdrop")).toBeVisible(); + } + + test("renders the clear cache action button", async({ page }) => { + await openPalette(page); + const i18n = await loadI18n(page); + + const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); + await expect(actionsSection.locator(".range-preset").filter({ hasText: i18n.action_clear_cache })).toBeVisible(); + }); + + // Alt+X is tested via WebSocket interception so the server cache is not actually + // cleared, keeping network--view available for the next test. + test("Alt+X sends the CLEAR command and closes the palette", async({ page }) => { + let clearSent = false; + + await page.routeWebSocket("ws://localhost:1339", (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((msg) => { + const data = JSON.parse(msg); + if (data.commandName === "CLEAR") { + clearSent = true; + ws.send(JSON.stringify({ status: "RELOAD", cache: [] })); + } + else { + server.send(msg); + } + }); + + server.onMessage((msg) => { + ws.send(msg); + }); + }); + + await openPalette(page); + + await page.keyboard.press("Alt+x"); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + await page.waitForSelector(`[data-menu="search--view"].active`); + expect(clearSent).toBe(true); + }); + + test("clicking clear cache closes the palette and hides data views", async({ page }) => { + let clearSent = false; + + await page.routeWebSocket("ws://localhost:1339", (ws) => { + const server = ws.connectToServer(); + + ws.onMessage((msg) => { + const data = JSON.parse(msg); + if (data.commandName === "CLEAR") { + clearSent = true; + ws.send(JSON.stringify({ status: "RELOAD", cache: [] })); + } + else { + server.send(msg); + } + }); + + server.onMessage((msg) => { + ws.send(msg); + }); + }); + + await openPalette(page); + const i18n = await loadI18n(page); + + const actionsSection = page.locator(".section").filter({ hasText: i18n.section_actions }); + await actionsSection.locator(".range-preset").filter({ hasText: i18n.action_clear_cache }).click(); + + await expect(page.locator(".backdrop")).not.toBeVisible(); + await page.waitForSelector(`[data-menu="search--view"].active`); + expect(clearSent).toBe(true); + + for (const menu of ["network--view", "home--view", "tree--view", "warnings--view"]) { + await expect(page.locator(`[data-menu="${menu}"]`)).toContainClass("hidden"); + } + }); +}); diff --git a/test/e2e/global-teardown.js b/test/e2e/global-teardown.js new file mode 100644 index 00000000..1e233729 --- /dev/null +++ b/test/e2e/global-teardown.js @@ -0,0 +1,11 @@ +// Import Node.js Dependencies +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export default async function globalTeardown() { + await fs.rm( + path.join(os.tmpdir(), "nsecure-e2e-payloads"), + { recursive: true, force: true } + ); +} diff --git a/workspaces/cache/docs/PayloadCache.md b/workspaces/cache/docs/PayloadCache.md index 8a0618bc..f82e14e3 100644 --- a/workspaces/cache/docs/PayloadCache.md +++ b/workspaces/cache/docs/PayloadCache.md @@ -44,7 +44,7 @@ export interface PayloadCacheOptions { The base directory where payloads are stored. -**Default**: `~/.nsecure/payloads` +**Default**: `NODESECURE_PAYLOADS_PATH` environment variable or `~/.nsecure/payloads` if not set. ### `static getPathBySpec(spec: string): string` diff --git a/workspaces/cache/src/PayloadCache.ts b/workspaces/cache/src/PayloadCache.ts index 0d3d3e7c..a54cf4f0 100644 --- a/workspaces/cache/src/PayloadCache.ts +++ b/workspaces/cache/src/PayloadCache.ts @@ -51,7 +51,7 @@ export interface PayloadCacheOptions { } export class PayloadCache { - static PATH = path.join(os.homedir(), ".nsecure", "payloads"); + static PATH = process.env.NODESECURE_PAYLOADS_PATH ?? path.join(os.homedir(), ".nsecure", "payloads"); static getPathBySpec( spec: string @@ -308,6 +308,7 @@ export class PayloadManifestCache { async load() { const storage = new Map(); + this.currentSpec = null; try { const manifestContent = await this.#fsProvider.readFile( diff --git a/workspaces/cache/test/PayloadCache.test.ts b/workspaces/cache/test/PayloadCache.test.ts index a4f729bf..bc0ffb69 100644 --- a/workspaces/cache/test/PayloadCache.test.ts +++ b/workspaces/cache/test/PayloadCache.test.ts @@ -773,5 +773,22 @@ describe("PayloadManifestCache", () => { assert.equal(storage.size, 0); }); + + it("should reset currentSpec when manifest read fails", async() => { + const mockFs = createMockFs(); + + mockFs.readFile.mock.mockImplementation( + () => Promise.reject(new Error("ENOENT")) + ); + + const manifest = new PayloadManifestCache({ + fsProvider: mockFs as unknown as typeof fs + }); + manifest.currentSpec = "express@4.18.2"; + + await manifest.load(); + + assert.equal(manifest.currentSpec, null); + }); }); }); diff --git a/workspaces/server/src/websocket/commands/clear.ts b/workspaces/server/src/websocket/commands/clear.ts new file mode 100644 index 00000000..6e618f03 --- /dev/null +++ b/workspaces/server/src/websocket/commands/clear.ts @@ -0,0 +1,15 @@ +// Import Internal Dependencies +import { context } from "../websocket.als.ts"; +import type { WebSocketResponse } from "../websocket.types.ts"; + +export async function* clear(): AsyncGenerator { + const { cache } = context.getStore()!; + + await cache.clear(); + await cache.load(); + + yield { + status: "RELOAD", + cache: [] + }; +} diff --git a/workspaces/server/src/websocket/index.ts b/workspaces/server/src/websocket/index.ts index dd3b068e..9c5c17a7 100644 --- a/workspaces/server/src/websocket/index.ts +++ b/workspaces/server/src/websocket/index.ts @@ -7,6 +7,7 @@ import type { PayloadCache } from "@nodesecure/cache"; // Import Internal Dependencies import { search } from "./commands/search.ts"; import { remove } from "./commands/remove.ts"; +import { clear } from "./commands/clear.ts"; import { context } from "./websocket.als.ts"; import type { WebSocketResponse, @@ -56,13 +57,15 @@ export class WebSocketServerInstanciator { }; const commandName = message.commandName; - this.#logger.info(`[ws|command.${commandName.toLowerCase()}] ${message.spec}`); + const specLog = "spec" in message ? ` ${message.spec}` : ""; + this.#logger.info(`[ws|command.${commandName.toLowerCase()}]${specLog}`); context.run(ctx, async() => { try { const socketMessages = match(message) .with({ commandName: "SEARCH" }, (command) => search(command.spec)) .with({ commandName: "REMOVE" }, (command) => remove(command.spec)) + .with({ commandName: "CLEAR" }, () => clear()) .exhaustive(); for await (const message of socketMessages) { diff --git a/workspaces/server/src/websocket/websocket.types.ts b/workspaces/server/src/websocket/websocket.types.ts index f277cc60..8a6fe139 100644 --- a/workspaces/server/src/websocket/websocket.types.ts +++ b/workspaces/server/src/websocket/websocket.types.ts @@ -38,10 +38,10 @@ export type WebSocketResponse = | ScanResponse | ErrorResponse; -export type WebSocketMessage = { - commandName: "SEARCH" | "REMOVE"; - spec: string; -}; +export type WebSocketMessage = + | { commandName: "SEARCH"; spec: string; } + | { commandName: "REMOVE"; spec: string; } + | { commandName: "CLEAR"; }; export interface WebSocketContext { socket: WebSocket;