From 9985f30eee7f16f52b82dcac8eb55b358fc60c47 Mon Sep 17 00:00:00 2001 From: PierreDemailly Date: Sun, 29 Mar 2026 18:55:11 +0200 Subject: [PATCH] test(interface): add e2e tests with playwright --- .github/workflows/nodejs.yml | 8 +- .gitignore | 4 + package.json | 2 + playwright.config.js | 15 ++ src/commands/http.js | 4 +- test/e2e/fixtures/nsecure-result.json | 225 ++++++++++++++++++++++++++ test/e2e/navigation.spec.js | 76 +++++++++ test/e2e/settings.spec.js | 54 +++++++ test/e2e/warnings.spec.js | 77 +++++++++ views/index.html | 4 +- 10 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 playwright.config.js create mode 100644 test/e2e/fixtures/nsecure-result.json create mode 100644 test/e2e/navigation.spec.js create mode 100644 test/e2e/settings.spec.js create mode 100644 test/e2e/warnings.spec.js diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index d0edec68..59f6c5d0 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -30,8 +30,14 @@ jobs: - name: Install dependencies run: npm install --ignore-scripts - name: Build - run: npm run build --ws --if-present + run: npm run build + - name: Install Playwright browsers + if: matrix.os == 'ubuntu-latest' + run: npx playwright install --with-deps - name: Run tests run: npm run coverage + - name: Run e2e tests + if: matrix.os == 'ubuntu-latest' + run: npm run test:e2e - name: Send coverage report to Codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 diff --git a/.gitignore b/.gitignore index f2f21648..addefcdf 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ typings/ .next nsecure-result.json +!**/fixtures/nsecure-result.json vuln.json tmp/ dist/ @@ -73,3 +74,6 @@ reports .DS_Store .claude + +# playwright test results +test-results diff --git a/package.json b/package.json index 61f499a8..13d1ae5c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "build:workspaces": "npm run build --ws --if-present", "test": "npm run test:cli && npm run lint && npm run lint:css", "test:cli": "node --no-warnings --test \"test/**/*.test.js\"", + "test:e2e": "playwright test", "test:all": "npm run test --ws --if-present", "coverage": "c8 --reporter=lcov npm run test", "ci:publish": "changeset publish", @@ -79,6 +80,7 @@ "@openally/config.eslint": "^2.4.2", "@openally/config.typescript": "1.3.0", "@openally/httpie": "1.1.2", + "@playwright/test": "^1.50.0", "@stylistic/stylelint-plugin": "5.0.1", "@types/node": "25.5.0", "c8": "11.0.0", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..88b0a8a8 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,15 @@ +// Import Third-party Dependencies +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./test/e2e", + 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 }, + port: 3000, + timeout: 15_000 + } +}); diff --git a/src/commands/http.js b/src/commands/http.js index 100b9acd..6ab2c72a 100644 --- a/src/commands/http.js +++ b/src/commands/http.js @@ -71,7 +71,9 @@ export async function start( const link = `http://localhost:${httpServer.address().port}`; console.log(kleur.magenta().bold(await i18n.getToken("cli.http_server_started")), kleur.cyan().bold(link)); - open(link); + if (Boolean(process.env.NODESECURE_NO_OPEN) === false) { + open(link); + } }); new WebSocketServerInstanciator({ diff --git a/test/e2e/fixtures/nsecure-result.json b/test/e2e/fixtures/nsecure-result.json new file mode 100644 index 00000000..b145bd91 --- /dev/null +++ b/test/e2e/fixtures/nsecure-result.json @@ -0,0 +1,225 @@ +{ + "id": "j6vlz7", + "rootDependency": { + "name": "ms", + "version": "2.1.3", + "integrity": "sha512-EY5JVSmS/ZEMr9kLaphuJfQMLLbK87KibiJPcp3GB4YQHlFK6Ynk9mr3WRVyYqKw8akoxIoZfzLWnBTREgvbmw==" + }, + "scannerVersion": "10.8.0", + "vulnerabilityStrategy": "none", + "warnings": [], + "highlighted": { + "contacts": [], + "packages": [], + "identifiers": [] + }, + "dependencies": { + "ms": { + "versions": { + "2.1.3": { + "id": 0, + "type": "cjs", + "usedBy": {}, + "isDevDependency": false, + "existOnRemoteRegistry": true, + "flags": [ + "hasWarnings" + ], + "warnings": [ + { + "kind": "unsafe-regex", + "location": [ + [ + 53, + 14 + ], + [ + 53, + 144 + ] + ], + "source": "JS-X-Ray", + "i18n": "sast_warnings.unsafe_regex", + "severity": "Warning", + "experimental": false, + "value": "^(-?(?:\\d+)?\\.?\\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$", + "file": "index.js" + } + ], + "dependencyCount": 0, + "gitUrl": null, + "alias": {}, + "description": "Tiny millisecond conversion utility", + "size": 6913, + "author": null, + "scripts": { + "precommit": "lint-staged", + "lint": "eslint lib/* bin/*", + "test": "mocha tests.js" + }, + "licenses": [ + { + "licenses": { + "MIT": "https://spdx.org/licenses/MIT.html#licenseText" + }, + "spdx": { + "osi": true, + "fsf": true, + "fsfAndOsi": true, + "includesDeprecated": false + }, + "fileName": "package.json" + }, + { + "licenses": { + "MIT": "https://spdx.org/licenses/MIT.html#licenseText" + }, + "spdx": { + "osi": true, + "fsf": true, + "fsfAndOsi": true, + "includesDeprecated": false + }, + "fileName": "license.md" + } + ], + "uniqueLicenseIds": [ + "MIT" + ], + "composition": { + "extensions": [ + ".md", + ".js", + ".json" + ], + "files": [ + "index.js", + "license.md", + "package.json", + "readme.md" + ], + "minified": [], + "unused": [], + "missing": [], + "required_files": [], + "required_nodejs": [], + "required_thirdparty": [], + "required_subpath": {} + }, + "repository": "vercel/ms", + "integrity": "1f743a8b72bd7a02b88d452246f50ff14164f32e", + "links": { + "npm": "https://www.npmjs.com/package/ms/v/2.1.3", + "homepage": "https://github.com/vercel/ms#readme", + "repository": "https://github.com/vercel/ms" + } + } + }, + "vulnerabilities": [], + "metadata": { + "homepage": "https://github.com/vercel/ms#readme", + "publishedCount": 32, + "lastVersion": "2.1.3", + "lastUpdateAt": "2020-12-08T13:54:35.223Z", + "hasReceivedUpdateInOneYear": false, + "hasChangedAuthor": false, + "integrity": { + "2.1.3": "1f743a8b72bd7a02b88d452246f50ff14164f32e" + }, + "author": { + "name": "rauchg", + "email": "rauchg@gmail.com" + }, + "publishers": [ + { + "name": "vercel-release-bot", + "email": "infra+release@vercel.com", + "version": "4.0.0-nightly.202508271359", + "at": "2025-08-27T14:00:01.232Z" + }, + { + "name": "leerobinson", + "email": "lrobinson2011@gmail.com", + "version": "3.0.0-canary.1", + "at": "2021-09-15T15:40:43.956Z" + }, + { + "name": "mrmckeb", + "email": "mrmckeb.npm@outlook.com", + "version": "3.0.0-beta.2", + "at": "2021-08-25T16:55:32.842Z" + }, + { + "name": "styfle", + "email": "steven@ceriously.com", + "version": "2.1.3", + "at": "2020-12-08T13:54:35.223Z" + }, + { + "name": "leo", + "email": "leo@zeit.co", + "version": "2.1.1", + "at": "2017-11-30T18:30:16.876Z" + }, + { + "name": "rauchg", + "email": "rauchg@gmail.com", + "version": "0.7.1", + "at": "2015-04-20T23:38:57.957Z" + } + ], + "maintainers": [ + { + "email": "matheus.frndes@gmail.com", + "name": "matheuss" + }, + { + "email": "rauchg@gmail.com", + "name": "rauchg" + }, + { + "email": "nick.tracey@vercel.com", + "name": "nick.tracey" + }, + { + "email": "infra+release@vercel.com", + "name": "vercel-release-bot" + }, + { + "email": "team@zeit.co", + "name": "zeit-bot" + }, + { + "email": "matt.j.straka@gmail.com", + "name": "matt.straka" + } + ], + "hasManyPublishers": false + } + } + }, + "metadata": { + "startedAt": 1774800760807, + "executionTime": 1250, + "apiCalls": [ + { + "name": "pacote.manifest ms@2.1.3", + "startedAt": 1774800760809, + "executionTime": 7 + }, + { + "name": "pacote.extract ms@2.1.3", + "startedAt": 1774800760821, + "executionTime": 114 + }, + { + "name": "tarball.scanDirOrArchive ms@2.1.3", + "startedAt": 1774800760938, + "executionTime": 330 + } + ], + "apiCallsCount": 3, + "errorCount": 0, + "errors": [] + } +} diff --git a/test/e2e/navigation.spec.js b/test/e2e/navigation.spec.js new file mode 100644 index 00000000..addeb702 --- /dev/null +++ b/test/e2e/navigation.spec.js @@ -0,0 +1,76 @@ +// Import Third-party Dependencies +import { test, expect } from "@playwright/test"; + +// CONSTANTS +const kDataMenus = [ + "home--view", + "network--view", + "tree--view", + "warnings--view" +]; +const kAlwaysVisibleMenus = [ + "search--view", + "settings--view" +]; + +test.describe("[navigation] with data", () => { + test.beforeEach(async({ page }) => { + await page.goto("/"); + await page.waitForSelector(`[data-menu="network--view"].active`); + }); + + test("all tabs are visible in the sidebar", async({ page }) => { + for (const menu of [...kDataMenus, ...kAlwaysVisibleMenus]) { + await expect(page.locator(`[data-menu="${menu}"]`)).not.toContainClass("hidden"); + } + }); + + test("network view is active by default", async({ page }) => { + await expect(page.locator("#network--view")).not.toContainClass("hidden"); + await expect(page.locator(`[data-menu="network--view"]`)).toContainClass("active"); + }); + + test("clicking the settings tab shows the settings view", async({ page }) => { + await page.locator(`[data-menu="settings--view"]`).click(); + + await expect(page.locator("#settings--view")).not.toContainClass("hidden"); + await expect(page.locator("#network--view")).toContainClass("hidden"); + }); + + test("pressing S navigates to the settings view", async({ page }) => { + await page.keyboard.press("s"); + + await expect(page.locator("#settings--view")).not.toContainClass("hidden"); + }); + + test("pressing A navigates to the warnings view", async({ page }) => { + await page.keyboard.press("a"); + + await expect(page.locator("#warnings--view")).not.toContainClass("hidden"); + }); +}); + +test.describe("[navigation] without data", () => { + test.beforeEach(async({ page }) => { + await page.route("**/data", (route) => route.fulfill({ status: 204 })); + await page.goto("/"); + await page.waitForSelector(`[data-menu="search--view"].active`); + }); + + test("data-dependent tabs are hidden in the sidebar", async({ page }) => { + for (const menu of kDataMenus) { + await expect(page.locator(`[data-menu="${menu}"]`)).toContainClass("hidden"); + } + }); + + test("always visible menus tabs remain visible", async({ page }) => { + for (const menu of kAlwaysVisibleMenus) { + await expect(page.locator(`[data-menu="${menu}"]`)).not.toContainClass("hidden"); + } + }); + + test("search view is the active view", async({ page }) => { + await expect(page.locator("#search--view")).not.toContainClass("hidden"); + await expect(page.locator(`[data-menu="search--view"]`)).toContainClass("active"); + }); +}); diff --git a/test/e2e/settings.spec.js b/test/e2e/settings.spec.js new file mode 100644 index 00000000..b149a7ce --- /dev/null +++ b/test/e2e/settings.spec.js @@ -0,0 +1,54 @@ +// Import Third-party Dependencies +import { test, expect } from "@playwright/test"; + +test.describe("settings page", () => { + test.beforeEach(async({ page }) => { + await page.goto("/"); + await page.waitForSelector(`[data-menu="network--view"].active`); + await page.locator(`[data-menu="settings--view"]`).click(); + await expect(page.locator("#settings--view")).not.toContainClass("hidden"); + }); + + test("renders the default package menu dropdown", async({ page }) => { + await expect(page.locator("#default_package_menu")).toBeVisible(); + }); + + test("renders the theme selector dropdown", async({ page }) => { + await expect(page.locator("#theme_selector")).toBeVisible(); + }); + + test("renders warning filter checkboxes from js-x-ray", async({ page }) => { + const checkboxes = page.locator("input[name='warnings']"); + + await expect(checkboxes.first()).toBeVisible(); + expect(await checkboxes.count()).toBeGreaterThan(0); + }); + + test("renders all flag filter checkboxes", async({ page }) => { + await expect(page.locator("#hasManyPublishers")).toBeVisible(); + await expect(page.locator("#hasIndirectDependencies")).toBeVisible(); + await expect(page.locator("#hasMissingOrUnusedDependency")).toBeVisible(); + await expect(page.locator("#isDead")).toBeVisible(); + await expect(page.locator("#isOutdated")).toBeVisible(); + await expect(page.locator("#hasDuplicate")).toBeVisible(); + }); + + test("renders keyboard shortcuts section with all hotkey inputs", async({ page }) => { + await expect(page.locator(".shortcuts")).toBeVisible(); + expect(await page.locator(".hotkey").count()).toBe(8); + }); + + test("save button is disabled on initial render", async({ page }) => { + await expect(page.locator(".save")).toContainClass("disabled"); + }); + + test("save button becomes enabled after changing a setting", async({ page }) => { + const themeSelector = page.locator("#theme_selector"); + const currentValue = await themeSelector.inputValue(); + const newValue = currentValue === "dark" ? "light" : "dark"; + + await themeSelector.selectOption(newValue); + + await expect(page.locator(".save")).not.toContainClass("disabled"); + }); +}); diff --git a/test/e2e/warnings.spec.js b/test/e2e/warnings.spec.js new file mode 100644 index 00000000..91153b3b --- /dev/null +++ b/test/e2e/warnings.spec.js @@ -0,0 +1,77 @@ +// Import Third-party Dependencies +import { test, expect } from "@playwright/test"; + +// CONSTANTS +const kCleanConfig = { + defaultPackageMenu: "info", + ignore: { warnings: [], flags: [] }, + showFriendlyDependencies: false, + theme: "dark", + disableExternalRequests: true +}; + +test.describe("warnings page", () => { + test.beforeEach(async({ page }) => { + // Mock config route with clean config + await page.route("**/config", async(route) => { + if (route.request().method() === "GET") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(kCleanConfig) + }); + + return; + } + + await route.continue(); + }); + + await page.goto("/"); + await page.waitForSelector(`[data-menu="network--view"].active`); + await page.locator(`[data-menu="warnings--view"]`).click(); + await expect(page.locator("#warnings--view")).not.toContainClass("hidden"); + + // Wait for the Lit component to render after secureDataSet is assigned + await page + .locator("warnings-view") + .locator(".warnings-header") + .waitFor(); + }); + + test("shows 1 warning occurrence and 1 affected package in the header", async({ page }) => { + const subtitle = page + .locator("warnings-view") + .locator(".warnings-subtitle"); + + await expect(subtitle).toContainText("1"); + }); + + test("shows the unsafe-regex kind card in the Warning section", async({ page }) => { + const unsafeRegexKindName = page + .locator("warnings-view") + .locator(".kind-name") + .filter({ hasText: "unsafe-regex" }); + await expect(unsafeRegexKindName).toBeVisible(); + }); + + test("lists ms@2.1.3 in the unsafe-regex card", async({ page }) => { + const unsafeRegexCard = page + .locator("warnings-view") + .locator(".kind-card") + .filter({ has: page.locator(".kind-name", { hasText: "unsafe-regex" }) }); + const packageRow = unsafeRegexCard + .locator(".pkg-row") + .filter({ has: page.locator(".pkg-name", { hasText: /^ms/ }) }); + + await expect(packageRow.locator(".pkg-name")).toContainText("ms"); + await expect(packageRow.locator(".version")).toContainText("@2.1.3"); + }); + + test("Warning severity pill is non-zero, Critical is zero", async({ page }) => { + const warningsView = page.locator("warnings-view"); + + await expect(warningsView.locator(".severity-pill.warning")).not.toContainClass("zero"); + await expect(warningsView.locator(".severity-pill.critical")).toContainClass("zero"); + }); +}); diff --git a/views/index.html b/views/index.html index d4608d50..ed6a163a 100644 --- a/views/index.html +++ b/views/index.html @@ -155,8 +155,8 @@

[[=z.token('settings.general.title')]]

- - + +