Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 3 additions & 24 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
33 changes: 33 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions extensions/levelcode-ai/test/authRetryCallers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions scripts/test-extensions.sh
Original file line number Diff line number Diff line change
@@ -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 ────────"
Loading