diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6f912f..0a59ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,6 +253,10 @@ jobs: working-directory: web run: npm run audit + - name: Run unit tests + working-directory: web + run: npm test + - name: Lint working-directory: web run: npm run lint @@ -351,6 +355,10 @@ jobs: node --check platforms.js npm pack --dry-run --ignore-scripts + - name: Test Windows audit evidence helper + if: runner.os == 'Windows' + run: node --test scripts/test_win_audit_verify.mjs + deployment: name: Deployment profiles runs-on: ubuntu-24.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 104b02f..e883395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,12 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- CI Web job now runs unit tests (`npm test`), including audit retry regression + tests. `npm run audit` retries recognized transient registry failures up to + three attempts per threshold (two retries), with a 60-second limit per attempt + and portable backoff. It starts npm through Node on Windows, preserves audit + reports and failure diagnostics, + and fails immediately for vulnerabilities, unknown errors or incomplete runs. - The npm installer no longer aborts a concurrent first run on Windows. The per-asset cache lock previously treated only `EEXIST` as contention, but a contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process diff --git a/scripts/test_win_audit_verify.mjs b/scripts/test_win_audit_verify.mjs new file mode 100644 index 0000000..0b7b5ae --- /dev/null +++ b/scripts/test_win_audit_verify.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +assert.equal(process.platform, "win32", "Run this process regression in the Windows CI job"); +const source = readFileSync(new URL("./win-audit-verify.bat", import.meta.url), "utf8"); + +function invoke(script, status) { + const directory = mkdtempSync(join(tmpdir(), "mem audit evidence ")); + try { + writeFileSync(join(directory, "verify.bat"), script); + // cmd.exe resolves the real .cmd fixture from its working directory. + // No registry, installed package, or user npm configuration is involved. + writeFileSync(join(directory, "npm.cmd"), + `@echo off\r\necho fixture npm audit status: ${status}\r\nexit /b ${status}\r\n`); + const result = spawnSync(process.env.ComSpec || "cmd.exe", ["/d", "/c", "verify.bat"], { + cwd: directory, + encoding: "utf8", + timeout: 15_000, + env: { ...process.env, AUDIT_RC: "" }, + }); + assert.ifError(result.error); + assert.match(result.stdout, new RegExp(`fixture npm audit status: ${status}`)); + return result; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +for (const status of [0, 7]) { + test(`prints completed evidence and preserves audit exit ${status}`, () => { + const result = invoke(source, status); + assert.equal(result.status, status); + assert.match(result.stdout, /\[win-audit-verify\] finished at/); + assert.match(result.stdout, new RegExp(`npm run audit exit code: ${status}`)); + }); +} + +test("negative control: omitting CALL loses the post-audit evidence", () => { + const broken = source.replace("call npm run audit", "npm run audit"); + assert.notEqual(broken, source); + const result = invoke(broken, 7); + assert.doesNotMatch(result.stdout, /\[win-audit-verify\] finished at/); +}); + +test("negative control: separate ENDLOCAL loses a nonzero saved exit status", () => { + const broken = source.replace("endlocal & exit /b %AUDIT_RC%", "endlocal\r\nexit /b %AUDIT_RC%"); + assert.notEqual(broken, source); + const result = invoke(broken, 7); + assert.match(result.stdout, /npm run audit exit code: 7/); + assert.equal(result.status, 0); +}); diff --git a/scripts/win-audit-verify.bat b/scripts/win-audit-verify.bat new file mode 100644 index 0000000..e899083 --- /dev/null +++ b/scripts/win-audit-verify.bat @@ -0,0 +1,41 @@ +@echo off +REM win-audit-verify.bat — Verify audit-retry.mjs works on real Windows. +REM Must be run from a real Windows terminal (cmd.exe), NOT WSL. +REM Captures: commit, npm_execpath, full audit output, exit code. + +setlocal enabledelayedexpansion + +echo === win-audit-verify === +echo. + +REM 1. Show which commit is being tested +echo --- git head --- +git rev-parse HEAD +git log -1 --format=^"%%h %%s^" +echo. + +REM 2. Show Node version +echo --- node --- +node --version +echo. + +REM 3. Prove npm_execpath is set by npm run, and that a direct node.exe +REM invocation does NOT have it (the failure mode from the review). +echo --- npm_execpath probe via node -e (should be EMPTY) --- +node -e "console.log('npm_execpath=' + (process.env.npm_execpath || '(unset)'))" +echo. + +REM 4. Run the actual audit via npm run — this is the path that supplies npm_execpath. +echo --- npm run audit (full output) --- +echo [win-audit-verify] starting npm run audit at %DATE% %TIME% +call npm run audit +set AUDIT_RC=!ERRORLEVEL! +echo [win-audit-verify] finished at %DATE% %TIME% +echo. + +REM 5. Report exit code +echo --- result --- +echo [win-audit-verify] npm run audit exit code: !AUDIT_RC! +echo. + +endlocal & exit /b %AUDIT_RC% diff --git a/web/audit-retry.mjs b/web/audit-retry.mjs new file mode 100644 index 0000000..8ef1bfe --- /dev/null +++ b/web/audit-retry.mjs @@ -0,0 +1,133 @@ +import { spawnSync } from "node:child_process"; +import { setTimeout as sleep } from "node:timers/promises"; +import { pathToFileURL } from "node:url"; + +const MAX_ATTEMPTS = 3; +const BACKOFF_MS = 10_000; +// At most six 60-second attempts and four 10-second backoffs across both thresholds. +const ATTEMPT_TIMEOUT_MS = 60_000; + +const NETWORK_PATTERNS = [ + "network timeout", + "503 Service Unavailable", + "ECONNRESET", + "ETIMEDOUT", +]; + +const VULNERABILITY_PATTERNS = [ + "found \\d+ vulnerabilit(?:y|ies)", + "npm audit report", + "vulnerabilities found", +]; + +const COMMANDS = [ + { + label: "production dependencies (moderate threshold)", + args: ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"], + }, + { + label: "all dependencies (high threshold)", + args: ["audit", "--audit-level=high", "--fetch-timeout=45000"], + }, +]; + +function isNetworkError(stderr) { + return NETWORK_PATTERNS.some((p) => stderr.toLowerCase().includes(p.toLowerCase())); +} + +function isVulnerabilityReport(stdout) { + return VULNERABILITY_PATTERNS.some((p) => new RegExp(p, "i").test(stdout)); +} + +async function runWithRetry(label, args, { spawn, wait, npmExecPath, stdout, stderr }) { + let result; + const finish = () => { + stdout.write(result.stdout ?? ""); + stderr.write(result.stderr ?? ""); + if (result.error) { + stderr.write(`[audit-retry] ${result.error.code ?? "spawn error"}: ${result.error.message}\n`); + } + if (result.signal) { + stderr.write(`[audit-retry] terminated by ${result.signal}.\n`); + } + return Number.isInteger(result.status) && result.status > 0 && result.status <= 255 ? result.status : 1; + }; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const ts = new Date().toISOString(); + stderr.write( + `[audit-retry] ${ts} — ${label} (attempt ${attempt}/${MAX_ATTEMPTS})\n` + ); + + // npm run supplies the CLI path, including on Windows where npm is a .cmd + // shim that cannot be launched directly with shell-free spawnSync. + result = spawn(npmExecPath ? process.execPath : "npm", npmExecPath ? [npmExecPath, ...args] : args, { + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + timeout: ATTEMPT_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + + // An interrupted or unstarted audit is never a valid audit result, even + // when its partial output happens to mention a transient network error. + if (result.error || result.signal || !Number.isInteger(result.status) || result.status < 0 || result.status > 255) { + stderr.write(`[audit-retry] ${label} did not complete — not retrying.\n`); + return finish(); + } + + if (result.status === 0) { + stdout.write(result.stdout ?? ""); + stderr.write(result.stderr ?? ""); + stderr.write(`[audit-retry] ${label} passed.\n`); + return 0; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + + if (isVulnerabilityReport(output)) { + stderr.write( + `[audit-retry] ${label} found real vulnerabilities — not retrying.\n` + ); + return finish(); + } + + if (isNetworkError(output)) { + if (attempt < MAX_ATTEMPTS) { + stderr.write(`[audit-retry] ${label} hit a network error — will retry.\n`); + await wait(BACKOFF_MS); + continue; + } + stderr.write(`[audit-retry] ${label} exhausted ${MAX_ATTEMPTS} attempts.\n`); + return finish(); + } + + stderr.write( + `[audit-retry] ${label} failed with unrecognized error — not retrying.\n` + ); + return finish(); + } +} + +export async function runAudits({ + spawn = spawnSync, + wait = sleep, + npmExecPath = process.env.npm_execpath, + platform = process.platform, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + if (platform === "win32" && !npmExecPath) { + stderr.write("[audit-retry] Run npm run audit so npm supplies its CLI path on Windows.\n"); + return 1; + } + + for (const cmd of COMMANDS) { + const code = await runWithRetry(cmd.label, cmd.args, { spawn, wait, npmExecPath, stdout, stderr }); + if (code !== 0) return code; + } + return 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exitCode = await runAudits(); +} diff --git a/web/audit-retry.test.mjs b/web/audit-retry.test.mjs new file mode 100644 index 0000000..ef17dda --- /dev/null +++ b/web/audit-retry.test.mjs @@ -0,0 +1,248 @@ +// @vitest-environment node +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { runAudits } from "./audit-retry.mjs"; + +const PASS = { status: 0, stdout: "", stderr: "" }; +const PROD_ARGS = ["audit", "--omit=dev", "--audit-level=moderate", "--fetch-timeout=45000"]; +const ALL_ARGS = ["audit", "--audit-level=high", "--fetch-timeout=45000"]; + +function harness(results, overrides = {}) { + let stdout = ""; + let stderr = ""; + const spawn = vi.fn(() => { + const result = results.shift(); + if (!result) throw new Error("Unexpected audit attempt"); + return result; + }); + const wait = vi.fn(async () => {}); + return { + spawn, + wait, + output: () => ({ stdout, stderr }), + run: () => runAudits({ + spawn, + wait, + npmExecPath: "/npm with spaces/npm-cli.js", + stdout: { write: (text) => { stdout += text; } }, + stderr: { write: (text) => { stderr += text; } }, + ...overrides, + }), + }; +} + +describe("audit retry policy", () => { + it("preserves successful audit reports, including findings below the threshold", async () => { + const production = { status: 0, stdout: "found 0 vulnerabilities\n", stderr: "production notice\n" }; + const development = { status: 0, stdout: "# npm audit report\n1 moderate severity vulnerability\n", stderr: "development notice\n" }; + const test = harness([production, development]); + expect(await test.run()).toBe(0); + expect(test.output().stdout).toBe(production.stdout + development.stdout); + expect(test.output().stderr).toContain(production.stderr); + expect(test.output().stderr).toContain(development.stderr); + expect(test.spawn).toHaveBeenCalledTimes(2); + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("requires both unchanged thresholds to pass, using Node and the npm CLI path", async () => { + const test = harness([PASS, PASS], { platform: "win32" }); + expect(await test.run()).toBe(0); + expect(test.spawn.mock.calls.map(([command, args]) => [command, args])).toEqual([ + [process.execPath, ["/npm with spaces/npm-cli.js", ...PROD_ARGS]], + [process.execPath, ["/npm with spaces/npm-cli.js", ...ALL_ARGS]], + ]); + for (const [, args, options] of test.spawn.mock.calls) { + expect(options).toMatchObject({ timeout: 60_000, killSignal: "SIGKILL" }); + expect(options.shell).toBeUndefined(); + const fetchTimeout = args.filter(a => a.startsWith("--fetch-timeout=")).map(a => Number(a.split("=")[1])); + for (const ms of fetchTimeout) expect(ms).toBeLessThan(options.timeout); + } + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("keeps direct Node invocation available on Unix", async () => { + const test = harness([PASS, PASS], { npmExecPath: "", platform: "linux" }); + expect(await test.run()).toBe(0); + expect(test.spawn.mock.calls[0].slice(0, 2)).toEqual(["npm", PROD_ARGS]); + }); + + it("fails with an actionable message when direct invocation lacks npm on Windows", async () => { + const test = harness([], { npmExecPath: "", platform: "win32" }); + expect(await test.run()).toBe(1); + expect(test.output().stderr).toContain("Run npm run audit"); + expect(test.spawn).not.toHaveBeenCalled(); + }); + + it.each(["network timeout", "503 Service Unavailable", "econnreset", "ETIMEDOUT"])( + "retries a recognized transient failure: %s", async (message) => { + const test = harness([{ status: 1, stderr: message }, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.spawn).toHaveBeenCalledTimes(3); + expect(test.wait.mock.calls).toEqual([[10_000]]); + }, + ); + + it("recognizes transient failures on stdout", async () => { + const test = harness([{ status: 1, stdout: "ECONNRESET" }, PASS, PASS]); + expect(await test.run()).toBe(0); + expect(test.wait).toHaveBeenCalledTimes(1); + }); + + it("caps retries at three, skips the final backoff, and retains final diagnostics", async () => { + const failure = { status: 7, stdout: "final stdout\n", stderr: "503 Service Unavailable: final detail\n" }; + const test = harness([failure, failure, failure]); + expect(await test.run()).toBe(7); + expect(test.spawn).toHaveBeenCalledTimes(3); + expect(test.wait.mock.calls).toEqual([[10_000], [10_000]]); + expect(test.output().stdout).toBe(failure.stdout); + expect(test.output().stderr).toContain(failure.stderr); + expect(test.output().stderr).toContain("exhausted 3 attempts"); + expect(test.output().stderr.match(/will retry/g)).toHaveLength(2); + }); + + it("gives the second threshold its own bounded retry budget", async () => { + const failure = { status: 1, stderr: "ETIMEDOUT" }; + const test = harness([failure, failure, PASS, failure, failure, PASS]); + expect(await test.run()).toBe(0); + expect(test.spawn).toHaveBeenCalledTimes(6); + expect(test.wait.mock.calls).toEqual(Array(4).fill([10_000])); + expect(test.spawn.mock.calls[3][1].slice(1)).toEqual(ALL_ARGS); + }); + + it.each(["stdout", "stderr"])("never retries vulnerabilities on %s, even with network text", async (stream) => { + const test = harness([{ status: 1, [stream]: "# npm audit report\nETIMEDOUT\n" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output()[stream]).toContain("# npm audit report"); + }); + + it.each(["found 1 vulnerability", "found 2 vulnerabilities", "vulnerabilities found"])( + "prioritizes vulnerability summaries over network text: %s", async (message) => { + const test = harness([{ status: 1, stdout: message, stderr: "ECONNRESET" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + }, + ); + + it.each(["E401 unauthorized", "invalid config", "fetch failed", "audit endpoint returned an error"])( + "fails unknown or non-transient errors without retry: %s", async (message) => { + const test = harness([{ status: 2, stdout: "diagnostic\n", stderr: message }]); + expect(await test.run()).toBe(2); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stdout).toBe("diagnostic\n"); + expect(test.output().stderr).toContain(message); + }, + ); + + it.each([ + ["missing executable", { status: null, error: Object.assign(new Error("npm missing"), { code: "ENOENT" }) }, "ENOENT"], + ["timeout", { status: null, error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }) }, "timed out"], + ["signal", { status: null, signal: "SIGTERM" }, "SIGTERM"], + ["null status", { status: null }, "did not complete"], + ["missing status", {}, "did not complete"], + ["negative status", { status: -1 }, "did not complete"], + ["out of range status", { status: 256 }, "did not complete"], + ["buffer overflow", { status: null, error: Object.assign(new Error("output limit"), { code: "ENOBUFS" }) }, "ENOBUFS"], + ["error with zero status", { status: 0, error: new Error("incomplete") }, "incomplete"], + ["signal with zero status", { status: 0, signal: "SIGKILL" }, "SIGKILL"], + ])("fails closed for %s regardless of transient-looking output", async (_label, result, diagnostic) => { + const test = harness([{ ...result, stderr: "503 Service Unavailable" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stderr).toContain(diagnostic); + expect(test.output().stderr).toContain("503 Service Unavailable"); + }); + + it("fails overall if the second threshold finds vulnerabilities", async () => { + const test = harness([PASS, { status: 1, stdout: "found 2 vulnerabilities" }]); + expect(await test.run()).toBe(1); + expect(test.spawn).toHaveBeenCalledTimes(2); + expect(test.wait).not.toHaveBeenCalled(); + }); + + it("vite.config.ts includes audit-retry.test.mjs in test collection", async () => { + const configPath = join(__dirname, "vite.config.ts"); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("audit-retry.test.mjs"); + }); +}); + +describe("audit CLI process behavior", () => { + const directories = []; + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); + }); + + function fixture(results) { + const directory = mkdtempSync(join(tmpdir(), "audit npm fixture ")); + directories.push(directory); + const cli = join(directory, "npm cli.cjs"); + const log = join(directory, "calls.json"); + writeFileSync(cli, ` + const fs = require('node:fs'); + const log = ${JSON.stringify(log)}; + const calls = fs.existsSync(log) ? JSON.parse(fs.readFileSync(log, 'utf8')) : []; + const result = ${JSON.stringify(results)}[calls.length]; + calls.push(process.argv.slice(2)); + fs.writeFileSync(log, JSON.stringify(calls)); + process.stdout.write(result.stdout || ''); + process.stderr.write(result.stderr || ''); + process.exitCode = result.status; + `); + return { cli, log }; + } + + function invoke(cli) { + return spawnSync(process.execPath, [fileURLToPath(new URL("./audit-retry.mjs", import.meta.url))], { + encoding: "utf8", + timeout: 5_000, + killSignal: "SIGKILL", + env: { ...process.env, npm_execpath: cli }, + }); + } + + it("executes a CLI path with spaces and exits zero only after both audits", () => { + const { cli, log } = fixture([PASS, PASS]); + const result = invoke(cli); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(JSON.parse(readFileSync(log, "utf8"))).toEqual([PROD_ARGS, ALL_ARGS]); + }); + + it.each(["# npm audit report\n", "unknown audit error\n"])("returns failure and output for %s", (message) => { + const { cli, log } = fixture([{ status: 2, stdout: message, stderr: "detail\n" }]); + const result = invoke(cli); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stdout).toBe(message); + expect(result.stderr).toContain("detail\n"); + expect(JSON.parse(readFileSync(log, "utf8"))).toEqual([PROD_ARGS]); + }); + + it("exits nonzero when the npm CLI cannot be started", () => { + const { cli } = fixture([]); + rmSync(cli); + const result = invoke(cli); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("MODULE_NOT_FOUND"); + }); + + it("terminates a stalled process and fails closed at the attempt timeout", async () => { + const test = harness([], { + spawn: (_command, _args, options) => spawnSync(process.execPath, [ + "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);", + ], { ...options, timeout: 200 }), + }); + expect(await test.run()).toBe(1); + expect(test.wait).not.toHaveBeenCalled(); + expect(test.output().stderr).toContain("ETIMEDOUT"); + expect(test.output().stderr).toContain("SIGKILL"); + }); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 103ae42..28a95f0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2161,16 +2161,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2179,13 +2179,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2206,9 +2206,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2219,13 +2219,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2233,14 +2233,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2249,9 +2249,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2259,13 +2259,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3784,9 +3784,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -4847,9 +4847,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6009,19 +6009,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6049,12 +6049,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/web/package.json b/web/package.json index 6a66d95..f97e956 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "audit": "npm audit --omit=dev --audit-level=moderate && npm audit --audit-level=high", + "audit": "node audit-retry.mjs", "lint": "eslint . --ext .ts,.tsx --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", "test:enrichment": "node enrichment-acceptance.mjs", diff --git a/web/vite.config.ts b/web/vite.config.ts index 7f64a36..f5b8a78 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -25,7 +25,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: ['./src/test-setup.ts'], - include: ['src/**/*.{test,spec}.{ts,tsx}'], + include: ['src/**/*.{test,spec}.{ts,tsx}', 'audit-retry.test.mjs'], coverage: { provider: 'v8', reporter: ['text', 'json', 'lcov'],