diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7d6dd9..0e9d75f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,30 +40,9 @@ jobs: with: node-version: "24" - name: Extension unit tests - # `shopt` is a bash builtin, so pin the shell instead of relying on the runner default (bash on - # Linux/macOS, but pwsh on Windows — where this step would break if the job were ever copied). - # Explicit `shell: bash` also upgrades the default `bash -e` to - # `bash --noprofile --norc -eo pipefail`, so a stray profile file can't perturb the gate either. - shell: bash - # DISCOVERS suites — it used to `cd extensions/levelcode-ai`, so levelcode-updater's tests never - # ran here, including the one guarding the updater's Download button against serving a raw - # .app.zip. Globbing every extension means a new suite is gated the moment it is added, with no - # list here to keep in sync. Requires are file-relative, so running from the repo root is fine. - run: | - shopt -s nullglob - count=0 - for t in extensions/*/test/*.test.js; do - echo "── $t" - node "$t" # `-e` (from `shell: bash` above) aborts the job on the first failure - count=$((count + 1)) - done - # A zero-match glob would otherwise report success and gate nothing — the exact failure this - # step is fixing. Fail loudly instead. - if [ "$count" -eq 0 ]; then - echo "::error::No suites matched extensions/*/test/*.test.js — the gate would pass vacuously." - exit 1 - fi - echo "──────── $count test files passed ────────" + # The gate itself lives in a script, so the pull-request workflow (test.yml) runs exactly what + # this does. It discovers every extensions/*/test/*.test.js and fails if it finds none. + run: ./scripts/test-extensions.sh build: name: Build ${{ matrix.arch }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8dd2e02 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Extension tests + +# The release gate (scripts/test-extensions.sh), on every pull request and on develop. +# +# release.yml runs the same script, but only when a tag is pushed — which made a tag the first time +# the suites ran on Linux. v1.3.0's gate failed there on a suite that had only ever run on a Mac, +# after the pull requests that wrote it had merged. Here a suite that passes on one machine and not +# on the runner is found on the pull request that adds it. + +on: + pull_request: + push: + branches: [develop] + +permissions: + contents: read + +# A newer push to the same branch or pull request supersedes the run before it. +concurrency: + group: extension-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Extension unit tests + runs-on: ubuntu-latest # the runner the release gate uses + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" # as release.yml + - name: Extension unit tests + run: ./scripts/test-extensions.sh diff --git a/extensions/levelcode-ai/test/authRetryCallers.test.js b/extensions/levelcode-ai/test/authRetryCallers.test.js index 843a769..f41ccef 100644 --- a/extensions/levelcode-ai/test/authRetryCallers.test.js +++ b/extensions/levelcode-ai/test/authRetryCallers.test.js @@ -210,11 +210,23 @@ const { registerInlineComplete } = require('../inlineComplete'); // ── the network: the only stand-in ─────────────────────────────────────────────────────────────── const tick = () => new Promise((resolve) => setImmediate(resolve)); +/** How long anything here is given to happen. Reached only by what never does. */ +const PATIENCE_MS = 2000; +/** + * Wait for `cond` — by the CLOCK, not by counting turns of the event loop. Inline completion waits + * out its debounce on a real timer, a millisecond even when the setting is 0, and how many turns fit + * into a millisecond is the machine's business: a few dozen on a Mac, well over a thousand on + * Linux, where the release gate runs. A count that was plenty on one ran out on the other before + * the timer had fired. + */ async function until(cond, what) { - for (let i = 0; i < 400; i++) { if (cond()) { return; } await tick(); } - assert.fail('never happened: ' + what); + const deadline = Date.now() + PATIENCE_MS; + while (!cond()) { + if (Date.now() > deadline) { assert.fail('never happened: ' + what); } + await tick(); + } } -/** Let everything already in motion get as far as it can. */ +/** Let everything already in motion get as far as it can. Turns, not time — so only for what no timer stands in the way of. */ async function settle() { for (let i = 0; i < 40; i++) { await tick(); } } function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; } function within(promise, ms, what) { diff --git a/scripts/test-extensions.sh b/scripts/test-extensions.sh new file mode 100755 index 0000000..564da45 --- /dev/null +++ b/scripts/test-extensions.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# +# Run every extension's unit suites. This is the gate: the release workflow runs it before spending +# an hour of macOS build minutes, and every pull request runs it too (.github/workflows/test.yml). +# +# It DISCOVERS suites — extensions/*/test/*.test.js — so a new one is gated the moment it is added, +# with no list to keep in sync. (The gate used to `cd extensions/levelcode-ai`; levelcode-updater's +# suites never ran, including the one guarding the Download button against serving a raw .app.zip.) +# Requires are file-relative, so the suites run from the repo root. +# +# Usage: +# ./scripts/test-extensions.sh +# +set -euo pipefail +shopt -s nullglob + +cd "$(dirname "$0")/.." + +count=0 +for t in extensions/*/test/*.test.js; do + echo "── $t" + node "$t" # `set -e` stops at the first suite that fails + count=$((count + 1)) +done +# A glob that matches nothing would otherwise report success and gate nothing. Fail loudly instead. +if [ "$count" -eq 0 ]; then + echo "::error::No suites matched extensions/*/test/*.test.js — the gate would pass vacuously." + exit 1 +fi +echo "──────── $count test files passed ────────"