Skip to content

Commit 02199cd

Browse files
feat: run Errata tests interactively in a widget
Tests can additionally be run interactively using a widget. When the text cursor is on a test's source span, the InfoView offers a "run" button that runs the test in a separate process, streaming its output as it is produced and killing the process if the run is cancelled.
1 parent 0d52941 commit 02199cd

13 files changed

Lines changed: 1235 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ jobs:
235235
npx tsc --noEmit -p jsconfig.json
236236
popd
237237
238+
- name: Type check the run-test widget JS code
239+
run: |
240+
pushd src/errata/Errata/widget
241+
npx tsc --noEmit -p jsconfig.json
242+
popd
243+
238244
- name: Check the ToC width storage key stays in sync
239245
run: |
240246
# toc-resize.js and toc-resize-preload.js must agree on the localStorage

doc/UsersGuide/Releases/Entries/TestFramework.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ The test runner discovers every test in the package; it can restrict the run to
2626
Elaboration-time tests can be written with `#test_msgs` and `#test_guard`, variants of `#guard_msgs` and `#guard` that run their check at compile time and record the outcome as a test case, reported together with the rest of the suite.
2727

2828
Verso's own test suite runs on Errata: `lake test` discovers and runs every test in the package, and CI publishes the resulting reports.
29+
30+
Tests can also be run interactively from the editor: a panel widget shown on a test's declaration runs it in a separate process, streaming its output as it is produced.

lakefile.lean

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,19 +142,30 @@ lean_lib VersoTests where
142142
roots := #[`VersoTests]
143143
globs := #[Glob.andSubmodules `VersoTests]
144144

145-
-- Everything below is Errata's own implementation: its library, its self-tests, the generated
146-
-- discovery runner, and the `lake test` driver.
145+
-- Everything below is Errata's own implementation: its library, the single-test runner and widget
146+
-- support exe, its self-tests, the generated discovery runner, and the `lake test` driver.
147147
namespace Errata
148148

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

