feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)

Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(new URL('..', import.meta.url)))
const offline = process.argv.includes('--offline')
const SKIP_DIRS = new Set(['.git', 'node_modules', '.superpowers', 'bin', 'obj', '.scala-build', '.bsp', 'dist', 'fable_modules'])
const SKIP_DIRS = new Set(['.git', 'node_modules', '.superpowers', 'bin', 'obj', '.scala-build', '.bsp', 'dist', 'fable_modules', 'test-results', 'playwright-report'])
const SKIP_PATHS = [join('docs', 'superpowers')]
const TIMEOUT_MS = 20000
const CONCURRENCY = 6
+42 -12
View File
@@ -3,7 +3,7 @@
# Progression checkpoint: for each module N (that has tests in the track), build a temp copy where src/ holds the
# reference solutions of modules <= N and the untouched student stubs for the rest, then run the tests of modules
# M01..MN. All must pass: this proves a student who has finished module N has a green suite up to N, and that
# no earlier solution secretly depends on a later one.
# no earlier solution secretly depends on a later one. The optional module 07b sits between 07 and 08.
set -uo pipefail
track="${1:?usage: check-progression.sh <js|scala|fsharp>}"
repo="$(cd "$(dirname "$0")/.." && pwd)"
@@ -15,36 +15,45 @@ files_for() {
js:01) echo "util.js todo.js" ;;
js:02) echo "model.js" ;;
js:03) echo "rules.js" ;;
js:04) echo "random.js spawn.js" ;;
js:04) echo "random.js spawn.js state.js" ;;
js:05) echo "game.js" ;;
js:06) echo "view.js" ;;
js:07) echo "io.js" ;;
js:07b) echo "safeio.js" ;; # optional module 7b
js:08) echo "stream.js" ;;
js:09) echo "input.js app.js runtime.js browser.js main.js" ;;
js:10) echo "" ;; # edits game/input/app, already included by m05/m09
js:10) echo "replay.js" ;; # also edits game/input/stream/app/io/runtime/browser, already included by m05-m09
js:11) echo "pong" ;;
js:12) echo "check.js" ;;
js:13) echo "life" ;;
scala:01) echo "Util.scala" ;;
scala:02) echo "Model.scala" ;;
scala:03) echo "Rules.scala" ;;
scala:04) echo "Random.scala Spawn.scala" ;;
scala:04) echo "Random.scala Spawn.scala State.scala" ;;
scala:05) echo "Game.scala" ;;
scala:06) echo "View.scala" ;;
scala:07) echo "IO.scala" ;;
scala:07b) echo "SafeIO.scala" ;;
scala:08) echo "Stream.scala" ;;
scala:09) echo "Input.scala App.scala Runtime.scala" ;;
scala:10) echo "" ;;
scala:10) echo "Replay.scala" ;;
scala:11) echo "pong" ;;
scala:12) echo "Check.scala" ;;
scala:13) echo "life" ;;
fsharp:01) echo "Util.fs" ;; # Snake.fsproj lists every file from the start and is identical in both trees
fsharp:02) echo "Model.fs" ;;
fsharp:03) echo "Rules.fs" ;;
fsharp:04) echo "Random.fs Spawn.fs" ;;
fsharp:04) echo "Random.fs Spawn.fs State.fs" ;;
fsharp:05) echo "Game.fs" ;;
fsharp:06) echo "View.fs" ;;
fsharp:07) echo "IO.fs" ;;
fsharp:07b) echo "SafeIO.fs" ;;
fsharp:08) echo "Stream.fs" ;;
fsharp:09) echo "Input.fs App.fs Runtime.fs" ;;
fsharp:10) echo "" ;;
fsharp:10) echo "Replay.fs" ;;
fsharp:11) echo "Pong" ;;
fsharp:12) echo "Check.fs" ;;
fsharp:13) echo "Life" ;;
*) echo "" ;;
esac
}
@@ -56,23 +65,44 @@ case "$track" in
*) echo "unknown track: $track" >&2; exit 2 ;;
esac
# Modules in course order. "07b" (optional) comes after 07: order is position in this list, not numeric value.
modules="01 02 03 04 05 06 07 07b 08 09 10 11 12 13"
# Scala's `./test.sh M07` (munit glob "*M07*") and F#'s (Expecto --filter-test-list, a substring) also select the
# M07b tests, unlike JS's "m07-*" glob. Before module 07b is reached its tests must not run, so delete them from
# the temp copy (and, for F#, their line in tests/Snake.Tests.fsproj; Expecto finds test lists by attribute).
drop_tests() {
case "$track" in
scala) rm -f "$tmp"/scala/test/M"$1"*.test.scala ;;
fsharp) for f in "$tmp"/fsharp/tests/M"$1"*.fs; do
[ -e "$f" ] || continue
rm -f "$f"; sed -i "/Include=\"$(basename "$f")\"/d" "$tmp/fsharp/tests/Snake.Tests.fsproj"
done ;;
esac
}
status=0
for n in 01 02 03 04 05 06 07 08 09 10 11; do
for n in $modules; do
has "$n" || continue
tmp="$(mktemp -d)"
tar -C "$repo" --exclude=.git --exclude=node_modules --exclude=bin --exclude=obj \
--exclude=.scala-build --exclude=.bsp --exclude=.superpowers -cf - . | tar -C "$tmp" -xf -
for m in 01 02 03 04 05 06 07 08 09 10 11; do
[ "$m" -le "$n" ] || break
reached=""; past=0
for m in $modules; do
if [ $past -eq 1 ]; then # a later module: drop its tests if a reached module's tag is a prefix of its tag
for r in $reached; do [[ "$m" == "$r"?* ]] && drop_tests "$m"; done
continue
fi
for f in $(files_for "$m"); do
[ -e "$tmp/$track/solutions/$f" ] || continue # module not (yet) present in this track
rm -rf "$tmp/$track/src/$f"
cp -r "$tmp/$track/solutions/$f" "$tmp/$track/src/$f"
done
reached="$reached $m"
[ "$m" = "$n" ] && past=1
done
ok=1
for m in 01 02 03 04 05 06 07 08 09 10 11; do
[ "$m" -le "$n" ] || break
for m in $reached; do
has "$m" || continue
if ! out="$(cd "$tmp/$track" && ./test.sh "M$m" 2>&1)"; then
echo "checkpoint M$n: FAIL (M$m tests fail with solutions up to M$n)"
+31 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Usage: scripts/check-stubs.sh <js|scala|fsharp>
# Runs each module M01..M11 that has tests against the stubs in src/. Fails if any module's tests pass,
# Runs each module M01..M13 (and the optional M07b) that has tests against the stubs in src/. Fails if any module's tests pass,
# and also fails (exit 2) on tooling/build errors, so a broken toolchain can never look like "stubs fail".
set -uo pipefail
track="${1:?usage: check-stubs.sh <js|scala|fsharp>}"
@@ -32,11 +32,39 @@ tests_failed() {
esac
}
# Modules in course order; "07b" is the optional Module 7b.
modules="01 02 03 04 05 06 07 07b 08 09 10 11 12 13"
# Scala's `./test.sh M07` (munit glob "*M07*") and F#'s (Expecto --filter-test-list, a substring) also select the
# M07b tests. Their failures would hide an M07 that wrongly passes, so for such a module run the tests in a temp
# copy without the longer-tagged modules' test files (F#: also without their line in tests/Snake.Tests.fsproj).
run_module() {
local n="$1" longer="" t f tmp
if [ "$track" != js ]; then
for t in $modules; do [[ "$t" == "$n"?* ]] && has "$t" && longer="$longer $t"; done
fi
if [ -z "$longer" ]; then (cd "$dir" && ./test.sh "M$n" 2>&1); return; fi
tmp="$(mktemp -d)"
tar -C "$repo" --exclude=.git --exclude=node_modules --exclude=bin --exclude=obj \
--exclude=.scala-build --exclude=.bsp --exclude=.superpowers -cf - . | tar -C "$tmp" -xf -
for t in $longer; do
case "$track" in
scala) rm -f "$tmp"/scala/test/M"$t"*.test.scala ;;
fsharp) for f in "$tmp"/fsharp/tests/M"$t"*.fs; do
rm -f "$f"; sed -i "/Include=\"$(basename "$f")\"/d" "$tmp/fsharp/tests/Snake.Tests.fsproj"
done ;;
esac
done
(cd "$tmp/$track" && ./test.sh "M$n" 2>&1); local rc=$?
rm -rf "$tmp"
return $rc
}
status=0
for n in 01 02 03 04 05 06 07 08 09 10 11; do
for n in $modules; do
m="M$n"
if ! has "$n"; then echo "$m: no tests"; continue; fi
out="$(cd "$dir" && ./test.sh "$m" 2>&1)"; rc=$?
out="$(run_module "$n")"; rc=$?
out="$(sed $'s/\x1b\\[[0-9;?]*[A-Za-z]//g' <<<"$out")"
if [ $rc -eq 0 ]; then
echo "$m: FAIL (tests pass against stubs)"; status=1
Executable
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Usage: scripts/e2e.sh [--tracks js,scala,fsharp] [--base-url <url>] [-- <playwright args>]
# End-to-end tests (e2e/, Playwright) of every track's four pages against the pure JS model. Copies the repo to a temp
# dir, replaces each selected track's src/ with its solutions/, runs build-web.sh for Scala and F#, serves the copy with
# scripts/serve.mjs on a free port, runs the tests against it and cleans up. The exit code is Playwright's.
# --tracks the tracks to test (default: all three)
# --base-url test an already served copy instead (no copy, no build); its <track>/src must hold the code to test
# -- ... passed to `playwright test`, e.g. -- --project=chromium, -- -g "snake", -- --repeat-each=3
# The first run installs @playwright/test into e2e/node_modules (npm, needs the network once). Browsers are never
# downloaded: a missing one is skipped with a message (see e2e/playwright.config.mjs).
set -euo pipefail
repo="$(cd "$(dirname "$0")/.." && pwd)"
tracks="js,scala,fsharp"
base_url=""
pw_args=()
while [ $# -gt 0 ]; do
case "$1" in
--tracks) tracks="${2:?--tracks needs a value}"; shift 2 ;;
--base-url) base_url="${2:?--base-url needs a value}"; shift 2 ;;
--) shift; pw_args=("$@"); break ;;
*) echo "usage: scripts/e2e.sh [--tracks js,scala,fsharp] [--base-url <url>] [-- <playwright args>]" >&2; exit 2 ;;
esac
done
IFS=',' read -r -a selected <<< "$tracks"
for t in "${selected[@]}"; do
case "$t" in js|scala|fsharp) ;; *) echo "unknown track: $t" >&2; exit 2 ;; esac
done
if [ ! -d "$repo/e2e/node_modules/@playwright/test" ]; then
echo "e2e: installing @playwright/test into e2e/node_modules" >&2
(cd "$repo/e2e" && npm ci --no-audit --no-fund) || { echo "e2e: npm ci failed (no network?)" >&2; exit 2; }
fi
tmp=""
server=""
cleanup() {
if [ -n "$server" ]; then kill "$server" 2>/dev/null || true; fi
if [ -n "$tmp" ]; then rm -rf "$tmp"; fi
}
trap cleanup EXIT
if [ -z "$base_url" ]; then
tmp="$(mktemp -d)"
# The copy leaves out builds lying around in the tree (*/web/dist), so a page is only testable once built here.
tar -C "$repo" --exclude=.git --exclude=node_modules --exclude=bin --exclude=obj --exclude=.scala-build \
--exclude=.bsp --exclude=.superpowers --exclude=./e2e --exclude='./*/web/dist' -cf - . | tar -C "$tmp" -xf -
for t in "${selected[@]}"; do
rm -rf "$tmp/$t/src"
cp -r "$tmp/$t/solutions" "$tmp/$t/src"
if [ "$t" != js ]; then
echo "e2e: building the $t pages (build-web.sh)" >&2
(cd "$tmp/$t" && ./build-web.sh > "$tmp/build-$t.log" 2>&1) || { cat "$tmp/build-$t.log" >&2; echo "e2e: $t build failed" >&2; exit 2; }
fi
done
port="$(node -e "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})")"
node "$tmp/scripts/serve.mjs" "$port" > "$tmp/serve.log" 2>&1 &
server=$!
base_url="http://127.0.0.1:$port"
for _ in $(seq 1 50); do
if node -e "fetch('$base_url/js/index.html').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"; then break; fi
sleep 0.1
done
fi
echo "e2e: testing $tracks at $base_url" >&2
set +e
(cd "$repo/e2e" && E2E_BASE_URL="$base_url" E2E_TRACKS="$tracks" npx --no-install playwright test ${pw_args[@]+"${pw_args[@]}"})
status=$?
set -e
exit $status
+18
View File
@@ -0,0 +1,18 @@
# Known equivalent mutants for scripts/mutate.mjs: mutants that survive because they cannot change behaviour on any
# state the program can reach, not because a test is missing. One per line:
# <file>: <original line> => <mutated line> # <why it is equivalent>
# Copy the "allow-list line" the script prints for a survivor and replace <reason>. Only add a line after you have
# convinced yourself the mutant is equivalent; otherwise it is a test gap, and the fix is a test.
pong/rules.js: (nx === PLAYER_X && vel.dx < 0 && hits(player, ny)) || => (nx === PLAYER_X && vel.dx <= 0 && hits(player, ny)) || # vel.dx is always -1 or 1 (initialPong sets 1, a bounce negates it, serve sets -1 or 1), so < 0 and <= 0 agree
pong/rules.js: (nx === PLAYER_X && vel.dx < 0 && hits(player, ny)) || => (nx === PLAYER_X && vel.dx < 1 && hits(player, ny)) || # vel.dx is always -1 or 1, so < 0 and < 1 agree
pong/rules.js: (nx === CPU_X && vel.dx > 0 && hits(cpu, ny)) => (nx === CPU_X && vel.dx >= 0 && hits(cpu, ny)) # vel.dx is always -1 or 1, so > 0 and >= 0 agree
pong/view.js: text(320, 200, state.score.player > state.score.cpu ? 'You Win!' : 'CPU Wins', 32), => text(320, 200, state.score.player >= state.score.cpu ? 'You Win!' : 'CPU Wins', 32), # only shown when status is Over; a match ends the moment one side reaches WIN and only one point is scored per frame, so the scores are never equal there
check.js: .map((s, i) => (i > 0 && !sameHead(s.snake, states[i - 1].snake) ? i : -1)) => .map((s, i) => (i > 0 && !sameHead(s.snake, states[i - 1].snake) ? i : -2)) # -1 is only a "no move here" marker that the next line filters out with i >= 0; -2 is filtered out the same way
check.js: .filter((i) => i >= 0) => .filter((i) => i > 0) # the indices kept come from i > 0 on the line above, so 0 never occurs and >= 0 and > 0 agree
check.js: .filter((i) => i >= 0) => .filter((i) => i >= 1) # the indices kept come from i > 0 on the line above (integers >= 1, or the marker -1), so >= 0 and >= 1 agree
safeio.js: return isDone(r.state) ? [r.state, toArray(outs)] : go(r.state, budget * 2, outs) => return isDone(r.state) ? [r.state, toArray(outs)] : go(r.state, budget * 3, outs) # the budget only decides how many steps run per chunk; the chunks' outputs are joined in order, so any growth factor >= 2 gives the same result (it only changes the stack depth bound)
safeio.js: return go(start, 1, leaf([])) => return go(start, 2, leaf([])) # the first budget only decides the chunk sizes; the outputs are joined in order and runFor stops at isDone, so the result is the same
life/rules.js: const neighbours = (p) => offsets.map(([dx, dy]) => ({ x: p.x + dx, y: p.y + dy })).filter(onBoard) => const neighbours = (p) => offsets.map(([dx, dy]) => ({ x: p.x - dx, y: p.y + dy })).filter(onBoard) # the eight offsets are symmetric (with (dx, dy) the list also holds (-dx, dy)), so negating dx gives the same set of neighbours, only in another order, and only their count is used
life/rules.js: const neighbours = (p) => offsets.map(([dx, dy]) => ({ x: p.x + dx, y: p.y + dy })).filter(onBoard) => const neighbours = (p) => offsets.map(([dx, dy]) => ({ x: p.x + dx, y: p.y - dy })).filter(onBoard) # the eight offsets are symmetric (with (dx, dy) the list also holds (dx, -dy)), so negating dy gives the same set of neighbours, only in another order, and only their count is used
life/rules.js: : [...cells.filter((c) => index(c) < index(p)), { x: p.x, y: p.y }, ...cells.filter((c) => index(c) > index(p))] => : [...cells.filter((c) => index(c) <= index(p)), { x: p.x, y: p.y }, ...cells.filter((c) => index(c) > index(p))] # this branch is only reached when no cell has p's index (the line above removes p if it is present), so < and <= agree
life/rules.js: : [...cells.filter((c) => index(c) < index(p)), { x: p.x, y: p.y }, ...cells.filter((c) => index(c) > index(p))] => : [...cells.filter((c) => index(c) < index(p)), { x: p.x, y: p.y }, ...cells.filter((c) => index(c) >= index(p))] # this branch is only reached when no cell has p's index (the line above removes p if it is present), so > and >= agree
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env node
// Usage: node scripts/mutate.mjs [--jobs N] [--skip-failing] [file...]
//
// Mutation analysis of the JS reference solutions: how many small bugs would the JS test suite notice?
// Each mutant changes one token on one line of one file in js/solutions (e.g. `<=` -> `<`, `===` -> `!==`,
// `+` -> `-`, `1` -> `2`, dropping a `!`). The whole JS suite runs against each mutant in a temp copy of the repo
// whose js/src holds the solutions. A mutant is "killed" when some test fails (or the run times out), and
// "survives" when every test still passes: either the suite has a gap, or the mutant is equivalent (it cannot
// change behaviour, e.g. `x >= 0` -> `x > 0` where x is never 0).
//
// Files: paths relative to js/solutions (e.g. `game.js pong/rules.js`); default: every .js file there except the
// browser glue (browser.js, *main.js) and todo.js.
// Allow-list: scripts/mutate-allow.txt lists known equivalent mutants, one per line, as
// <file>: <original line> => <mutated line> # <why it is equivalent>
// (lines trimmed, no line number, so the list survives edits elsewhere in the file). The exact line to paste is
// printed for each survivor.
// Exit: 0 when every survivor is allow-listed, 1 when there are others, 2 on a setup error (including a suite that
// already fails on the unmutated solutions; --skip-failing then leaves out the failing test files, with a warning).
import { readFileSync, writeFileSync, cpSync, rmSync, mkdtempSync, readdirSync, existsSync } from 'node:fs'
import { spawn, spawnSync } from 'node:child_process'
import { join, dirname, relative, basename } from 'node:path'
import { tmpdir, availableParallelism } from 'node:os'
import { fileURLToPath } from 'node:url'
const repo = join(dirname(fileURLToPath(import.meta.url)), '..')
const allowPath = join(repo, 'scripts', 'mutate-allow.txt')
const TIMEOUT_MS = 60000
const argv = process.argv.slice(2)
const flag = (name) => argv.includes(name)
const jobsAt = argv.indexOf('--jobs')
const jobs = jobsAt >= 0 ? Number(argv[jobsAt + 1]) : Math.max(1, availableParallelism() - 1)
const fileArgs = argv.filter((a, i) => !a.startsWith('--') && !(jobsAt >= 0 && i === jobsAt + 1))
if (!Number.isInteger(jobs) || jobs < 1) { console.error('mutate: --jobs needs a positive integer'); process.exit(2) }
const listJs = (dir) => readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? listJs(join(dir, e.name)) : e.name.endsWith('.js') ? [join(dir, e.name)] : [])
const isGlue = (f) => basename(f) === 'browser.js' || basename(f).endsWith('main.js') || basename(f) === 'todo.js'
const solutions = join(repo, 'js', 'solutions')
const files = fileArgs.length > 0
? fileArgs.map((f) => f.replace(/^js\/solutions\//, ''))
: listJs(solutions).map((f) => relative(solutions, f)).filter((f) => !isGlue(f)).sort()
const missing = files.filter((f) => !existsSync(join(solutions, f)))
if (missing.length > 0) { console.error(`mutate: not in js/solutions: ${missing.join(', ')}`); process.exit(2) }
// Single-token rules. Each match of each rule on a code line is one mutant.
const rules = [
[/<=/g, '<'], [/>=/g, '>'], [/(?<![<=>!])<(?![=<])/g, '<='], [/(?<![=>-])>(?![=>])/g, '>='],
[/===/g, '!=='], [/!==/g, '==='], [/&&/g, '||'], [/\|\|/g, '&&'],
[/ \+ /g, ' - '], [/ - /g, ' + '], [/ % /g, ' * '],
[/\b0\b/g, '1'], [/\b1\b/g, '2'], [/\b2\b/g, '3'], [/\b3\b/g, '4'], [/\b8\b/g, '7'],
[/!(?=[a-z(])/g, ''],
]
const isCodeLine = (line) => !/^\s*(\/\/|\*|\/\*|import\b)/.test(line)
const stripComment = (line) => line.replace(/\/\/.*$/, '')
const mutantsOf = (file) => {
const lines = readFileSync(join(solutions, file), 'utf8').split('\n')
return lines.flatMap((line, li) => {
if (!isCodeLine(line)) return []
const code = stripComment(line)
return rules.flatMap(([re, rep]) => [...code.matchAll(re)].map((m) => {
const mutated = line.slice(0, m.index) + rep + line.slice(m.index + m[0].length)
return { file, line: li + 1, original: line.trim(), mutated: mutated.trim(),
source: [...lines.slice(0, li), mutated, ...lines.slice(li + 1)].join('\n') }
}))
})
}
const keyOf = (m) => `${m.file}: ${m.original} => ${m.mutated}`
// The allow-list: `key # reason`; a line without a reason is an error, so nobody adds one without thinking.
const readAllow = () => {
if (!existsSync(allowPath)) return new Map()
const entries = readFileSync(allowPath, 'utf8').split('\n')
.map((l, i) => [l.trim(), i + 1]).filter(([l]) => l !== '' && !l.startsWith('#'))
return new Map(entries.map(([l, n]) => {
const at = l.lastIndexOf(' # ')
if (at < 0 || l.slice(at + 4).trim() === '') {
console.error(`mutate: ${relative(repo, allowPath)}:${n}: missing " # reason"`); process.exit(2)
}
return [l.slice(0, at).trim(), l.slice(at + 4).trim()]
}))
}
// A temp copy of the repo parts the JS suite needs, with js/src replaced by the solutions.
const makeCopy = () => {
const work = mkdtempSync(join(tmpdir(), 'pure-snake-mutate-'))
cpSync(join(repo, 'js'), join(work, 'js'), { recursive: true, filter: (s) => !s.includes('node_modules') })
cpSync(join(repo, 'fixtures'), join(work, 'fixtures'), { recursive: true })
rmSync(join(work, 'js', 'src'), { recursive: true })
cpSync(join(work, 'js', 'solutions'), join(work, 'js', 'src'), { recursive: true })
return work
}
const runSuite = (work, args) => new Promise((resolve) => {
const child = spawn('node', ['--test', '--test-concurrency=1', ...args], { cwd: join(work, 'js'), stdio: 'ignore' })
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve('timeout') }, TIMEOUT_MS)
child.on('exit', (code) => { clearTimeout(timer); resolve(code === 0 ? 'pass' : 'fail') })
})
// Baseline: the suite must pass on the unmutated solutions, or every mutant would look "killed".
// With --skip-failing, tests that already fail are skipped by name (a file that fails to load is left out whole).
const baseline = makeCopy()
const allTests = readdirSync(join(baseline, 'js', 'test')).filter((f) => f.endsWith('.test.js')).sort()
.map((f) => `test/${f}`)
const failingIn = (t) => {
const r = spawnSync('node', ['--test', '--test-reporter=tap', t],
{ cwd: join(baseline, 'js'), encoding: 'utf8', timeout: TIMEOUT_MS })
if (r.status === 0) return null
const names = [...r.stdout.matchAll(/^\s*not ok \d+ - (.*)$/gm)].map((m) => m[1].trim()).filter((n) => n !== t)
return { file: t, names }
}
const failing = allTests.map(failingIn).filter((f) => f !== null)
rmSync(baseline, { recursive: true })
const skipFiles = failing.filter((f) => f.names.length === 0).map((f) => f.file)
const skipNames = failing.flatMap((f) => f.names)
if (failing.length > 0) {
console.error('mutate: the suite already fails on the unmutated solutions:')
failing.forEach((f) => console.error(` ${f.file}: ${f.names.length === 0 ? '(the whole file)' : f.names.join('; ')}`))
if (!flag('--skip-failing')) { console.error('mutate: fix them first, or pass --skip-failing to leave them out'); process.exit(2) }
console.error('mutate: WARNING: running without them; some mutants may survive only because of that')
}
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const suiteArgs = [...skipNames.map((n) => `--test-skip-pattern=^${escapeRe(n)}$`),
...allTests.filter((t) => !skipFiles.includes(t))]
const tests = allTests.filter((t) => !skipFiles.includes(t))
const allow = readAllow() // read before the long run, so a malformed line fails fast
const mutants = files.flatMap(mutantsOf)
console.log(`mutate: ${mutants.length} mutants in ${files.length} files, ${tests.length} test files, ${jobs} jobs`)
// A pool of workers, each with its own copy; a worker writes the mutant, runs the suite, restores the file.
const results = new Array(mutants.length)
const worker = async (queue) => {
const work = makeCopy()
try {
for (const i of queue) {
const m = mutants[i]
const path = join(work, 'js', 'src', m.file)
const original = readFileSync(path, 'utf8')
writeFileSync(path, m.source)
results[i] = await runSuite(work, suiteArgs)
writeFileSync(path, original)
}
} finally { rmSync(work, { recursive: true, force: true }) }
}
const queues = Array.from({ length: Math.min(jobs, mutants.length) }, (_, w) =>
mutants.map((_, i) => i).filter((i) => i % jobs === w))
await Promise.all(queues.map(worker))
const survivors = mutants.filter((_, i) => results[i] === 'pass')
const timeouts = results.filter((r) => r === 'timeout').length
const unlisted = survivors.filter((m) => !allow.has(keyOf(m)))
const listed = survivors.filter((m) => allow.has(keyOf(m)))
const survivorKeys = new Set(survivors.map(keyOf))
const stale = [...allow.keys()].filter((k) => files.includes(k.slice(0, k.indexOf(': '))) && !survivorKeys.has(k))
console.log(`killed ${mutants.length - survivors.length} (${timeouts} by timeout), survived ${survivors.length}` +
` (${listed.length} allow-listed as equivalent, ${unlisted.length} not)`)
if (unlisted.length > 0) {
console.log('\nSurvivors not in scripts/mutate-allow.txt (a test gap, or an equivalent mutant to allow-list with a reason):')
unlisted.forEach((m) => console.log(` js/solutions/${m.file}:${m.line}\n - ${m.original}\n + ${m.mutated}\n allow-list line: ${keyOf(m)} # <reason>`))
}
if (stale.length > 0) {
console.log('\nAllow-list entries for these files that no longer occur as survivors (remove or update them):')
stale.forEach((k) => console.log(` ${k}`))
}
process.exit(unlisted.length > 0 ? 1 : failing.length > 0 ? 2 : 0)
+2 -1
View File
@@ -39,7 +39,8 @@ try {
for (let i = 0; i < 100 && !target; i++) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
target = list.find((t) => t.type === 'page')
// Chrome may list an extension page first; take the tab we opened.
target = list.find((t) => t.type === 'page' && t.url === 'about:blank') ?? list.find((t) => t.type === 'page' && !t.url.startsWith('chrome-extension://'))
} catch { await sleep(100) }
}
if (!target) finish(1, 'FAIL: could not connect to Chrome')
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Usage: scripts/typecheck-js.sh
# Type-checks the JavaScript track with TypeScript, using js/jsconfig.json and the types in js/src/types.d.ts.
# Two runs, both must be clean:
# 1. the untouched stubs in js/src (a student's starting point must not show errors in the editor);
# 2. a temp copy of the repo whose js/src holds the solutions.
# Nothing is installed in the repo: TypeScript comes from `npx -y -p typescript tsc`, which needs the network the
# first time (npm caches it afterwards). Set TS_PACKAGE to pin a version, e.g. TS_PACKAGE=typescript@5.9.
# Exit: 0 clean, 1 type errors, 2 TypeScript could not be run (no npx, no network).
set -uo pipefail
repo="$(cd "$(dirname "$0")/.." && pwd)"
pkg="${TS_PACKAGE:-typescript}"
if ! command -v npx >/dev/null 2>&1; then
echo "typecheck-js: npx not found (it comes with Node.js); cannot run TypeScript" >&2; exit 2
fi
if ! version="$(npx -y -p "$pkg" tsc --version 2>&1)"; then
echo "typecheck-js: could not run 'npx -y -p $pkg tsc' (no network, or npm cannot reach its registry)." >&2
echo "$version" | tail -5 >&2
echo "typecheck-js: nothing was checked." >&2
exit 2
fi
version="$(grep -o 'Version .*' <<<"$version")"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
tar -C "$repo" --exclude=.git --exclude=node_modules --exclude=bin --exclude=obj \
--exclude=.scala-build --exclude=.bsp --exclude=.superpowers -cf - js | tar -C "$tmp" -xf -
mkdir -p "$tmp/stubs" "$tmp/solved"
cp -r "$tmp/js" "$tmp/stubs/js"
mv "$tmp/js" "$tmp/solved/js"
rm -rf "$tmp/solved/js/src"
cp -r "$tmp/solved/js/solutions" "$tmp/solved/js/src"
status=0
for which in stubs solved; do
label="$([ "$which" = stubs ] && echo "js/src (stubs)" || echo "js/solutions as src")"
if out="$(cd "$tmp/$which" && npx -y -p "$pkg" tsc -p js/jsconfig.json 2>&1)"; then
echo "$label: ok ($version)"
else
echo "$label: FAIL"
grep -v '^npm notice' <<<"$out"
status=1
fi
done
exit $status