Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f8f6e2f
feat: add a proper test framework
david-christiansen Jun 24, 2026
805a924
chore: migrate BuildLog tests
david-christiansen Jun 24, 2026
e5887d3
glob warning
david-christiansen Jun 24, 2026
2618c96
Stemmer and zip tests
david-christiansen Jun 24, 2026
1ab9a41
Blog and serialization tests
david-christiansen Jun 24, 2026
5501fcd
Migrate the rest
david-christiansen Jun 24, 2026
a8cb3f0
Really the rest
david-christiansen Jun 24, 2026
bcf4d0d
Show on GH
david-christiansen Jun 24, 2026
fb450c7
invalidation
david-christiansen Jun 24, 2026
34fe6f6
review
david-christiansen Jun 25, 2026
dce73e5
review
david-christiansen Jun 25, 2026
ceaa78e
stx and exit
david-christiansen Jun 25, 2026
db8432d
widget better
david-christiansen Jun 30, 2026
8c0875e
docstring and widget improvements
david-christiansen Jul 1, 2026
a91db5d
separate Lakefile contents
david-christiansen Jul 1, 2026
815caca
Merge remote-tracking branch 'upstream/main' into test-framework
david-christiansen Jul 1, 2026
e66a331
migrate test post-merge
david-christiansen Jul 1, 2026
cbe0cbf
Shake Errata
david-christiansen Jul 1, 2026
0734cc8
cleanup
david-christiansen Jul 1, 2026
c34a7a4
migrate
david-christiansen Jul 1, 2026
76edbb8
restore removed mandatory check
david-christiansen Jul 1, 2026
6cca9ab
Update src/errata/Errata/Widget.lean
david-christiansen Jul 1, 2026
e5693e5
Update src/errata/Errata/widget/run_test_widget.js
david-christiansen Jul 1, 2026
2e270ca
Update lakefile.lean
david-christiansen Jul 1, 2026
b0180d9
Update lakefile.lean
david-christiansen Jul 1, 2026
6029b67
cleanups
david-christiansen Jul 2, 2026
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
35 changes: 34 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ jobs:
build:
name: Build and test
runs-on: nscloud-ubuntu-22.04-amd64-8x16
permissions:
contents: read
checks: write
env:
# Used for browser tests. Placing them here allows caching to work right.
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
Expand Down Expand Up @@ -90,7 +93,31 @@ jobs:

- name: Run tests
run: |
lake test -- --verbose --check-tex
lake test -- --test-options --verbose --check-tex --junit=errata-report.xml --markdown=errata-summary.md

- name: Add test results to the job summary
if: always()
run: |
if [ -f errata-summary.md ]; then cat errata-summary.md >> "$GITHUB_STEP_SUMMARY"; fi

- name: Publish the JUnit test report
if: always()
uses: dorny/test-reporter@v1
with:
name: Errata tests
path: errata-report.xml
reporter: java-junit
fail-on-error: false

- name: Upload the raw test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: errata-test-reports
path: |
errata-report.xml
errata-summary.md
if-no-files-found: ignore

- name: Generate the test website
run: |
Expand Down Expand Up @@ -147,6 +174,12 @@ jobs:
npx tsc --noEmit -p jsconfig.json
popd

- name: Type check the run-test widget JS code
run: |
pushd src/errata/Errata/widget
npx tsc --noEmit -p jsconfig.json
popd

- name: Check the ToC width storage key stays in sync
run: |
# toc-resize.js and toc-resize-preload.js must agree on the localStorage
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/no-eval-in-source.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,11 @@ jobs:
fi
done < <(find ./src -path ./src/tests -prune -o \
-path ./src/test-projects -prune -o \
-path ./src/errata-tests -prune -o \
-name "*.lean" -type f -print0)

