diff --git a/apps/extension/package.json b/apps/extension/package.json index 3ebbbda..3f5c422 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -31,7 +31,7 @@ "jsdom": "^26.1.0", "typescript": "^5.8.3", "vite": "^6.3.5", - "vitest": "^3.2.4" + "vitest": "^4.1.9" }, "dependencies": { "@csshub/shared": "*", diff --git a/apps/extension/perf-budgets.json b/apps/extension/perf-budgets.json index 8557293..e2e0352 100644 --- a/apps/extension/perf-budgets.json +++ b/apps/extension/perf-budgets.json @@ -1,7 +1,7 @@ { "entries": { - "background.js": 12, - "contentScript.js": 4, + "background.js": 13, + "contentScript.js": 22, "popup.js": 12, "settings.js": 15 }, @@ -12,5 +12,5 @@ "schemas": 2, "messaging": 2 }, - "totalJsGzipMaxKb": 130 + "totalJsGzipMaxKb": 132 } diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json index 8144184..77f679b 100644 --- a/apps/extension/public/manifest.json +++ b/apps/extension/public/manifest.json @@ -29,6 +29,15 @@ "" ], "content_scripts": [ + { + "matches": [ + "https://cssbattle.dev/play/*", + "https://www.cssbattle.dev/play/*" + ], + "js": ["shortcutBridge.js"], + "run_at": "document_start", + "world": "MAIN" + }, { "matches": [ "https://cssbattle.dev/play/*", diff --git a/apps/extension/src/contentScript.ts b/apps/extension/src/contentScript.ts index 8be1c60..9793b1d 100644 --- a/apps/extension/src/contentScript.ts +++ b/apps/extension/src/contentScript.ts @@ -1,6 +1,7 @@ import { detectChallengeContext } from "./contentScriptChallengeContext"; import { CLICKABLE_SELECTOR, + addCssBattleSubmitShortcutListener, asImageDataUrlOrNull, extractCodeFromCmLines, fetchTargetImagePayload, @@ -31,6 +32,9 @@ import { } from "./contentScriptStats"; const EXTENSION_CONTEXT_INVALIDATED = "Extension context invalidated"; +const CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE = "CSSHUB_SUBMIT_SHORTCUT"; +const CSSHUB_MESSAGE_SOURCE = "csshub-shortcut-bridge"; +const KEYBOARD_SUBMISSION_DEBOUNCE_MS = 750; const isExtensionContextInvalidated = (error: unknown): boolean => error instanceof Error && error.message.includes(EXTENSION_CONTEXT_INVALIDATED); @@ -134,6 +138,7 @@ const capturePreviewImage = async (): Promise => { }; let isProcessingSubmission = false; +let lastKeyboardSubmissionAt = 0; const notifySubmissionProcessingStarted = (): void => { void sendBackgroundAction("submissionProcessingStarted"); @@ -229,6 +234,15 @@ const processSubmission = async (): Promise => { } }; +const processKeyboardSubmission = (): void => { + const now = Date.now(); + if (now - lastKeyboardSubmissionAt < KEYBOARD_SUBMISSION_DEBOUNCE_MS) { + return; + } + lastKeyboardSubmissionAt = now; + void processSubmission(); +}; + const installSubmitListeners = (): void => { // Main ingestion path: capture and submit are automatic on CSSBattle submit clicks. document.addEventListener( @@ -253,6 +267,24 @@ const installSubmitListeners = (): void => { }, true ); + addCssBattleSubmitShortcutListener(window, processKeyboardSubmission); + window.addEventListener( + "message", + (event) => { + if (event.source !== window || event.origin !== window.location.origin) { + return; + } + const data = event.data as { source?: unknown; type?: unknown } | null; + if ( + data?.source !== CSSHUB_MESSAGE_SOURCE || + data.type !== CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE + ) { + return; + } + processKeyboardSubmission(); + }, + true + ); }; chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { diff --git a/apps/extension/src/contentScriptDom.ts b/apps/extension/src/contentScriptDom.ts index 8ddf9e9..f4271e7 100644 --- a/apps/extension/src/contentScriptDom.ts +++ b/apps/extension/src/contentScriptDom.ts @@ -45,6 +45,7 @@ export const isNumericChallengeId = (challengeId: string): boolean => export const SUBMIT_LABEL = /submit/i; export const CLICKABLE_SELECTOR = "button, [role='button'], input[type='submit'], a"; +export const ENTER_SHORTCUT_VALUES = new Set(["Enter", "NumpadEnter"]); export const CM_LINE_SELECTOR = ".cm-line"; export const MONACO_LINE_SELECTOR = ".monaco-editor .view-line"; export const CHALLENGE_ID_PATH_REGEX = /^\/play\/([^/]+)/; @@ -84,6 +85,33 @@ export const getChallengeNameFromTitle = ( export const isSubmitControlText = (text: string): boolean => SUBMIT_LABEL.test(text); +export const isCssBattleSubmitShortcut = ( + event: Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "repeat" | "shiftKey" + > +): boolean => + [ + [event.key, event.code].some((value) => ENTER_SHORTCUT_VALUES.has(value)), + [event.metaKey, event.ctrlKey].some(Boolean), + ![event.altKey, event.shiftKey, event.repeat].some(Boolean), + ].every(Boolean); + +export const addCssBattleSubmitShortcutListener = ( + target: EventTarget, + onSubmit: () => void +): void => { + const handleShortcut = (event: Event): void => { + if (!isCssBattleSubmitShortcut(event as KeyboardEvent)) { + return; + } + onSubmit(); + }; + + target.addEventListener("keydown", handleShortcut, true); + target.addEventListener("keyup", handleShortcut, true); +}; + export const dimensionsFromRect = ( rect: DOMRect, devicePixelRatio = 1 diff --git a/apps/extension/src/contentScriptShortcutBridge.ts b/apps/extension/src/contentScriptShortcutBridge.ts new file mode 100644 index 0000000..a3f818b --- /dev/null +++ b/apps/extension/src/contentScriptShortcutBridge.ts @@ -0,0 +1,28 @@ +const CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE = "CSSHUB_SUBMIT_SHORTCUT"; +const CSSHUB_MESSAGE_SOURCE = "csshub-shortcut-bridge"; +const ENTER_SHORTCUT_VALUES = new Set(["Enter", "NumpadEnter"]); + +const isCssBattleSubmitShortcut = (event: KeyboardEvent): boolean => + [ + [event.key, event.code].some((value) => ENTER_SHORTCUT_VALUES.has(value)), + [event.metaKey, event.ctrlKey].some(Boolean), + ![event.altKey, event.shiftKey, event.repeat].some(Boolean), + ].every(Boolean); + +const postSubmitShortcut = (event: KeyboardEvent): void => { + if (!isCssBattleSubmitShortcut(event)) { + return; + } + + window.postMessage( + { + source: CSSHUB_MESSAGE_SOURCE, + type: CSSHUB_SUBMIT_SHORTCUT_MESSAGE_TYPE, + keyEventType: event.type, + }, + window.location.origin + ); +}; + +window.addEventListener("keydown", postSubmitShortcut, true); +window.addEventListener("keyup", postSubmitShortcut, true); diff --git a/apps/extension/tests/e2e/submission-perf.spec.ts b/apps/extension/tests/e2e/submission-perf.spec.ts index 71c58b2..827fa5f 100644 --- a/apps/extension/tests/e2e/submission-perf.spec.ts +++ b/apps/extension/tests/e2e/submission-perf.spec.ts @@ -184,7 +184,8 @@ test("cssbattleSubmission commits within SLO with mocked GitHub", async () => { matchPct: 99.5, code: "
", targetImage: null, - resultImageDataUrl: null, + resultImageDataUrl: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", }, }); }); diff --git a/apps/extension/tests/unit/contentScript.dom.test.ts b/apps/extension/tests/unit/contentScript.dom.test.ts index ae80b7f..2044169 100644 --- a/apps/extension/tests/unit/contentScript.dom.test.ts +++ b/apps/extension/tests/unit/contentScript.dom.test.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { beforeEach, describe, expect, it } from "vitest"; import { + addCssBattleSubmitShortcutListener, buildCanonicalDailyTargetUrl, CLICKABLE_SELECTOR, asImageDataUrlOrNull, @@ -17,6 +18,7 @@ import { getElementDimensionsFromElement, getTargetImageUrl, isCssBattleHostedTargetUrl, + isCssBattleSubmitShortcut, isFooterDecorativeImage, isSubmitControlText, isTargetImageElement, @@ -70,6 +72,91 @@ describe("isSubmitControlText", () => { }); }); +describe("isCssBattleSubmitShortcut", () => { + it("matches CSSBattle keyboard submit shortcuts", () => { + expect( + isCssBattleSubmitShortcut( + new KeyboardEvent("keydown", { key: "Enter", metaKey: true }) + ) + ).toBe(true); + expect( + isCssBattleSubmitShortcut( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }) + ) + ).toBe(true); + expect( + isCssBattleSubmitShortcut( + new KeyboardEvent("keydown", { code: "NumpadEnter", ctrlKey: true }) + ) + ).toBe(true); + expect(isCssBattleSubmitShortcut(new KeyboardEvent("keydown", { key: "Enter" }))).toBe( + false + ); + expect( + isCssBattleSubmitShortcut( + new KeyboardEvent("keydown", { key: "Enter", shiftKey: true }) + ) + ).toBe(false); + expect( + isCssBattleSubmitShortcut( + new KeyboardEvent("keydown", { key: "s", metaKey: true }) + ) + ).toBe(false); + }); + + it("captures shortcuts before document-level propagation can be stopped", () => { + let shortcutSubmissions = 0; + let documentListenersReached = 0; + + addCssBattleSubmitShortcutListener(window, () => { + shortcutSubmissions += 1; + }); + window.addEventListener( + "keydown", + (event) => { + event.stopImmediatePropagation(); + }, + true + ); + document.addEventListener( + "keydown", + () => { + documentListenersReached += 1; + }, + true + ); + + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + ctrlKey: true, + bubbles: true, + }) + ); + + expect(shortcutSubmissions).toBe(1); + expect(documentListenersReached).toBe(0); + }); + + it("also captures shortcut keyup as a fallback", () => { + let shortcutSubmissions = 0; + + addCssBattleSubmitShortcutListener(window, () => { + shortcutSubmissions += 1; + }); + + document.dispatchEvent( + new KeyboardEvent("keyup", { + key: "Enter", + ctrlKey: true, + bubbles: true, + }) + ); + + expect(shortcutSubmissions).toBe(1); + }); +}); + describe("asImageDataUrlOrNull", () => { it("keeps image data URLs and drops empty canvas placeholders", () => { expect(asImageDataUrlOrNull("data:image/png;base64,abc")).toBe( diff --git a/apps/extension/vite.config.ts b/apps/extension/vite.config.ts index d782c69..17d7a52 100644 --- a/apps/extension/vite.config.ts +++ b/apps/extension/vite.config.ts @@ -111,6 +111,7 @@ const previewFrameCaptureInjectPlugin = (): Plugin => ({ outfile: resolve(__dirname, "dist/previewFrameCaptureInject.js"), bundle: true, format: "iife", + minify: true, platform: "browser", target: "chrome109", logLevel: "silent", @@ -123,16 +124,22 @@ const contentScriptBundlePlugin = (): Plugin => ({ name: "content-script-iife", apply: "build", async closeBundle() { - await esbuild({ - entryPoints: [resolve(__dirname, "src/contentScript.ts")], - outfile: resolve(__dirname, "dist/contentScript.js"), - bundle: true, - format: "iife", - platform: "browser", - target: "chrome109", - alias: extensionSrcAlias, - logLevel: "silent", - }); + for (const [entry, outfile] of [ + ["src/contentScript.ts", "dist/contentScript.js"], + ["src/contentScriptShortcutBridge.ts", "dist/shortcutBridge.js"], + ] as const) { + await esbuild({ + entryPoints: [resolve(__dirname, entry)], + outfile: resolve(__dirname, outfile), + bundle: true, + format: "iife", + minify: true, + platform: "browser", + target: "chrome109", + alias: extensionSrcAlias, + logLevel: "silent", + }); + } }, }); diff --git a/package-lock.json b/package-lock.json index 00f085d..e87003e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "jsdom": "^26.1.0", "typescript": "^5.8.3", "vite": "^6.3.5", - "vitest": "^3.2.4" + "vitest": "^4.1.9" } }, "node_modules/@asamuzakjp/css-color": { @@ -2348,6 +2348,13 @@ "integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2443,6 +2450,7 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, + "license": "MIT", "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -2462,7 +2470,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", @@ -4602,37 +4611,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, + "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -4648,44 +4660,48 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, + "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -4693,26 +4709,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, - "dependencies": { - "tinyspy": "^4.0.3" - }, + "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -4829,6 +4844,7 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" } @@ -5020,15 +5036,6 @@ "node": ">= 0.8" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -5072,17 +5079,11 @@ ] }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, + "license": "MIT", "engines": { "node": ">=18" } @@ -5115,15 +5116,6 @@ "node": ">=8" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.0.tgz", @@ -5393,15 +5385,6 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6863,12 +6846,6 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true - }, "node_modules/lru-cache": { "version": "11.3.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", @@ -6892,6 +6869,7 @@ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -7158,6 +7136,20 @@ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", "dev": true }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7403,16 +7395,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, - "engines": { - "node": ">= 14.16" - } + "license": "MIT" }, "node_modules/pend": { "version": "1.2.0", @@ -8118,10 +8102,11 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" }, "node_modules/stream-to-array": { "version": "2.3.0", @@ -8219,24 +8204,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true - }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -8369,29 +8336,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -9720,116 +9670,80 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/vite-node/node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true - }, - "node_modules/vite-node/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, + "license": "MIT", "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -9840,32 +9754,29 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/vitest/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, + "license": "MIT" + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18" } }, - "node_modules/vitest/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -10178,7 +10089,7 @@ }, "devDependencies": { "typescript": "^5.9.3", - "vitest": "^3.2.4" + "vitest": "^4.1.9" } } } diff --git a/package.json b/package.json index 9d22d04..5c94b2c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:staging": "npm run build:extension:staging", "build:preview": "npm run build:extension:preview", "build:dev": "npm run build:extension:dev", - "test": "npm run test --workspaces --if-present", + "test": "node --test scripts/test-security.test.mjs && npm run test --workspaces --if-present", "test:e2e": "npm run test:e2e --workspaces --if-present", "lint": "eslint \"apps/**/*.{ts,tsx}\" \"packages/**/*.ts\" \"scripts/**/*.mjs\"", "lint:fix": "npm run lint -- --fix", diff --git a/packages/shared/package.json b/packages/shared/package.json index 3e6fece..f0a365b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -20,6 +20,6 @@ }, "devDependencies": { "typescript": "^5.9.3", - "vitest": "^3.2.4" + "vitest": "^4.1.9" } } diff --git a/scripts/test-security.mjs b/scripts/test-security.mjs index b68a6e3..b3e609c 100644 --- a/scripts/test-security.mjs +++ b/scripts/test-security.mjs @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; const SECRET_PATTERNS = [ "ghp_[A-Za-z0-9]{36}", @@ -9,16 +10,21 @@ const SECRET_PATTERNS = [ "xox[baprs]-[0-9A-Za-z-]{10,}", ]; -const run = (command, args) => { +const NPM_SPAWN_OPTIONS = process.platform === "win32" ? { shell: true } : {}; + +export const normalizeRunResult = (result) => ({ + status: result.status ?? 1, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", +}); + +const run = (command, args, options = {}) => { const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + ...options, }); - return { - status: result.status ?? 1, - stdout: result.stdout ?? "", - stderr: result.stderr ?? "", - }; + return normalizeRunResult(result); }; const isStrictMode = process.argv.includes("--strict"); @@ -35,7 +41,7 @@ const log = (message) => { const runAudit = () => { log("== Dependency audit =="); - const result = run("npm", ["audit", "--workspaces", "--json"]); + const result = run("npm", ["audit", "--workspaces", "--json"], NPM_SPAWN_OPTIONS); const raw = result.stdout.trim(); let parsed = null; @@ -159,4 +165,6 @@ const main = () => { log("\nSecurity checks passed."); }; -main(); +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/test-security.test.mjs b/scripts/test-security.test.mjs new file mode 100644 index 0000000..55bf7cc --- /dev/null +++ b/scripts/test-security.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { normalizeRunResult } from "./test-security.mjs"; + +describe("normalizeRunResult", () => { + it("coerces nullish spawn output streams to strings", () => { + const result = normalizeRunResult({ + status: null, + stdout: null, + stderr: null, + }); + + assert.deepEqual(result, { + status: 1, + stdout: "", + stderr: "", + }); + }); +});