Skip to content

Commit 659bb36

Browse files
authored
fix(web): keep expanded job output open across the UI refresh (#767)
Fixes #764. ## The bug The web UI refreshes every five seconds. With a job's history panel open, that refresh calls `loadHistory()`, which clears the table body and re-renders every row from the API response. The `<details>` element wrapping a run's stdout/stderr was always emitted without an `open` attribute, so the browser rebuilt it collapsed. Any output a user expanded therefore snapped shut within five seconds of opening it — long enough to start reading, not long enough to finish. Reproduced in headless Chrome against a running daemon: `details.open` goes `true` → `false` across one refresh cycle. Nothing to do with the reporter's reverse proxy; it is purely client-side. ## The fix `loadHistory()` now reads back which outputs are expanded before it replaces the table, and re-applies `open` to the matching rows. State is keyed by the execution's `date` (RFC3339Nano, already returned per run and unique) rather than by row position, so an expanded output also stays open when a newer run is prepended. Preservation is scoped to the job the table is showing. On a refresh that is the job being refreshed, which is the point; on a job switch the rows still belong to the previously selected job, and its keys must not decide what is expanded in another job's history (review finding, `afa7286`). | | before | after | |---|---|---| | `details.open` across one refresh | `false` | `true` | | same row after history grew 4 → 5 rows | — | still open | | expand under job A, switch to job B | — | all of B's rows collapsed | ## Regression guard `TestE2E_WebUI_ExpandedOutputSurvivesRefresh` boots the binary with `--enable-web`, expands an output in headless Chrome, outlasts a full refresh cycle and asserts it is still open. Verified red against the pre-fix UI and green with the fix. The bug lives in what the refresh does to *live DOM state*, which markup assertions cannot observe — hence a browser. `chromedp` keeps it inside the Go toolchain, so CI runs it unchanged through go-check's `go test -tags=e2e ./e2e/...`; a JavaScript suite would have needed a second workflow and a Node toolchain in a repo that has none. MIT-licensed and reachable only from the e2e-tagged package, so it is not linked into the shipped binary. It skips cleanly where no browser is installed, mirroring the existing `dockerAvailable` contract. Two follow-up commits harden that test after it failed on CI while passing locally — first because the run it expanded had aged out of the capped history, then because `details:last-of-type` matched the first output rather than the last. It now resolves the newest run in JS, clicks and inspects the same element, waits for a multi-row history so the two can never coincide, and reports a dropped row differently from a collapsed one. A text-level test on the embedded asset (`web/ui_history_state_test.go`) additionally fails fast if a future rewrite of `loadHistory()` drops the state preservation, without needing a browser. ## Also here: the pre-push smoke hook no longer hangs Unrelated to the UI bug, but it blocked pushing this branch, and the documented workaround was `git push --no-verify`. Four tests in `core/adapters/docker` construct a client against a deliberately unreachable address to assert something *other* than connectivity, and relied on the dial failing immediately. That only holds where the kernel answers a closed port with an RST. Where a firewall DROPs instead, the connect runs to its own timeout, so each blocked for the full 30s `NegotiateTimeout`: ``` TestNewClientWithConfig_DoesNotMutateConfigHost 30.01s TestNewClientWithConfig_NormalizesDockerHostEnv 30.00s TestNewClientWithConfig_TCPPlusTLSAcceptsCertMaterial 30.03s TestNewClientWithConfig_TCPPlusTLSAcceptsEnvCertPath 7.12s ``` The hook gives the whole binary 60s, so the package died with `panic: test timed out after 1m0s` — blocking pushes that touch no `core/` file at all. Each now sets `NegotiateTimeout` explicitly via a shared constant that documents why. Assertions are unchanged; only their runtime stops depending on how the network answers an unreachable port. The 30s production default and the tests covering it (#608) are untouched. Package runtime under the hook's budget: timeout panic → 4.8s. Full smoke command: hang → 33s. `lefthook run pre-push` exits 0, and every push on this branch ran with hooks enabled. `docs/feedback/lefthook-smoke-hook-docker-hang.md` and its AGENTS.md pointer are deleted rather than rewritten — the workaround they describe no longer applies. ## Note on the new dependency Dependency Review warns that `github.com/chromedp/sysutil` scores 2 on OpenSSF Scorecard against this repo's threshold of 3 (the check passes; the threshold warns rather than blocks). It arrives transitively via `chromedp → cdproto/cdp`, is 112 lines of platform boot-time helpers from the same org, and is reachable only from the e2e-tagged test package, so it is not linked into the released binary. `chromedp` itself scores 3.3 and `cdproto` 3.1. ## Test plan - [x] `go test ./...` — full suite green - [x] `go test -race -tags=e2e ./e2e/...` — all 14 e2e tests green, no skips, browser confirmed launched on CI - [x] `golangci-lint run` incl. `--build-tags="e2e unix"` — 0 issues - [x] `lefthook run pre-push` — exit 0 - [x] Browser test verified red-then-green against the pre-fix UI - [x] Job-switch scoping verified in a browser against a two-job daemon
2 parents e2ed904 + afa7286 commit 659bb36

12 files changed

Lines changed: 375 additions & 29 deletions

AGENTS.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ This file explains repo‑wide conventions and where to find scoped rules.
5858

5959
## Recurring friction notes
6060
- `./docs/feedback/golangci-lint-cache-cross-worktree.md` — run `golangci-lint cache clean` before pushing if you use multiple sibling worktrees; stale cache entries from siblings get replayed as findings and block the `pre-push` hook.
61-
- `./docs/feedback/lefthook-smoke-hook-docker-hang.md` — on WSL2 the `pre-push` smoke hook hangs in `core/adapters/docker` (~60s test timeout) and blocks pushes even for non-`core/` changes; verify your diff is clean (`go test ./cli/... ./middlewares/...`), then `git push --no-verify` and rely on CI. Never disable the docker tests.
6261

6362
## Repository hygiene
6463
- Manage dependencies exclusively with Go modules.

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **An expanded job output no longer collapses on its own.** The web UI refreshes every five seconds, and with a job's history panel open that refresh rebuilt the whole table from scratch. Every `<details>` element was re-created without its `open` attribute, so any output a user had expanded snapped shut within five seconds of opening it — long enough to start reading, not long enough to finish. The history table now records which outputs are expanded before it re-renders and restores them afterwards, keyed by the execution's timestamp rather than its row position, so an expanded output also stays open when a new run appears above it. Only the user collapses an output now ([#764](https://github.com/netresearch/ofelia/issues/764)).
13+
1014
## [0.28.1] - 2026-07-28
1115

1216
A documentation and tooling release. The one change that reaches a running

core/adapters/docker/client_mutation_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,14 +378,13 @@ func TestValidateAndNormalizeHost(t *testing.T) {
378378
//
379379
// Cannot use t.Parallel() — t.Setenv is incompatible with parallel subtests.
380380
func TestNewClientWithConfig_NormalizesDockerHostEnv(t *testing.T) {
381-
// 127.0.0.1:0 is unreachable (port 0); construction succeeds but no real
382-
// connection is attempted (API negotiation is best-effort and tolerates
383-
// failure at this layer for unit-test purposes).
381+
// 127.0.0.1:0 is unreachable (port 0); API negotiation is best-effort and
382+
// tolerates failure at this layer for unit-test purposes.
384383
t.Setenv("DOCKER_HOST", "TCP://127.0.0.1:0")
385384

386385
// We don't care if the actual connection fails — we care that construction
387386
// validates the scheme and doesn't reject a valid (if uppercase) TCP host.
388-
_, err := NewClientWithConfig(&ClientConfig{})
387+
_, err := NewClientWithConfig(&ClientConfig{NegotiateTimeout: unreachableNegotiateTimeout})
389388
if err != nil && errors.Is(err, ErrUnsupportedDockerHostScheme) {
390389
t.Fatalf("uppercase TCP:// scheme should be normalized, not rejected: %v", err)
391390
}
@@ -437,6 +436,7 @@ func TestNewClientWithConfig_DoesNotMutateConfigHost(t *testing.T) {
437436
const original = "TCP://127.0.0.1:0"
438437
cfg := DefaultConfig()
439438
cfg.Host = original
439+
cfg.NegotiateTimeout = unreachableNegotiateTimeout
440440

441441
// We don't care whether the dial succeeds (it won't on port 0) - only
442442
// that the config struct is unchanged when control returns.

core/adapters/docker/client_negotiate_timeout_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ import (
1212
"time"
1313
)
1414

15+
// unreachableNegotiateTimeout bounds the eager API version negotiation in tests
16+
// that construct a client against a deliberately unreachable address and assert
17+
// something other than connectivity (scheme normalization, the TLS-material
18+
// gate, config immutability).
19+
//
20+
// Those tests used to leave NegotiateTimeout at its 30s default and rely on the
21+
// dial failing immediately, which holds only where the kernel answers a closed
22+
// port with RST. On a host whose firewall DROPs instead of rejecting - the
23+
// packets are silently discarded, so the connect runs to its own timeout -
24+
// each such construction blocked for the full 30s and the package blew the
25+
// 60s budget of the lefthook pre-push smoke hook. Bounding negotiation here
26+
// keeps the assertions intact and makes their runtime independent of how the
27+
// network answers an unreachable port.
28+
const unreachableNegotiateTimeout = 50 * time.Millisecond
29+
1530
// TestNewClientWithConfig_NegotiateAPIVersionTimeout verifies that NewClientWithConfig
1631
// returns within a bounded time when the Docker daemon is reachable but does not
1732
// respond to API version negotiation (e.g. a wedged socket proxy whose upstream is hung).

core/adapters/docker/client_tcptls_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ func TestNewClientWithConfig_TCPPlusTLSAcceptsCertMaterial(t *testing.T) {
6464
certPath := writeFakeTLSMaterial(t)
6565

6666
_, err := NewClientWithConfig(&ClientConfig{
67-
Host: "tcp+tls://127.0.0.1:0",
68-
TLSCertPath: certPath,
67+
Host: "tcp+tls://127.0.0.1:0",
68+
TLSCertPath: certPath,
69+
NegotiateTimeout: unreachableNegotiateTimeout,
6970
})
7071
if errors.Is(err, ErrTCPTLSRequiresCertMaterial) {
7172
t.Fatalf("cert material was provided via ClientConfig.TLSCertPath, must not return ErrTCPTLSRequiresCertMaterial: %v", err)
@@ -84,7 +85,10 @@ func TestNewClientWithConfig_TCPPlusTLSAcceptsEnvCertPath(t *testing.T) {
8485
certPath := writeFakeTLSMaterial(t)
8586
t.Setenv("DOCKER_CERT_PATH", certPath)
8687

87-
_, err := NewClientWithConfig(&ClientConfig{Host: "tcp+tls://127.0.0.1:0"})
88+
_, err := NewClientWithConfig(&ClientConfig{
89+
Host: "tcp+tls://127.0.0.1:0",
90+
NegotiateTimeout: unreachableNegotiateTimeout,
91+
})
8892
if errors.Is(err, ErrTCPTLSRequiresCertMaterial) {
8993
t.Fatalf("DOCKER_CERT_PATH was set, must not return ErrTCPTLSRequiresCertMaterial: %v", err)
9094
}

docs/feedback/lefthook-smoke-hook-docker-hang.md

Lines changed: 0 additions & 20 deletions
This file was deleted.

e2e/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ go test -tags=e2e -race -v -timeout=10m ./e2e/...
3232
- Go toolchain matching `go.mod`
3333
- Docker daemon (Docker tests skip automatically when unavailable)
3434
- `alpine:3.20` image is pulled on demand by the Docker tests
35+
- Chrome or Chromium for the web-UI test (skips automatically when absent).
36+
Any of `google-chrome`, `google-chrome-stable`, `chromium` or
37+
`chromium-browser` on `PATH` is used; set `OFELIA_E2E_CHROME=/path/to/chrome`
38+
to point at a browser that is not on `PATH`.
3539

3640
## What is covered
3741

@@ -49,6 +53,13 @@ go test -tags=e2e -race -v -timeout=10m ./e2e/...
4953
- `TestE2E_DockerRunJob_FailingContainerMarkedFailed` — non-zero container
5054
exit is surfaced as `failed: true` in Ofelia's log.
5155

56+
### Web UI (real browser)
57+
- `TestE2E_WebUI_ExpandedOutputSurvivesRefresh` — drives the served UI in
58+
headless Chrome: expand a run's output, outlast the 5s auto-refresh, assert
59+
it is still open. Regression guard for
60+
[#764](https://github.com/netresearch/ofelia/issues/764), where the refresh
61+
re-rendered the history table and collapsed it.
62+
5263
### Configuration surface
5364
- `TestE2E_Validate_MalformedINI` — malformed INI produces a useful error.
5465
- `TestE2E_Validate_MissingConfigFile` — missing file path is reported.

e2e/web_ui_history_test.go

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
//go:build e2e && unix
2+
// +build e2e,unix
3+
4+
// Copyright (c) 2025-2026 Netresearch DTT GmbH
5+
// SPDX-License-Identifier: MIT
6+
7+
package e2e
8+
9+
import (
10+
"context"
11+
"fmt"
12+
"net"
13+
"os"
14+
"os/exec"
15+
"testing"
16+
"time"
17+
18+
"github.com/chromedp/chromedp"
19+
)
20+
21+
// uiRefreshInterval mirrors the `setInterval(refresh, 5000)` in
22+
// static/ui/index.html. The test has to outlast one full cycle to prove the
23+
// refresh does not clobber user state, so it is stated once here rather than
24+
// buried as a magic number in the waits below.
25+
const uiRefreshInterval = 5 * time.Second
26+
27+
// noKeyMarker is what the expand step reports when the expanded <details>
28+
// carries no data-key — i.e. a build without the fix. It has to be
29+
// distinguishable from "no element at all", because those two mean very
30+
// different things about why the test could not find its row afterwards.
31+
const noKeyMarker = "NO_KEY"
32+
33+
// TestE2E_WebUI_ExpandedOutputSurvivesRefresh drives the real web UI in a real
34+
// browser and pins the fix for https://github.com/netresearch/ofelia/issues/764.
35+
//
36+
// The UI polls the history endpoint every 5s and re-renders the table by
37+
// replacing the tbody's innerHTML. Before the fix the `<details>` element that
38+
// wraps a run's output was re-created without its `open` attribute, so an
39+
// output the user had expanded collapsed on its own within five seconds.
40+
//
41+
// This is deliberately a browser test and not an assertion on the served HTML:
42+
// the bug lives in what the refresh does to DOM state after the user has
43+
// interacted with it, which no amount of markup inspection can observe.
44+
func TestE2E_WebUI_ExpandedOutputSurvivesRefresh(t *testing.T) {
45+
t.Parallel()
46+
47+
browserPath := chromeExecutable()
48+
if browserPath == "" {
49+
t.Skip("no Chrome/Chromium executable found; skipping browser-driven UI test")
50+
}
51+
52+
addr := reserveLoopbackAddr(t)
53+
54+
// Cadence is a deliberate trade-off against history eviction. A job keeps
55+
// the last `HistoryLimit` runs (default 10) and drops the OLDEST first, so
56+
// at @every 1s an entry is evicted 10s after it appears - faster than this
57+
// test's wait on a loaded runner, which would make a legitimately dropped
58+
// row look like the collapse bug. At 2s the entry we expand needs ~20s to
59+
// reach the cut, while new runs still arrive during the wait so the
60+
// re-render is genuinely exercised.
61+
configBody := `[global]
62+
log-level = info
63+
64+
[job-local "e2e-web-output"]
65+
schedule = @every 2s
66+
command = sh -c "echo OFELIA_E2E_WEB_OUTPUT"
67+
`
68+
configPath := writeConfig(t, configBody)
69+
daemon := startDaemon(t, configPath, "--enable-web", "--web-address="+addr)
70+
t.Cleanup(func() { daemon.shutdown(t, 15*time.Second) })
71+
72+
// Wait until the job has actually run, otherwise the history table renders
73+
// the "No history yet." placeholder and there is nothing to expand.
74+
if err := daemon.waitForLog(`Job \"e2e-web-output\"`, 15*time.Second); err != nil {
75+
t.Fatalf("job did not run before the UI check: %v\nstdout=%s", err, daemon.stdout.String())
76+
}
77+
78+
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(),
79+
append(chromedp.DefaultExecAllocatorOptions[:],
80+
chromedp.ExecPath(browserPath),
81+
)...)
82+
t.Cleanup(cancelAlloc)
83+
84+
// The budget covers browser startup plus the refresh cycle the test waits
85+
// out; without it a wedged browser would hang until the package timeout.
86+
browserCtx, cancelBrowser := chromedp.NewContext(allocCtx)
87+
t.Cleanup(cancelBrowser)
88+
ctx, cancelTimeout := context.WithTimeout(browserCtx, 90*time.Second)
89+
t.Cleanup(cancelTimeout)
90+
91+
const (
92+
jobRow = `#jobs tbody tr`
93+
historyDetails = `#history tbody details`
94+
historySummary = `#history tbody details summary`
95+
)
96+
97+
// The history table renders oldest run first, so the LAST output is the
98+
// newest one - and the newest is the furthest from eviction. Expanding the
99+
// first would pick the entry that is about to be dropped.
100+
//
101+
// "Last" is resolved in JS over the full node list rather than with a CSS
102+
// positional selector: each <details> sits alone in its own <td>, so
103+
// `details:last-of-type` matches every one of them and querySelector then
104+
// returns the FIRST. That mismatch - click the newest row, inspect the
105+
// oldest - is exactly what broke this test once already.
106+
//
107+
// Locating and clicking happen in one expression so no refresh can slip
108+
// between them and re-render the node under us. The trade-off is a
109+
// synthetic click instead of a CDP input event; on a <summary> that still
110+
// runs the browser's native toggle, which is the behavior under test.
111+
const expandNewest = `(() => {
112+
const all = document.querySelectorAll('` + historyDetails + `');
113+
const d = all[all.length - 1];
114+
if (!d) return '';
115+
d.querySelector('summary').click();
116+
return d.dataset.key || '` + noKeyMarker + `';
117+
})()`
118+
const newestIsOpen = `(() => {
119+
const all = document.querySelectorAll('` + historyDetails + `');
120+
const d = all[all.length - 1];
121+
return d ? d.open === true : false;
122+
})()`
123+
124+
var openAfterClick, openAfterRefresh, stillPresent bool
125+
var keyAfterClick, keyAfterRefresh string
126+
127+
err := chromedp.Run(ctx,
128+
chromedp.Navigate("http://"+addr+"/"),
129+
130+
// Selecting the job opens the history panel and loads its runs.
131+
chromedp.WaitVisible(jobRow, chromedp.ByQuery),
132+
chromedp.Click(jobRow, chromedp.ByQuery),
133+
chromedp.WaitVisible(historySummary, chromedp.ByQuery),
134+
135+
// Wait for several runs before interacting. A single-row history would
136+
// make "oldest" and "newest" the same element and hide selector bugs
137+
// locally that only surface on a slower runner.
138+
chromedp.Poll(`document.querySelectorAll('`+historyDetails+`').length >= 3`,
139+
nil, chromedp.WithPollingTimeout(30*time.Second)),
140+
141+
// Expand the newest run's output and note which execution it is, so we
142+
// can find the same one after the refresh.
143+
chromedp.Evaluate(expandNewest, &keyAfterClick),
144+
chromedp.Evaluate(newestIsOpen, &openAfterClick),
145+
)
146+
if err != nil {
147+
t.Fatalf("driving the web UI failed: %v", err)
148+
}
149+
if keyAfterClick == "" {
150+
t.Fatalf("no run with output found in the history table; nothing to expand")
151+
}
152+
if !openAfterClick {
153+
t.Fatalf("output did not expand on click; the test cannot observe the refresh behavior")
154+
}
155+
156+
// Outlast a full refresh cycle. The daemon keeps firing the job, so this
157+
// also covers the harder case: new runs arrive and the expanded row is no
158+
// longer the newest one.
159+
//
160+
// Presence is read separately from openness. Without that split, a row that
161+
// legitimately aged out of the capped history is indistinguishable from one
162+
// the refresh collapsed - the two need different fixes, and conflating them
163+
// once already sent this test chasing the wrong bug.
164+
err = chromedp.Run(ctx,
165+
chromedp.Sleep(uiRefreshInterval+2*time.Second),
166+
chromedp.Evaluate(selectorForKey(keyAfterClick, historyDetails)+` !== null`, &stillPresent),
167+
chromedp.Evaluate(selectorForKey(keyAfterClick, historyDetails)+`?.open === true`, &openAfterRefresh),
168+
chromedp.Evaluate(`(() => {
169+
const all = document.querySelectorAll('`+historyDetails+`');
170+
const d = all[all.length - 1];
171+
return d ? (d.dataset.key || '') : '';
172+
})()`, &keyAfterRefresh),
173+
)
174+
if err != nil {
175+
t.Fatalf("re-reading the expanded output after a refresh failed: %v", err)
176+
}
177+
178+
if !stillPresent {
179+
t.Fatalf("execution %q dropped out of the history table within %s, so the test could not "+
180+
"observe the refresh behavior — the job cadence is outrunning the history limit, "+
181+
"not a regression of issue #764", keyAfterClick, uiRefreshInterval+2*time.Second)
182+
}
183+
184+
if !openAfterRefresh {
185+
t.Fatalf("expanded job output collapsed on its own across the %s refresh cycle "+
186+
"(execution %q is still listed, just no longer open); regression of issue #764",
187+
uiRefreshInterval, keyAfterClick)
188+
}
189+
190+
// Not an assertion, just context: whether the row we kept open was still
191+
// the newest one tells us which variant was exercised.
192+
if keyAfterRefresh != keyAfterClick {
193+
t.Logf("history advanced during the test (newest run is now %q, kept %q open) — "+
194+
"the expanded output survived a re-render that also reordered rows",
195+
keyAfterRefresh, keyAfterClick)
196+
}
197+
}
198+
199+
// selectorForKey builds a JS expression that finds the <details> belonging to
200+
// one specific execution.
201+
//
202+
// A build without the fix emits no data-key at all (noKeyMarker), so there is
203+
// nothing to look the row up by. It falls back to the last output — the same
204+
// one that was expanded — so the failure is reported as "collapsed" rather
205+
// than as a row that vanished from the history.
206+
func selectorForKey(key, fallbackSelector string) string {
207+
if key == noKeyMarker {
208+
return `(() => {
209+
const all = document.querySelectorAll('` + fallbackSelector + `');
210+
return all[all.length - 1] || null;
211+
})()`
212+
}
213+
return fmt.Sprintf(`document.querySelector('#history tbody details[data-key="%s"]')`, key)
214+
}
215+
216+
// reserveLoopbackAddr asks the kernel for a free loopback port and returns it
217+
// as host:port. The listener is closed before returning, so there is a small
218+
// race window — acceptable here, and far better than a hardcoded port that
219+
// would make parallel e2e runs collide.
220+
func reserveLoopbackAddr(t *testing.T) string {
221+
t.Helper()
222+
ln, err := net.Listen("tcp", "127.0.0.1:0")
223+
if err != nil {
224+
t.Fatalf("reserve loopback port: %v", err)
225+
}
226+
addr := ln.Addr().String()
227+
if err := ln.Close(); err != nil {
228+
t.Fatalf("close port reservation: %v", err)
229+
}
230+
return addr
231+
}
232+
233+
// chromeExecutable returns the path to a usable Chrome/Chromium binary, or ""
234+
// when none is installed. Mirrors dockerAvailable's skip-cleanly contract: a
235+
// developer machine without a browser should not fail the suite, while CI
236+
// runners (ubuntu-latest ships Chrome) exercise the test for real.
237+
//
238+
// OFELIA_E2E_CHROME overrides the search for developers whose browser is not on
239+
// PATH — e.g. one managed by a browser-automation toolchain in a cache dir.
240+
func chromeExecutable() string {
241+
if fromEnv := os.Getenv("OFELIA_E2E_CHROME"); fromEnv != "" {
242+
if _, err := os.Stat(fromEnv); err == nil {
243+
return fromEnv
244+
}
245+
}
246+
for _, candidate := range []string{
247+
"google-chrome",
248+
"google-chrome-stable",
249+
"chromium",
250+
"chromium-browser",
251+
} {
252+
if path, err := exec.LookPath(candidate); err == nil {
253+
return path
254+
}
255+
}
256+
return ""
257+
}

0 commit comments

Comments
 (0)