154+
input_file errataRunTestWidgetJs where
155+
text := true
156+
path := "src/errata/Errata/widget/run_test_widget.js"
157+
154158
lean_lib Errata where
155159
srcDir := "src/errata"
156160
roots := #[`Errata]
157-
needs := #[errataUsageFile]
161+
needs := #[errataUsageFile, errataRunTestWidgetJs]
162+
163+
-- Runs one test in a fresh process so the widget can stream its output and kill it on cancel.
164+
@[default_target]
165+
lean_exe «errata-run-one» where
166+
srcDir := "src/errata"
167+
root := `ErrataRunOne
168+
supportInterpreter := true
158169

159170
-- Tests that exercise Errata using Errata itself.
160171
lean_lib ErrataTests where

src/errata-tests/ErrataTests.lean

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,62 @@ def reportMarkdown : Test := do
155155
assertContains "expected 1\nactual 2" md
156156
assertContains "Summary by module" md
157157

158+
/-- `runValue` reports a passing value as passed. -/
159+
@[test]
160+
def runOnePasses : Test := do
161+
let o ← runValue default (pure () : Test)
162+
assertEq "passed" o.status
163+
164+
/-- `runValue` reports a failing value as failed and carries its message. -/
165+
@[test]
166+
def runOneFails : Test := do
167+
let o ← runValue default (TestResult.fail { message := "boom" })
168+
assertEq "failed" o.status
169+
assertEq (some "boom") o.message?
170+
171+
/-- `runValue` reports a skipped value as skipped. -/
172+
@[test]
173+
def runOneSkips : Test := do
174+
let o ← runValue default (TestResult.skip "later")
175+
assertEq "skipped" o.status
176+
177+
/-- A failing run surfaces its captured output in the outcome. -/
178+
@[test]
179+
def runOneCapturesOutput : Test := do
180+
let o ← runValue default (do IO.println "trace line"; failHere "nope" : Test)
181+
assertEq "failed" o.status
182+
assertEq 1 o.output.size
183+
assertEq "stdout" o.output[0]!.stream
184+
assertContains "trace line" o.output[0]!.text
185+
186+
/-- An outcome takes the most severe verdict among several named results. -/
187+
@[test]
188+
def runOneAggregates : Test := do
189+
let o ← runValue default (do result "a" (pure ()); result "b" (failHere "bad") : Test)
190+
assertEq "failed" o.status
191+
192+
/-- A passing run still surfaces its captured output. -/
193+
@[test]
194+
def runOnePassOutput : Test := do
195+
let o ← runValue default (do IO.println "printed"; return true : IO Bool)
196+
assertEq "passed" o.status
197+
assertEq 1 o.output.size
198+
assertEq "stdout" o.output[0]!.stream
199+
assertContains "printed" o.output[0]!.text
200+
201+
/-- Captured output keeps stdout and stderr distinct and interleaved in order. -/
202+
@[test]
203+
def runOneStreams : Test := do
204+
let o ← runValue default (do
205+
IO.println "out one"
206+
IO.eprintln "err one"
207+
IO.println "out two"
208+
return true : IO Bool)
209+
assertEq "passed" o.status
210+
assertEq 3 o.output.size
211+
assertEq "stdout" o.output[0]!.stream
212+
assertEq "stderr" o.output[1]!.stream
213+
assertEq "stdout" o.output[2]!.stream
158214
/-- `failure` from the `Alternative` instance fails a test. -/
159215
@[test]
160216
def alternativeFailure : Test := expectFail failure

src/errata/Errata.lean

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public import Errata.Process
1515
public import Errata.Golden
1616
public import Errata.Report
1717
public import Errata.Runner
18+
public import Errata.NameJson
19+
public import Errata.RunOne
1820
public import Errata.Discovery
1921
public import Errata.CompileTime
2022
public import Errata.Property

src/errata/Errata/Discovery.lean

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ public import Errata.IsTest
99
public import Errata.Runner
1010
public import Lean
1111
public meta import Lean
12+
public meta import Errata.NameJson
13+
public meta import Errata.Widget
1214

1315
open Lean Meta Elab Term
1416

@@ -68,6 +70,70 @@ meta def recordTest (decl : Name) : AttrM Unit := do
6870
let docstring? ← findDocString? (← getEnv) decl
6971
modifyEnv (testExt.addEntry · { name := decl, file := ← getFileName, docstring? })
7072

73+
/-- A synthetic syntax carrying the given source range, used to position the widget. -/
74+
meta def rangeSyntax [Monad m] [MonadFileMap m]
75+
(startPos stopPos : String.Pos.Raw) : m Syntax := do
76+
let str := (← getFileMap).source
77+
let leading : Substring.Raw := { str, startPos, stopPos := startPos }
78+
let trailing : Substring.Raw := { str, startPos := stopPos, stopPos }
79+
return Syntax.atom (.original leading startPos trailing stopPos) ""
80+
81+
/-- The text of line {lean}`i`, trimmed of surrounding whitespace. -/
82+
private meta def lineText (lines : Array String) (i : Nat) : String :=
83+
((lines[i]?).getD "").trimAscii.copy
84+
85+
/-- The first non-blank line at or above {lean}`i`, or {lean}`none` if all are blank up to the top. -/
86+
private meta partial def firstNonBlankUp (lines : Array String) (i : Nat) : Option Nat :=
87+
if (lineText lines i).isEmpty then
88+
if i == 0 then none else firstNonBlankUp lines (i - 1)
89+
else some i
90+
91+
/-- Scanning up from {lean}`i`, the line that opens a doc comment, stopping at a non-comment line. -/
92+
private meta partial def docOpenLine (lines : Array String) (i : Nat) : Option Nat :=
93+
if (lineText lines i).startsWith "/--" then some i
94+
else if (lineText lines i).startsWith "/-" then none
95+
else if i == 0 then none
96+
else docOpenLine lines (i - 1)
97+
98+
/--
99+
The 0-based start line of a doc comment immediately above {lean}`markerLineIdx`, if any. To avoid
100+
mistaking an unrelated trailing comment for one, the comment must be a single-line doc comment or
101+
have its closing delimiter on its own line opened by a doc-comment line.
102+
-/
103+
private meta def docStartLine? (lines : Array String) (markerLineIdx : Nat) : Option Nat := do
104+
guard (markerLineIdx > 0)
105+
let endLine ← firstNonBlankUp lines (markerLineIdx - 1)
106+
let t := lineText lines endLine
107+
if t.startsWith "/--" && t.endsWith "-/" then return endLine
108+
guard (t == "-/" && endLine > 0)
109+
docOpenLine lines (endLine - 1)
110+
111+
/--
112+
The source range to show the test's widget over: the whole declaration, including a doc comment above
113+
it. The recorded declaration range is used when available; otherwise the command is re-parsed from the
114+
start of the marker's line, extending up over an immediately preceding doc comment. Falls back to the
115+
marker itself.
116+
-/
117+
meta def widgetRangeSyntax (decl : Name) (attrStx : Syntax) : AttrM Syntax := do
118+
let fileMap ← getFileMap
119+
if let some ranges ← findDeclarationRanges? decl then
120+
let stx ← rangeSyntax (fileMap.ofPosition ranges.range.pos) (fileMap.ofPosition ranges.range.endPos)
121+
return stx
122+
let some attrPos := attrStx.getPos? | return attrStx
123+
let lineStart := fileMap.ofPosition ⟨(fileMap.toPosition attrPos).line, 0
124+
let inputCtx := Parser.mkInputContext fileMap.source (← getFileName)
125+
let pmctx : Parser.ParserModuleContext := { env := ← getEnv, options := ← getOptions }
126+
let (cmdStx, _, _) := Parser.parseCommand inputCtx pmctx { pos := lineStart } {}
127+
match cmdStx.getRange? with
128+
| some range =>
129+
-- Extend the span up over a doc comment immediately above the marker, when there is one.
130+
let lines := (fileMap.source.splitOn "\n").toArray
131+
let startPos := match docStartLine? lines ((fileMap.toPosition attrPos).line - 1) with
132+
| some docIdx => fileMap.ofPosition ⟨docIdx + 1, 0
133+
| none => range.start
134+
rangeSyntax startPos range.stop
135+
| none => return attrStx
136+
71137
/-- Marks a definition as a test, discovered and run by the Errata test runner. -/
72138
meta initialize
73139
registerBuiltinAttribute {
@@ -80,6 +146,22 @@ meta initialize
80146
Attribute.Builtin.ensureNoArgs stx
81147
unless kind == AttributeKind.global do throwAttrMustBeGlobal `test kind
82148
recordTest decl
149+
-- Show the widget when the cursor is anywhere on the declaration, not just on the marker.
150+
let widgetStx ← widgetRangeSyntax decl stx
151+
-- A hash of the test's source, so a run is invalidated when the test is edited.
152+
let source := (← getFileMap).source
153+
let version := match widgetStx.getRange? with
154+
| some range =>
155+
let sub : Substring.Raw := { str := source, startPos := range.start, stopPos := range.stop }
156+
toString sub.toString.hash
157+
| none => ""
158+
let props := pure <| json% {
159+
decl: $(Errata.nameToJson decl),
160+
module: $(toString (← getMainModule)),
161+
name: $(toString (privateToUserName decl)),
162+
version: $version
163+
}
164+
Lean.Widget.savePanelWidgetInfo Errata.Widget.runTestWidget.javascriptHash.val props widgetStx
83165
}
84166

