diff --git a/.github/workflows/group-apps-packages-ci.yml b/.github/workflows/group-apps-packages-ci.yml index 715c6f2..1bd6db8 100644 --- a/.github/workflows/group-apps-packages-ci.yml +++ b/.github/workflows/group-apps-packages-ci.yml @@ -53,7 +53,7 @@ jobs: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/project-liminal-drift-ci.yml b/.github/workflows/project-liminal-drift-ci.yml index f326e83..ae11b68 100644 --- a/.github/workflows/project-liminal-drift-ci.yml +++ b/.github/workflows/project-liminal-drift-ci.yml @@ -49,7 +49,7 @@ jobs: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: pnpm @@ -131,7 +131,7 @@ jobs: - name: Upload browser artifacts if: ${{ always() && steps.browser-smoke.outcome != 'skipped' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: liminal-drift-browser-smoke-${{ github.run_attempt }} path: apps/liminal-drift/artifacts/ diff --git a/.github/workflows/project-shika-ci.yml b/.github/workflows/project-shika-ci.yml index 133ddda..68b0355 100644 --- a/.github/workflows/project-shika-ci.yml +++ b/.github/workflows/project-shika-ci.yml @@ -49,7 +49,7 @@ jobs: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22.22.2 cache: pnpm diff --git a/.github/workflows/project-youtube-auto-resume-ci.yml b/.github/workflows/project-youtube-auto-resume-ci.yml index b0a2ba0..b0dcb42 100644 --- a/.github/workflows/project-youtube-auto-resume-ci.yml +++ b/.github/workflows/project-youtube-auto-resume-ci.yml @@ -5,14 +5,24 @@ on: pull_request: paths: - "others/userscripts/youtube-auto-resume/**" + - "package.json" + - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "patches/**" + - "scripts/auditDependencies*.mjs" + - "scripts/checkBracesSecurity.cjs" - ".github/workflows/project-youtube-auto-resume-ci.yml" push: branches: - main paths: - "others/userscripts/youtube-auto-resume/**" + - "package.json" + - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "patches/**" + - "scripts/auditDependencies*.mjs" + - "scripts/checkBracesSecurity.cjs" - ".github/workflows/project-youtube-auto-resume-ci.yml" permissions: @@ -40,14 +50,14 @@ jobs: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm --filter @cedarflake/youtube-auto-resume --fail-if-no-match install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Check userscript version if: github.event_name == 'pull_request' @@ -67,6 +77,8 @@ jobs: - name: Check YouTube userscript run: pnpm --filter @cedarflake/youtube-auto-resume --fail-if-no-match check - - name: Audit YouTube userscript dependencies + - name: Audit workspace dependencies if: ${{ !cancelled() }} - run: pnpm --config.registry=https://registry.npmjs.org/ --filter @cedarflake/youtube-auto-resume --fail-if-no-match audit --audit-level high + run: | + pnpm test:dependency-audit + pnpm audit:dependencies diff --git a/.github/workflows/project-youtube-auto-resume-extension-ci.yml b/.github/workflows/project-youtube-auto-resume-extension-ci.yml index 3c11c8f..34b0194 100644 --- a/.github/workflows/project-youtube-auto-resume-extension-ci.yml +++ b/.github/workflows/project-youtube-auto-resume-extension-ci.yml @@ -5,14 +5,24 @@ on: pull_request: paths: - "apps/youtube-auto-resume-extension/**" + - "package.json" + - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "patches/**" + - "scripts/auditDependencies*.mjs" + - "scripts/checkBracesSecurity.cjs" - ".github/workflows/project-youtube-auto-resume-extension-ci.yml" push: branches: - main paths: - "apps/youtube-auto-resume-extension/**" + - "package.json" + - "pnpm-lock.yaml" - "pnpm-workspace.yaml" + - "patches/**" + - "scripts/auditDependencies*.mjs" + - "scripts/checkBracesSecurity.cjs" - ".github/workflows/project-youtube-auto-resume-extension-ci.yml" permissions: @@ -39,14 +49,14 @@ jobs: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22 cache: pnpm cache-dependency-path: pnpm-lock.yaml - name: Install dependencies - run: pnpm --filter @cedarflake/youtube-auto-resume-extension --fail-if-no-match install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Check extension run: pnpm --filter @cedarflake/youtube-auto-resume-extension --fail-if-no-match check @@ -60,6 +70,8 @@ jobs: - name: Test extension runtime run: pnpm --filter @cedarflake/youtube-auto-resume-extension --fail-if-no-match test:e2e - - name: Audit extension dependencies + - name: Audit workspace dependencies if: ${{ !cancelled() }} - run: pnpm --config.registry=https://registry.npmjs.org/ --filter @cedarflake/youtube-auto-resume-extension --fail-if-no-match audit --audit-level high + run: | + pnpm test:dependency-audit + pnpm audit:dependencies diff --git a/.github/workflows/repo-codeql-security.yml b/.github/workflows/repo-codeql-security.yml index ad270f4..ecae3ed 100644 --- a/.github/workflows/repo-codeql-security.yml +++ b/.github/workflows/repo-codeql-security.yml @@ -48,12 +48,12 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: build-mode: none languages: javascript-typescript - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/repo-repository-contract-ci.yml b/.github/workflows/repo-repository-contract-ci.yml index f0a0bd4..f8af5ce 100644 --- a/.github/workflows/repo-repository-contract-ci.yml +++ b/.github/workflows/repo-repository-contract-ci.yml @@ -31,7 +31,7 @@ jobs: version: 11.7.0 - name: Setup Node - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 22.22.2 cache: pnpm diff --git a/apps/liminal-drift/README.md b/apps/liminal-drift/README.md index 9d3a50f..bc710d0 100644 --- a/apps/liminal-drift/README.md +++ b/apps/liminal-drift/README.md @@ -44,7 +44,7 @@ pnpm check:rules ## Verification -- `pnpm check` runs formatting checks, lint, procedural generation checks, game-rule checks, license policy checks, production build, and bundle budget checks. +- `pnpm check` runs formatting checks, lint, procedural generation checks, game-rule checks, browser-helper regressions, license policy checks, production build, and bundle budget checks. - `pnpm check:bundle` verifies the built JS/CSS assets stay within raw and gzip size budgets. - `pnpm check:canvas -- ` captures desktop and mobile screenshots, checks the 3D scene is visible and moving, verifies modal focus / telemetry / progress semantics, and covers blocked local storage, invalid best-score storage, reduced-motion CSS, and repeated Escape input. - `pnpm check:interaction -- ` verifies keyboard driving, drifting, reverse darkness, single-click restart at the road end, interference reset, pause/resume, background freezing with interrupted menu transitions, gamepad input, and the mobile desktop-required fallback. diff --git a/apps/liminal-drift/package.json b/apps/liminal-drift/package.json index d5fcf4d..16cc582 100644 --- a/apps/liminal-drift/package.json +++ b/apps/liminal-drift/package.json @@ -14,7 +14,8 @@ "preview": "vite preview", "format": "prettier . --write", "format:check": "prettier . --check", - "check": "pnpm run format:check && pnpm lint && pnpm run check:generation && pnpm run check:rules && pnpm run check:licenses && pnpm build && pnpm run check:bundle", + "check": "pnpm run format:check && pnpm lint && pnpm run check:generation && pnpm run check:rules && pnpm run check:browser-helpers && pnpm run check:licenses && pnpm build && pnpm run check:bundle", + "check:browser-helpers": "node --test scripts/browserDrivingChecks.test.mjs", "check:browser": "pnpm run check:canvas -- http://127.0.0.1:4173/ && pnpm run check:interaction -- http://127.0.0.1:4173/", "check:bundle": "node scripts/checkBundleBudget.mjs", "check:canvas": "node scripts/checkCanvas.mjs", diff --git a/apps/liminal-drift/scripts/browserDrivingChecks.mjs b/apps/liminal-drift/scripts/browserDrivingChecks.mjs new file mode 100644 index 0000000..5901bb8 --- /dev/null +++ b/apps/liminal-drift/scripts/browserDrivingChecks.mjs @@ -0,0 +1,24 @@ +/** @param {Element} element */ +export function observeStoppedSpeed(element) { + const initialText = element.textContent?.trim() + const initialSpeed = Number(initialText) + if (!initialText || !Number.isFinite(initialSpeed) || initialSpeed < 1) { + throw new Error(`Expected a moving car before observing its stop, got "${initialText}"`) + } + const observation = { + hasStopped: false, + disconnect: () => observer.disconnect(), + } + const observe = () => { + const text = element.textContent?.trim() + const speed = Number(text) + if (text && Number.isFinite(speed) && speed >= 0 && speed < 1) { + observation.hasStopped = true + observer.disconnect() + } + } + const observer = new MutationObserver(observe) + observer.observe(element, { childList: true, characterData: true, subtree: true }) + observe() + return observation +} diff --git a/apps/liminal-drift/scripts/browserDrivingChecks.test.mjs b/apps/liminal-drift/scripts/browserDrivingChecks.test.mjs new file mode 100644 index 0000000..ed1863a --- /dev/null +++ b/apps/liminal-drift/scripts/browserDrivingChecks.test.mjs @@ -0,0 +1,72 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { runInNewContext } from "node:vm" + +import { observeStoppedSpeed } from "./browserDrivingChecks.mjs" + +/** @param {string | null} initialSpeed */ +function monitor(initialSpeed) { + const element = { textContent: initialSpeed } + /** @type {(() => void) | undefined} */ + let callback + let isConnected = false + const observation = runInNewContext(`(${observeStoppedSpeed.toString()})(element)`, { + element, + MutationObserver: class { + /** @param {() => void} onMutation */ + constructor(onMutation) { + callback = onMutation + } + observe() { + isConnected = true + } + disconnect() { + isConnected = false + } + }, + }) + return { + observation, + get isConnected() { + return isConnected + }, + /** @param {string | null} speed */ + update(speed) { + element.textContent = speed + if (isConnected) callback?.() + }, + } +} + +test("retains a real stop when delayed polling sees reverse speed", () => { + const state = monitor("42") + assert.equal(state.observation.hasStopped, false) + state.update("0") + state.update("12") + assert.equal(state.observation.hasStopped, true) + assert.equal(state.isConnected, false) +}) + +test("does not report a stop without a valid stopped-speed sample", () => { + const state = monitor("42") + for (const speed of ["", " ", null, "NaN", "Infinity", "-1", "1", "12"]) { + state.update(speed) + assert.equal(state.observation.hasStopped, false) + } + state.update("0") + assert.equal(state.observation.hasStopped, true) +}) + +test("requires a moving car before observing the stop", () => { + for (const speed of ["0", "", " ", null, "NaN", "Infinity", "-1"]) { + assert.throws(() => monitor(speed), /Expected a moving car/) + } +}) + +test("disconnects when a browser check fails before stopping", () => { + const state = monitor("42") + state.observation.disconnect() + state.update("0") + assert.equal(state.observation.hasStopped, false) + assert.equal(state.isConnected, false) +}) diff --git a/apps/liminal-drift/scripts/checkCanvas.mjs b/apps/liminal-drift/scripts/checkCanvas.mjs index f433863..89db171 100644 --- a/apps/liminal-drift/scripts/checkCanvas.mjs +++ b/apps/liminal-drift/scripts/checkCanvas.mjs @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url" import { chromium, devices } from "playwright" +import { observeStoppedSpeed } from "./browserDrivingChecks.mjs" import { browserLaunchOptions } from "./browserLaunchOptions.mjs" import { measurePeripheralDifference, @@ -549,7 +550,6 @@ try { await page.waitForTimeout(2600) await page.keyboard.up("w") - await page.keyboard.down("s") const telemetryLines = (await page.locator(".hud").innerText()) .split(/\r?\n/) @@ -587,13 +587,31 @@ try { ) } - // Stop before encoding several screenshots: real bends now require steering, - // and a slow screenshot must not leave the throttle held into the first bend. + // Latch the brief stop before holding the brake: slow protocol round trips + // must not miss it and wait forever after the brake has engaged reverse. + const stoppedSpeed = await page + .locator(".hud__metric--speed strong") + .evaluateHandle(observeStoppedSpeed) + try { + await page.keyboard.down("s") + await page.waitForFunction((observation) => observation.hasStopped, stoppedSpeed) + } finally { + try { + await page.keyboard.up("s") + } finally { + try { + await stoppedSpeed.evaluate((observation) => observation.disconnect()) + } finally { + await stoppedSpeed.dispose() + } + } + } + // A delayed key release can briefly engage reverse. Verify the current car + // has coasted to rest as well, before encoding screenshots with no held keys. await page.waitForFunction(() => { const text = document.querySelector(".hud__metric--speed strong")?.textContent return Boolean(text) && Number(text) < 1 }) - await page.keyboard.up("s") await page.screenshot({ path: join(outputPath, `${viewport.name}.png`),