From 7c963131dc2c116369b863896b0b686d1cdba25c Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 17:07:06 -0300 Subject: [PATCH 1/5] ci: measure the macOS held-dialog pane test (temporary) --- .github/workflows/measure-tmp.yml | 32 +++++++++++++ internal/browser/zz_probe_live_test.go | 63 ++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 .github/workflows/measure-tmp.yml create mode 100644 internal/browser/zz_probe_live_test.go diff --git a/.github/workflows/measure-tmp.yml b/.github/workflows/measure-tmp.yml new file mode 100644 index 00000000..54b6a1c0 --- /dev/null +++ b/.github/workflows/measure-tmp.yml @@ -0,0 +1,32 @@ +# Temporary: measures the macOS held-dialog pane failure. Removed before merge. +name: measure-tmp +on: + pull_request: + paths: ['.github/workflows/measure-tmp.yml', 'internal/browser/**'] +jobs: + measure: + strategy: + fail-fast: false + matrix: + runner: [macos-15-intel, macos-latest] + suite: + - {name: pane, run: '^TestChromeAPaneOpenedOnADialogShowsThePage$', count: 20} + - {name: chrome, run: 'TestChrome', count: 3} + - {name: probe, run: '^TestProbeHeldScreencastSteps$', count: 10} + runs-on: ${{ matrix.runner }} + name: ${{ matrix.runner }} ${{ matrix.suite.name }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Measure + run: | + set -o pipefail + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version || true + go test -v -timeout 80m -count=${{ matrix.suite.count }} -run '${{ matrix.suite.run }}' ./internal/browser/ 2>&1 | tee out.txt + code=$? + echo "PASS: $(grep -c -- '^--- PASS' out.txt) FAIL: $(grep -c -- '^--- FAIL' out.txt)" + grep -- '--- FAIL\|_test.go:' out.txt || true + grep PROBE out.txt | sed 's/.*PROBE/PROBE/' | sort | uniq -c || true + exit $code diff --git a/internal/browser/zz_probe_live_test.go b/internal/browser/zz_probe_live_test.go new file mode 100644 index 00000000..a6711513 --- /dev/null +++ b/internal/browser/zz_probe_live_test.go @@ -0,0 +1,63 @@ +package browser + +import ( + "context" + "testing" + "time" + + "github.com/chromedp/cdproto/emulation" + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + "github.com/lichdotdev/lich/api" +) + +// Temporary probe: which CDP call of Screencast hangs on a held page. +func TestProbeHeldScreencastSteps(t *testing.T) { + p, srv := liveChrome(t) + frames := make(chan string, 8) + opened := make(chan struct{}, 1) + p.Listen(func(data string) { + select { + case frames <- data: + default: + } + }, func(string, string) {}, func(string, api.BrowserDownload) {}, func(string) {}, func(d *api.BrowserDialog) { + if d != nil { + opened <- struct{}{} + } + }) + ctx := context.Background() + if err := p.Navigate(ctx, srv.URL); err != nil { + t.Fatal(err) + } + step := func(name string, a chromedp.Action) { + c, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + s := time.Now() + err := p.runFree(c, a) + t.Logf("PROBE %s: err=%v in %s", name, err, time.Since(s)) + } + step("screenshot", chromedp.ActionFunc(func(ctx context.Context) error { + _, err := page.CaptureScreenshot().Do(ctx) + return err + })) + step("evaluate", chromedp.Evaluate(`setTimeout(() => confirm('Delete?'), 0)`, nil)) + <-opened + step("stop", page.StopScreencast()) + step("metrics", emulation.SetDeviceMetricsOverride(640, 400, 1, false)) + step("start", page.StartScreencast().WithFormat(page.ScreencastFormatJpeg).WithQuality(85).WithMaxWidth(640).WithMaxHeight(400)) + s := time.Now() + select { + case <-frames: + t.Logf("PROBE frame in %s", time.Since(s)) + case <-time.After(5 * time.Second): + t.Logf("PROBE no frame in 5s") + } + step("answer", page.HandleJavaScriptDialog(true)) + select { + case <-frames: + t.Logf("PROBE frame after answer in %s", time.Since(s)) + case <-time.After(5 * time.Second): + t.Logf("PROBE no frame after answer") + } +} From 9a1ecf4b5666a83683d92f8ee4a1644257291b87 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 17:17:28 -0300 Subject: [PATCH 2/5] probe v2 --- .github/workflows/measure-tmp.yml | 6 +-- internal/browser/zz_probe_live_test.go | 66 ++++++++++++++------------ 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.github/workflows/measure-tmp.yml b/.github/workflows/measure-tmp.yml index 54b6a1c0..f2663d60 100644 --- a/.github/workflows/measure-tmp.yml +++ b/.github/workflows/measure-tmp.yml @@ -8,11 +8,9 @@ jobs: strategy: fail-fast: false matrix: - runner: [macos-15-intel, macos-latest] + runner: [macos-15-intel] suite: - - {name: pane, run: '^TestChromeAPaneOpenedOnADialogShowsThePage$', count: 20} - - {name: chrome, run: 'TestChrome', count: 3} - - {name: probe, run: '^TestProbeHeldScreencastSteps$', count: 10} + - {name: probe, run: '^TestProbeHeldScreencastSteps$', count: 8} runs-on: ${{ matrix.runner }} name: ${{ matrix.runner }} ${{ matrix.suite.name }} steps: diff --git a/internal/browser/zz_probe_live_test.go b/internal/browser/zz_probe_live_test.go index a6711513..c1c0d2ef 100644 --- a/internal/browser/zz_probe_live_test.go +++ b/internal/browser/zz_probe_live_test.go @@ -2,62 +2,66 @@ package browser import ( "context" + "fmt" "testing" "time" - "github.com/chromedp/cdproto/emulation" + "github.com/chromedp/cdproto/browser" + "github.com/chromedp/cdproto/cdp" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "github.com/lichdotdev/lich/api" ) -// Temporary probe: which CDP call of Screencast hangs on a held page. +// Temporary probe: why Page.stopScreencast hangs on a held page. func TestProbeHeldScreencastSteps(t *testing.T) { + for _, idle := range []time.Duration{0, 2 * time.Second} { + t.Run(fmt.Sprint("idle", idle), func(t *testing.T) { probe(t, idle) }) + } +} + +func probe(t *testing.T, idle time.Duration) { p, srv := liveChrome(t) - frames := make(chan string, 8) + t0 := time.Now() + logf := func(f string, a ...any) { t.Logf("PROBE %6dms "+f, append([]any{time.Since(t0).Milliseconds()}, a...)...) } opened := make(chan struct{}, 1) - p.Listen(func(data string) { - select { - case frames <- data: - default: - } - }, func(string, string) {}, func(string, api.BrowserDownload) {}, func(string) {}, func(d *api.BrowserDialog) { + p.Listen(func(string) {}, func(string, string) {}, func(string, api.BrowserDownload) {}, func(string) {}, func(d *api.BrowserDialog) { if d != nil { opened <- struct{}{} } }) + chromedp.ListenTarget(p.ctx, func(ev any) { logf("event %T", ev) }) ctx := context.Background() if err := p.Navigate(ctx, srv.URL); err != nil { t.Fatal(err) } - step := func(name string, a chromedp.Action) { - c, cancel := context.WithTimeout(ctx, 10*time.Second) + logf("navigated") + step := func(name string, d time.Duration, a chromedp.Action) { + c, cancel := context.WithTimeout(ctx, d) defer cancel() s := time.Now() err := p.runFree(c, a) - t.Logf("PROBE %s: err=%v in %s", name, err, time.Since(s)) + logf("%s: err=%v in %s", name, err, time.Since(s)) } - step("screenshot", chromedp.ActionFunc(func(ctx context.Context) error { + step("screenshot", 10*time.Second, chromedp.ActionFunc(func(ctx context.Context) error { _, err := page.CaptureScreenshot().Do(ctx) return err })) - step("evaluate", chromedp.Evaluate(`setTimeout(() => confirm('Delete?'), 0)`, nil)) + time.Sleep(idle) + step("evaluate", 10*time.Second, chromedp.Evaluate(`setTimeout(() => confirm('Delete?'), 0)`, nil)) <-opened - step("stop", page.StopScreencast()) - step("metrics", emulation.SetDeviceMetricsOverride(640, 400, 1, false)) - step("start", page.StartScreencast().WithFormat(page.ScreencastFormatJpeg).WithQuality(85).WithMaxWidth(640).WithMaxHeight(400)) - s := time.Now() - select { - case <-frames: - t.Logf("PROBE frame in %s", time.Since(s)) - case <-time.After(5 * time.Second): - t.Logf("PROBE no frame in 5s") - } - step("answer", page.HandleJavaScriptDialog(true)) - select { - case <-frames: - t.Logf("PROBE frame after answer in %s", time.Since(s)) - case <-time.After(5 * time.Second): - t.Logf("PROBE no frame after answer") - } + logf("dialog open") + go step("stop", 40*time.Second, page.StopScreencast()) + time.Sleep(2 * time.Second) + step("history", 3*time.Second, chromedp.ActionFunc(func(ctx context.Context) error { + _, _, err := page.GetNavigationHistory().Do(ctx) + return err + })) + c := chromedp.FromContext(p.ctx) + step("version", 3*time.Second, chromedp.ActionFunc(func(ctx context.Context) error { + _, _, _, _, _, err := browser.GetVersion().Do(cdp.WithExecutor(ctx, c.Browser)) + return err + })) + step("answer", 5*time.Second, page.HandleJavaScriptDialog(true)) + time.Sleep(3 * time.Second) } From 988095a1117ff34eb4906e57caf347bc40c586e7 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 17:26:28 -0300 Subject: [PATCH 3/5] fix(browser): cast a page held by a dialog on Chrome 153 and older --- .github/workflows/measure-tmp.yml | 12 ++--- docs/ceilings.md | 4 +- internal/browser/chrome.go | 5 ++ internal/browser/chrome_view.go | 65 ++++++++++++++++++++----- internal/browser/dialogs_test.go | 31 ++++++++++++ internal/browser/zz_probe_live_test.go | 67 -------------------------- 6 files changed, 98 insertions(+), 86 deletions(-) delete mode 100644 internal/browser/zz_probe_live_test.go diff --git a/.github/workflows/measure-tmp.yml b/.github/workflows/measure-tmp.yml index f2663d60..447a135f 100644 --- a/.github/workflows/measure-tmp.yml +++ b/.github/workflows/measure-tmp.yml @@ -8,9 +8,10 @@ jobs: strategy: fail-fast: false matrix: - runner: [macos-15-intel] + runner: [macos-15-intel, macos-latest] suite: - - {name: probe, run: '^TestProbeHeldScreencastSteps$', count: 8} + - {name: pane, run: '^TestChromeAPaneOpenedOnADialogShowsThePage$', count: 50} + - {name: chrome, run: 'TestChrome', count: 5} runs-on: ${{ matrix.runner }} name: ${{ matrix.runner }} ${{ matrix.suite.name }} steps: @@ -20,11 +21,8 @@ jobs: go-version-file: go.mod - name: Measure run: | - set -o pipefail "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version || true - go test -v -timeout 80m -count=${{ matrix.suite.count }} -run '${{ matrix.suite.run }}' ./internal/browser/ 2>&1 | tee out.txt - code=$? - echo "PASS: $(grep -c -- '^--- PASS' out.txt) FAIL: $(grep -c -- '^--- FAIL' out.txt)" + go test -v -timeout 80m -count=${{ matrix.suite.count }} -run '${{ matrix.suite.run }}' ./internal/browser/ > out.txt 2>&1; code=$? + echo "TALLY PASS=$(grep -c -- '^--- PASS' out.txt) FAIL=$(grep -c -- '^--- FAIL' out.txt)" grep -- '--- FAIL\|_test.go:' out.txt || true - grep PROBE out.txt | sed 's/.*PROBE/PROBE/' | sort | uniq -c || true exit $code diff --git a/docs/ceilings.md b/docs/ceilings.md index c0f17cfa..36fb788b 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1033,7 +1033,9 @@ work when nobody knows it and that the call site never shows. The mechanism and none of 30 from 500 ms; Chrome for Testing 155 and Helium 0.19.2.1). The agent hears of a dialog the person answered, at most twenty between two page answers, but not of one the agent itself answered. A held page cannot lay out, so a pane opened or resized meanwhile shows it at the size it had, and lays it out at the pane's once the - dialog closes; a screenshot fails until then. + dialog closes; a screenshot fails until then. On Chrome 153 and older, which answers a screencast's start and + stop only once the dialog closes, a pane resized while one is open keeps its last frame until it is answered + (Chrome 151 on the macos-15-intel runner, measured; 154 on answers at once). A window opened with Open in a window was not tried with a dialog. ## Languages diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index 4218a613..8b2c3e95 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sync" "time" "github.com/chromedp/cdproto/browser" @@ -87,6 +88,10 @@ type chromePage struct { dl *downloads // gate is held while the page has a JavaScript dialog open. gate dialogGate + // cast orders the screencast's starts and stops, which can run on past + // their caller (castPastDialog); casting says one is running. + cast sync.Mutex + casting bool } // downloadFolder is downloadDir, which the live tests point at a folder of diff --git a/internal/browser/chrome_view.go b/internal/browser/chrome_view.go index 486bd5e8..9ac5f1d9 100644 --- a/internal/browser/chrome_view.go +++ b/internal/browser/chrome_view.go @@ -190,22 +190,65 @@ func (p *chromePage) deliver(frame *page.EventScreencastFrame) { // Screencast starts the frames at v, or restarts them there: Chrome keeps // the size a screencast started with. func (p *chromePage) Screencast(ctx context.Context, v Viewport) error { - if err := p.runFree(ctx, page.StopScreencast()); err != nil { - return err + w, h := v.devicePixels() + return p.castPastDialog(func() error { + if err := p.stopCast(ctx); err != nil { + return err + } + if err := p.Resize(ctx, v); err != nil { + return err + } + p.casting = true + return p.runFree(ctx, page.StartScreencast(). + WithFormat(page.ScreencastFormatJpeg). + WithQuality(screencastQuality). + WithMaxWidth(w). + WithMaxHeight(h)) + }) +} + +func (p *chromePage) StopScreencast(ctx context.Context) error { + return p.castPastDialog(func() error { return p.stopCast(ctx) }) +} + +// stopCast stops the frames. With none running it sends nothing, so a pane +// opened on a held page starts its frames without a stop waiting first. +func (p *chromePage) stopCast(ctx context.Context) error { + if !p.casting { + return nil } - if err := p.Resize(ctx, v); err != nil { + if err := p.runFree(ctx, page.StopScreencast()); err != nil { return err } - w, h := v.devicePixels() - return p.runFree(ctx, page.StartScreencast(). - WithFormat(page.ScreencastFormatJpeg). - WithQuality(screencastQuality). - WithMaxWidth(w). - WithMaxHeight(h)) + p.casting = false + return nil } -func (p *chromePage) StopScreencast(ctx context.Context) error { - return p.runFree(ctx, page.StopScreencast()) +// castPastDialog runs step, a start or stop of the screencast, after the ones +// before it, and returns once it is done or the page holds a dialog. Chrome up +// to 153 answers Page.startScreencast and Page.stopScreencast only once the +// renderer has seen them, and a renderer holding a dialog sees nothing until +// it closes, though the browser starts or stops the frames at once: a pane +// opened on the dialog waited the whole pageWait and lost the browser (Chrome +// 151, macos-15-intel runner, measured 2026-10-28). step runs on after that. +func (p *chromePage) castPastDialog(step func() error) error { + done := make(chan error, 1) + go func() { + p.cast.Lock() + defer p.cast.Unlock() + done <- step() + }() + select { + case err := <-done: + return err + case <-p.gate.held(): + go func() { + if err := <-done; err != nil && p.Alive() { + slog.Warn("browser: start or stop the screencast under a dialog", "err", err) + } + }() + return nil + } } // Resize lays the page out at the pane's size, as a window of that size would. diff --git a/internal/browser/dialogs_test.go b/internal/browser/dialogs_test.go index 42912fff..b96c6082 100644 --- a/internal/browser/dialogs_test.go +++ b/internal/browser/dialogs_test.go @@ -214,3 +214,34 @@ func TestTheGateKeepsTheLastSizeAskedForWhileHeld(t *testing.T) { t.Fatalf("a second close handed back %+v", v) } } + +// A screencast step stands for Chrome 153 and older here, which answers it +// only once the dialog closes. +func TestAScreencastUnderADialogReturnsAndRunsInOrder(t *testing.T) { + var p chromePage + p.gate.open() + answered := make(chan struct{}) + var order []int + first := func() error { + <-answered + order = append(order, 1) + return nil + } + if err := p.castPastDialog(first); err != nil { + t.Fatal(err) + } + ran := make(chan struct{}) + second := func() error { + order = append(order, 2) + close(ran) + return nil + } + if err := p.castPastDialog(second); err != nil { + t.Fatal(err) + } + close(answered) + <-ran + if len(order) != 2 || order[0] != 1 { + t.Fatalf("steps ran as %v, want 1 then 2", order) + } +} diff --git a/internal/browser/zz_probe_live_test.go b/internal/browser/zz_probe_live_test.go deleted file mode 100644 index c1c0d2ef..00000000 --- a/internal/browser/zz_probe_live_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package browser - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/chromedp/cdproto/browser" - "github.com/chromedp/cdproto/cdp" - "github.com/chromedp/cdproto/page" - "github.com/chromedp/chromedp" - "github.com/lichdotdev/lich/api" -) - -// Temporary probe: why Page.stopScreencast hangs on a held page. -func TestProbeHeldScreencastSteps(t *testing.T) { - for _, idle := range []time.Duration{0, 2 * time.Second} { - t.Run(fmt.Sprint("idle", idle), func(t *testing.T) { probe(t, idle) }) - } -} - -func probe(t *testing.T, idle time.Duration) { - p, srv := liveChrome(t) - t0 := time.Now() - logf := func(f string, a ...any) { t.Logf("PROBE %6dms "+f, append([]any{time.Since(t0).Milliseconds()}, a...)...) } - opened := make(chan struct{}, 1) - p.Listen(func(string) {}, func(string, string) {}, func(string, api.BrowserDownload) {}, func(string) {}, func(d *api.BrowserDialog) { - if d != nil { - opened <- struct{}{} - } - }) - chromedp.ListenTarget(p.ctx, func(ev any) { logf("event %T", ev) }) - ctx := context.Background() - if err := p.Navigate(ctx, srv.URL); err != nil { - t.Fatal(err) - } - logf("navigated") - step := func(name string, d time.Duration, a chromedp.Action) { - c, cancel := context.WithTimeout(ctx, d) - defer cancel() - s := time.Now() - err := p.runFree(c, a) - logf("%s: err=%v in %s", name, err, time.Since(s)) - } - step("screenshot", 10*time.Second, chromedp.ActionFunc(func(ctx context.Context) error { - _, err := page.CaptureScreenshot().Do(ctx) - return err - })) - time.Sleep(idle) - step("evaluate", 10*time.Second, chromedp.Evaluate(`setTimeout(() => confirm('Delete?'), 0)`, nil)) - <-opened - logf("dialog open") - go step("stop", 40*time.Second, page.StopScreencast()) - time.Sleep(2 * time.Second) - step("history", 3*time.Second, chromedp.ActionFunc(func(ctx context.Context) error { - _, _, err := page.GetNavigationHistory().Do(ctx) - return err - })) - c := chromedp.FromContext(p.ctx) - step("version", 3*time.Second, chromedp.ActionFunc(func(ctx context.Context) error { - _, _, _, _, _, err := browser.GetVersion().Do(cdp.WithExecutor(ctx, c.Browser)) - return err - })) - step("answer", 5*time.Second, page.HandleJavaScriptDialog(true)) - time.Sleep(3 * time.Second) -} From b7dd5e0781f8cc3aed6285abfc5a7b83e5169cdb Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 17:28:15 -0300 Subject: [PATCH 4/5] fix(browser): queue screencast steps instead of locking in the goroutine --- internal/browser/chrome.go | 10 ++++++---- internal/browser/chrome_view.go | 10 ++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index 8b2c3e95..e3cf7f53 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -88,10 +88,12 @@ type chromePage struct { dl *downloads // gate is held while the page has a JavaScript dialog open. gate dialogGate - // cast orders the screencast's starts and stops, which can run on past - // their caller (castPastDialog); casting says one is running. - cast sync.Mutex - casting bool + // castMu guards castLast, the last screencast start or stop queued, which + // the next one waits for: they can run on past their caller + // (castPastDialog). casting says one is running. + castMu sync.Mutex + castLast chan struct{} + casting bool } // downloadFolder is downloadDir, which the live tests point at a folder of diff --git a/internal/browser/chrome_view.go b/internal/browser/chrome_view.go index 9ac5f1d9..0bf914b4 100644 --- a/internal/browser/chrome_view.go +++ b/internal/browser/chrome_view.go @@ -232,10 +232,16 @@ func (p *chromePage) stopCast(ctx context.Context) error { // opened on the dialog waited the whole pageWait and lost the browser (Chrome // 151, macos-15-intel runner, measured 2026-10-28). step runs on after that. func (p *chromePage) castPastDialog(step func() error) error { + p.castMu.Lock() + prev, mine := p.castLast, make(chan struct{}) + p.castLast = mine + p.castMu.Unlock() done := make(chan error, 1) go func() { - p.cast.Lock() - defer p.cast.Unlock() + defer close(mine) + if prev != nil { + <-prev + } done <- step() }() select { From 5af33e148d0ed732b0da63992789dfd10400998d Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 28 Oct 2026 18:21:35 -0300 Subject: [PATCH 5/5] ci: drop the temporary macOS measurement job --- .github/workflows/measure-tmp.yml | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 .github/workflows/measure-tmp.yml diff --git a/.github/workflows/measure-tmp.yml b/.github/workflows/measure-tmp.yml deleted file mode 100644 index 447a135f..00000000 --- a/.github/workflows/measure-tmp.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Temporary: measures the macOS held-dialog pane failure. Removed before merge. -name: measure-tmp -on: - pull_request: - paths: ['.github/workflows/measure-tmp.yml', 'internal/browser/**'] -jobs: - measure: - strategy: - fail-fast: false - matrix: - runner: [macos-15-intel, macos-latest] - suite: - - {name: pane, run: '^TestChromeAPaneOpenedOnADialogShowsThePage$', count: 50} - - {name: chrome, run: 'TestChrome', count: 5} - runs-on: ${{ matrix.runner }} - name: ${{ matrix.runner }} ${{ matrix.suite.name }} - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 - with: - go-version-file: go.mod - - name: Measure - run: | - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version || true - go test -v -timeout 80m -count=${{ matrix.suite.count }} -run '${{ matrix.suite.run }}' ./internal/browser/ > out.txt 2>&1; code=$? - echo "TALLY PASS=$(grep -c -- '^--- PASS' out.txt) FAIL=$(grep -c -- '^--- FAIL' out.txt)" - grep -- '--- FAIL\|_test.go:' out.txt || true - exit $code