85167
/-- The test's name below its module: the declaration's components past the module prefix, dotted. -/

src/errata/Errata/NameJson.lean

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: David Thrane Christiansen
5+
-/
6+
module
7+
8+
public import Lean.Data.Json
9+
10+
public section
11+
12+
set_option linter.missingDocs true
13+
set_option doc.verso true
14+
15+
namespace Errata
16+
17+
open Lean
18+
19+
/--
20+
Encodes a {name}`Lean.Name` structurally, preserving the numeric and hygienic components that the
21+
standard string form does not round-trip. The widget and the single-test runner exchange test names
22+
this way.
23+
-/
24+
def nameToJson : Name → Json
25+
| .anonymous => .null
26+
| .str p s => Json.mkObj [("str", .arr #[nameToJson p, .str s])]
27+
| .num p n => Json.mkObj [("num", .arr #[nameToJson p, .num n])]
28+
29+
/-- Decodes a {name}`Lean.Name` written by {name}`nameToJson`. -/
30+
partial def nameOfJson? (j : Json) : Except String Name := do
31+
if j.isNull then return .anonymous
32+
if let .ok arr := j.getObjVal? "str" then
33+
let #[p, s] := (← fromJson? arr : Array Json) | .error "malformed name component"
34+
return .str (← nameOfJson? p) (← fromJson? s)
35+
if let .ok arr := j.getObjVal? "num" then
36+
let #[p, n] := (← fromJson? arr : Array Json) | .error "malformed name component"
37+
return .num (← nameOfJson? p) (← fromJson? n)
38+
.error "expected an encoded `Name`"

src/errata/Errata/RunOne.lean

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/-
2+
Copyright (c) 2026 Lean FRO LLC. All rights reserved.
3+
Released under Apache 2.0 license as described in the file LICENSE.
4+
Author: David Thrane Christiansen
5+
-/
6+
module
7+
8+
public import Errata.IsTest
9+
public import Lean.Data.Json
10+
11+
public section
12+
13+
set_option linter.missingDocs true
14+
set_option doc.verso true
15+
16+
namespace Errata
17+
18+
/-- A run of captured output from a single stream, used to render output with the streams distinct. -/
19+
structure OutputChunk where
20+
/-- The stream the text was written to: {lit}`"stdout"` or {lit}`"stderr"`. -/
21+
stream : String
22+
/-- The text written to that stream. -/
23+
text : String
24+
/-- When the chunk was received, in milliseconds since the Unix epoch; set by the runner. -/
25+
time : Nat := 0
26+
deriving Lean.FromJson, Lean.ToJson, Repr, Inhabited, DecidableEq
27+
28+
/-- The chunk for a single captured output fragment, tagged by its stream. -/
29+
def OutputChunk.ofOutput : Output → OutputChunk
30+
| .stdout s => { stream := "stdout", text := s }
31+
| .stderr s => { stream := "stderr", text := s }
32+
33+
/--
34+
The outcome of running a single test, in a form the InfoView widget renders. The status is one of
35+
{lit}`"passed"`, {lit}`"failed"`, {lit}`"error"`, or {lit}`"skipped"`.
36+
-/
37+
structure RunOutcome where
38+
/-- The overall verdict: {lit}`"passed"`, {lit}`"failed"`, {lit}`"error"`, or {lit}`"skipped"`. -/
39+
status : String
40+
/-- How long the run took, in milliseconds. -/
41+
durationMs : Nat
42+
/-- The failure or skip message, when the test did not pass. -/
43+
message? : Option String := none
44+
/-- Supporting detail for a failure, such as a diff or counterexample. -/
45+
detail? : Option String := none
46+
/-- The captured output, in order, with each chunk tagged by the stream it was written to. -/
47+
output : Array OutputChunk := #[]
48+
/-- The test's docstring, rendered as Markdown, when it has one. -/
49+
description? : Option String := none
50+
deriving Lean.FromJson, Lean.ToJson, Repr, Inhabited
51+
52+
/-- The status name a single result contributes. -/
53+
private def statusName : Status → String
54+
| .pass => "passed"
55+
| .fail _ => "failed"
56+
| .error _ => "error"
57+
| .skip _ => "skipped"
58+
59+
/-- The message a status carries, when it did not pass. -/
60+
private def statusMessage : Status → Option String
61+
| .pass => none
62+
| .fail f => some f.message
63+
| .error m => some m
64+
| .skip r => some r
65+
66+
/-- Appends one output fragment, merging it into the previous chunk when it is from the same stream. -/
67+
private def pushFragment (chunks : Array OutputChunk) (o : Output) : Array OutputChunk :=
68+
let stream := match o with | .stdout _ => "stdout" | .stderr _ => "stderr"
69+
match chunks.back? with
70+
| some last => if last.stream == stream
71+
then chunks.pop.push { last with text := last.text ++ o.text }
72+
else chunks.push { stream, text := o.text }
73+
| none => chunks.push { stream, text := o.text }
74+
75+
/--
76+
Condenses the results of one test run into a single outcome. The verdict is the most severe status
77+
present (error over failed over skipped over passed), the message and detail come from the first
78+
result with that status, and the output is every result's captured fragments in order, each tagged by
79+
its stream.
80+
-/
81+
def summarizeResults (results : Array Result) : RunOutcome := Id.run do
82+
let rank : Status → Nat
83+
| .error _ => 3
84+
| .fail _ => 2
85+
| .skip _ => 1
86+
| .pass => 0
87+
let worst := results.foldl (fun acc r => if rank r.status > rank acc then r.status else acc) .pass
88+
let duration := results.foldl (fun acc r => acc + r.durationMs) 0
89+
let output := results.foldl (fun acc r => r.output.log.foldl pushFragment acc) #[]
90+
return {
91+
status := statusName worst
92+
durationMs := duration
93+
message? := statusMessage worst
94+
detail? := match worst with | .fail f => f.detail? | _ => none
95+
output
96+
}
97+
98+
/--
99+
Runs one testable value to completion and condenses its results into a {name}`RunOutcome`. Captured
100+
output is kept on a passing result too, since the widget shows it on demand rather than only on
101+
failure.
102+
-/
103+
def runValue {α} [IsTest α] (location : Location) (value : α)
104+
(sink : Output → IO Unit := fun _ => pure ()) : IO RunOutcome := do
105+
let log ← IO.mkRef (#[] : Array Result)
106+
let usedOptions ← IO.mkRef ∅
107+
let cfg : Context := { log, usedOptions, location, writeOutput := sink }
108+
let start ← IO.monoMsNow
109+
let (outcome, output) ← runCapturing cfg (IsTest.toTest value)
110+
let dur := (← IO.monoMsNow) - start
111+
let logged ← log.get
112+
let results :=
113+
match cfg.resultOfOutcome outcome output dur (!logged.isEmpty) with
114+
| some r => logged.push r
115+
| none =>
116+
-- A passing test with named results: the results stand for it, but keep the test's own
117+
-- top-level output (written outside any result block) so the widget still shows it.
118+
if output.log.isEmpty then logged else logged.push { cfg.pass 0 with output }
119+
return summarizeResults results
120+
121+
/-- Runs one testable value with a default failure location, for callers without a source range. -/
122+
def runValueDefault {α} [IsTest α] (value : α) : IO RunOutcome := runValue default value

0 commit comments

Comments
 (0)