if [ ${#OFFENDING_FILES[@]} -gt 0 ]; then
echo "Found #eval statements in module source files (should be in src/tests/):"
echo "Found #eval statements in module source files (should be in src/tests/ or src/errata-tests/):"
printf '%s\n' "${OFFENDING_FILES[@]}"
echo ""
echo "Offending lines:"
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/test-imports.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# This check is retained only to satisfy the required "Check all test modules are imported" status
# check on the protected branch. It is a no-op on this layout: test modules now live under
# src/tests/VersoTests and are discovered by globbing, and src/tests/Tests no longer exists, so the
# scan below finds nothing. Coverage is instead enforced by `errataWarnUncoveredTestModules` during
# `lake test`. Remove this workflow (and drop the required check) once merged.
Comment on lines +1 to +5

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we just delete the required status and then go ahead and delete this file in the PR? Seems preferable and more atomic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. When we decide "merge", I can delete it from the PR and remove it from the required set. Until then, let's keep things passing.

name: All test modules imported

on: [pull_request, merge_group]
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-subverso.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ jobs:
- name: Run tests
if: steps.check-changes.outputs.changed == 'true'
run: |
lake test -- --verbose --check-tex
lake test -- --test-options --verbose --check-tex

- name: Create branch and open PR
if: steps.check-changes.outputs.changed == 'true'
Expand Down
2 changes: 1 addition & 1 deletion lake-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"type": "git",
"subDir": null,
"scope": "",
"rev": "f3f26cc72646205ca167117487c008ee1dafe816",
"rev": "f3c7bd5061bd81b4480295c524d4f245c8b7e4e2",
"name": "plausible",
"manifestFile": "lake-manifest.json",
"inputRev": "main",
Expand Down
251 changes: 246 additions & 5 deletions lakefile.lean
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,256 @@ lean_exe «verso-literate-plan» where
srcDir := "src/verso-literate-plan"
supportInterpreter := true

-- All test code: Errata test modules, compile-time tests, fixtures, and generators. Submodules are
-- globbed so each is built and every `@[test]` module is discoverable.
@[default_target]
lean_lib Tests where
lean_lib VersoTests where
srcDir := "src/tests"
roots := #[`VersoTests]
globs := #[Glob.andSubmodules `VersoTests]

@[test_driver]
lean_exe «verso-tests» where
root := `TestMain
srcDir := "src/tests"
-- Everything below is Errata's own implementation: its library, the single-test runner and widget
-- support exe, its self-tests, the generated discovery runner, and the `lake test` driver.
section Errata

@[default_target]
input_file errataUsageFile where
text := true
path := "src/errata/Errata/usage.txt"

input_file errataRunTestWidgetJs where
text := true
path := "src/errata/Errata/widget/run_test_widget.js"

lean_lib Errata where
srcDir := "src/errata"
roots := #[`Errata]
needs := #[errataUsageFile, errataRunTestWidgetJs]

-- Runs one test in a fresh process so the widget can stream its output and kill it on cancel.
@[default_target]
lean_exe «errata-run-one» where
srcDir := "src/errata"
root := `ErrataRunOne
supportInterpreter := true

-- Tests that exercise Errata using Errata itself.
lean_lib ErrataTests where
srcDir := "src/errata-tests"
roots := #[`ErrataTests]

-- The selected test set, written by the driver. The generated targets depend on it, so changing
-- the selection changes their trace and Lake rebuilds them rather than relinking a stale object.
input_file errataSelection where
text := true
path := ".lake/errata-runner/selection"

-- The generated discovered-tests module (`allTests`), written by the Errata driver.
lean_lib ErrataGenerated where
srcDir := ".lake/errata-runner"
roots := #[`ErrataDiscovered]
needs := #[errataSelection]

-- The generated, discovered test runner. Its source is written by the Errata test driver.
lean_exe «errata-runner» where
root := `ErrataRunnerMain
srcDir := ".lake/errata-runner"
supportInterpreter := true
needs := #[errataSelection]
Comment on lines +165 to +182

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current workflow is to for these to fail to build until errata-test is run to generate them. I presume the reason for this is that they can change based on the arguments passed to errata-test? Otherwise, I would have suggested to use needs to depend on custom target which generates them. That way, e.g., lake exe errata-runner works even if a errata-test has yet to be called. It may be worth documenting the rationale for the current design in a brief comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You understand correctly. I'll do the comment.


/--
Whether a source file introduces Errata tests, by an `@[test]` attribute (applied inline or with a
separate `attribute [test] …`), a `#test_msgs` command, or a `#test_guard` command. This drives the
glob-coverage check, which reads source files before anything is built; test discovery itself reads
the compiled modules.
-/
private def errataSourceHasTests (lines : List String) : Bool :=
lines.any fun line =>
let t := line.trimAsciiStart.copy
t.startsWith "@[test]" || t.startsWith "attribute [test" ||
t.startsWith "#test_msgs" || t.startsWith "#test_guard"
Comment on lines +184 to +194

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems very fragile. Perhaps one could (also?) mark errata files with a line like --! errata?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, maybe it'd be better just to delete the warning?

The situation I want to avoid is someone saying "I thought my tests were running!" when they aren't. I think remembering to add --! errata is no more likely than remembering to change the glob value, so I don't think it would catch this kind of thing.

But this is indeed fragile. Probably deleting it is better, and instead warning if the globs setting of a library that contains any test at all is the wrong one, or just documenting the footgun loudly in a README. I'll sleep on it.


/-- Reads a built module's `.olean` header: whether it participates in the module system, and whether
it records any `@[test]` (including those generated by `#test_msgs` and `#test_guard`). -/
private def errataModuleInfo (oleanFile : System.FilePath) : IO (Bool × Bool) := do
let (data, region) ← Lean.readModuleData oleanFile
let hasTests := data.entries.any fun (name, entries) => name == `Errata.test && entries.size > 0
let isModule := data.isModule
unsafe region.free
return (isModule, hasTests)

/-- Generate the bridge module: `import all` the module-system test modules so their private tests
are reachable, gathering them into `allTests` through `getAllTests%`. -/
private def errataDiscoveredSource (packageName : String) (mods : Array Lean.Name) : String :=
let imports := "\n".intercalate ("public import Errata" :: mods.toList.map (s!"import all {·}"))
let modList := " ".intercalate (mods.toList.map (·.toString))
s!"module\n\n{imports}\n\n\
public def allTests : Array Errata.TestEntry := getAllTests% \"{packageName}\" {modList}\n"

/-- Generate the non-module main: import the bridge module and the non-module test modules (which a
`module` cannot import), then run their combined tests. -/
private def errataMainSource (packageName : String) (mods : Array Lean.Name) (discovered : Lean.Name) :
String :=
let imports := "\n".intercalate
("import Errata" :: s!"import {discovered}" :: mods.toList.map (s!"import {·}"))
let modList := " ".intercalate (mods.toList.map (·.toString))
s!"{imports}\n\n\
def main (args : List String) : IO UInt32 :=\n \
Errata.runMain (allTests ++ getAllTests% \"{packageName}\" {modList}) args\n"

/--
Splits driver arguments at the `--test-options` marker into library names and runner passthrough
arguments. Library names precede the marker and may not look like options; everything after the
marker goes to the runner.
-/
private def errataSplitArgs (args : List String) : Except String (List String × List String) :=
let (names, rest) :=
match args.span (· != "--test-options") with
| (names, _ :: after) => (names, after)
| (names, []) => (names, [])
match names.find? (·.startsWith "-") with
| some opt =>
.error s!"unexpected option '{opt}': arguments before the `--test-options` marker name the \
libraries to test. Put runner options after the marker, \
e.g. `lake test -- --test-options {opt}`."
| none => .ok (names, rest)

/-- Usage information for `lake test`, shared with `Errata.usage` through one text file. -/
private def errataUsage : String := include_str "src/errata/Errata/usage.txt"

/-- Every `.lean` file below a directory, recursively. -/
private partial def errataLeanFiles (dir : System.FilePath) : IO (Array System.FilePath) := do
unless ← dir.pathExists do return #[]
let mut out := #[]
for entry in ← dir.readDir do
if ← entry.path.isDir then
out := out ++ (← errataLeanFiles entry.path)
else if entry.path.extension == some "lean" then
out := out.push entry.path
return out

/-- The module name of a `.lean` file relative to a source directory, if it lies within it. -/
private def errataModuleOfPath (srcDir path : System.FilePath) : Option Lean.Name := do
guard (path.extension == some "lean")
let stem ← path.fileStem
let parent ← path.parent
guard (srcDir.components.isPrefixOf parent.components)
let comps := parent.components.drop srcDir.components.length ++ [stem]
some (".".intercalate comps).toName

/--
Warns about modules that look like they define tests but whose library's globs do not cover them, so
the tests would be silently undiscovered. A module within a library's root that is not matched by the
library's globs, in a file that introduces tests, is the signal. The check reads source text and is a
heuristic, so it warns rather than failing the run.
-/
private def errataWarnUncoveredTestModules (ws : Lake.Workspace) : IO Unit := do
let mut missed : Array Lean.Name := #[]
for lib in ws.root.leanLibs do
if lib.name == `ErrataGenerated then continue
let srcDir := lib.srcDir
for path in ← errataLeanFiles srcDir do
let some mod := errataModuleOfPath srcDir path | continue
let withinRoot := lib.roots.any (·.isPrefixOf mod)
let globbed := lib.config.globs.any (·.matches mod)
if withinRoot && !globbed && !missed.contains mod then
let lines := (← IO.FS.readFile path).splitOn "\n"
if errataSourceHasTests lines then
missed := missed.push mod
unless missed.isEmpty do
IO.eprintln "warning: these modules look like they define tests but their library's globs do \
not cover them, so the tests are not discovered. Widen the library's `globs` \
(e.g. `globs := #[Glob.andSubmodules `Root]`):"
for mod in missed do
IO.eprintln s!" {mod}"

@[test_driver]
script «errata-test» (args) do
let ws ← getWorkspace
-- Answer `--help` before discovering or building anything.
if args.any (fun a => a == "--help" || a == "-h") then
IO.println errataUsage
return 0
let (libNames, runnerArgs) ←
match errataSplitArgs args with
| .ok result => pure result
| .error msg =>
IO.eprintln s!"error: {msg}"
IO.eprintln errataUsage
return 1
-- Search the named libraries, or every library in the package by default. A name may be a bare
-- `Library` in this package or a `package/Library` reaching into a dependency, following Lake's
-- target syntax. The generated runner lib has no source until this script writes it, and no tests.
let candidates := ws.root.leanLibs.filter (·.name != `ErrataGenerated)
let libs ←
if libNames.isEmpty then pure candidates
else do
let mut chosen : Array Lake.LeanLib := #[]
for spec in libNames do
let lib? ←
match spec.splitOn "/" with
| [libName] => pure (candidates.find? (·.name == libName.toName))
| [pkgName, libName] =>
let pkgName := if pkgName.startsWith "@" then pkgName.drop 1 else pkgName
let pkg? := if pkgName.isEmpty then some ws.root else ws.findPackageByName? pkgName.toName
match pkg? with
| some pkg => pure (pkg.findLeanLib? libName.toName)
| none =>
IO.eprintln s!"error: no package named '{pkgName}'"
return 1
| _ =>
IO.eprintln s!"error: invalid library spec '{spec}' (expected `Library` or `package/Library`)"
return 1
match lib? with
| some lib => chosen := chosen.push lib
| none =>
IO.eprintln s!"error: no library matches '{spec}'"
return 1
pure chosen
-- Modules that look like they define tests but escape their library's globs are likely a
-- configuration slip; warn, but run the discovered suite anyway.
errataWarnUncoveredTestModules ws
-- Build every module in the selected libraries; their compiled `.olean` headers are authoritative
-- on which modules carry tests, so no test is dropped by a source-level heuristic.
let modInfos ← runBuild do
let mut oleanJobs := #[]
let mut infos : Array (Lean.Name × System.FilePath) := #[]
for lib in libs do
let mods ← (← lib.modules.fetch).await
for m in mods do
oleanJobs := oleanJobs.push (← m.olean.fetch)
infos := infos.push (m.name, m.oleanFile)
let _ ← (Job.collectArray oleanJobs).await
pure (Job.pure infos)
Comment thread
david-christiansen marked this conversation as resolved.
Outdated
-- A test module is one whose `.olean` records a test. Module-system test modules go in the bridge
-- module (`import all`); non-module ones can only be imported by the non-module main.
let mut moduleMods : Array Lean.Name := #[]
let mut nonModuleMods : Array Lean.Name := #[]
for (moduleName, oleanFile) in modInfos do
let (isModule, hasTests) ← errataModuleInfo oleanFile
if hasTests then
if isModule then moduleMods := moduleMods.push moduleName
else nonModuleMods := nonModuleMods.push moduleName
-- Write the generated sources, plus a `selection` file naming the chosen test set. The generated
-- targets depend on that file, so a changed selection invalidates them through Lake's own trace.
let dir := ws.root.dir / ".lake" / "errata-runner"
IO.FS.createDirAll dir
let selection := "\n".intercalate ((moduleMods ++ nonModuleMods).map (·.toString) |>.qsort (· < ·)).toList
for (name, src) in
[("selection", selection ++ "\n"),
("ErrataDiscovered.lean", errataDiscoveredSource ws.root.prettyName moduleMods),
("ErrataRunnerMain.lean", errataMainSource ws.root.prettyName nonModuleMods `ErrataDiscovered)] do
let file := dir / name
let changed ← if ← file.pathExists then pure ((← IO.FS.readFile file) != src) else pure true
if changed then IO.FS.writeFile file src
-- Build and run the discovered runner.
let some exe := ws.findLeanExe? `«errata-runner»
| IO.eprintln "errata-runner executable is not configured"; return 1
let exePath ← runBuild exe.fetch
Comment thread
david-christiansen marked this conversation as resolved.
Outdated
let child ← IO.Process.spawn { cmd := exePath.toString, args := runnerArgs.toArray }
child.wait

end Errata

lean_lib UsersGuide where
srcDir := "doc"
Expand Down
Loading
Loading