-
-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathbiloba.go
More file actions
1405 lines (1234 loc) · 57.6 KB
/
Copy pathbiloba.go
File metadata and controls
1405 lines (1234 loc) · 57.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Biloba builds on top of [chromedp] to bring stable, performant, automated browser testing to Ginkgo. It embraces three principles:
- Performance via parallelization
- Stability via pragmatism
- Conciseness via Ginkgo and Gomega
The godoc documentation you are reading now is meant to be a sparse reference. To build a mental model for how to use Biloba please peruse the [documentation].
[chromedp]: https://github.com/chromedp/chromedp/
[documentation]: https://onsi.github.io/biloba
*/
package biloba
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
_ "embed"
"github.com/jehiah/agentdetection"
"github.com/chromedp/cdproto/browser"
"github.com/chromedp/cdproto/cdp"
"github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/fetch"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/target"
"github.com/chromedp/chromedp"
)
const BILOBA_VERSION = "0.15.1"
// minimumSupportedChromeMajor is the oldest Chrome major version Biloba's behavior is known to
// assume. Biloba tracks (and CI continuously validates against) the latest stable Chrome, so this
// is a conservative *floor*, not a ceiling: we warn when the connected browser is older than this,
// but never when it is newer. A newer Chrome breaking Biloba is our bug to fix, not the user's to
// work around - and warning on "newer than tested" would fire constantly given Chrome's ~4-week
// cadence. Bump this only if a future change relies on a browser feature older Chromes lack.
const minimumSupportedChromeMajor = 120
// chromeMajorVersion parses the major version out of a Browser.getVersion product string such as
// "HeadlessChrome/150.0.7871.24" or "Chrome/150.0.7871.24". Returns 0 when it can't (so callers
// treat an unparseable version as "don't warn" rather than guessing).
func chromeMajorVersion(product string) int {
slash := strings.LastIndex(product, "/")
if slash < 0 || slash == len(product)-1 {
return 0
}
version := product[slash+1:]
if dot := strings.Index(version, "."); dot >= 0 {
version = version[:dot]
}
major, err := strconv.Atoi(version)
if err != nil {
return 0
}
return major
}
// warnIfChromeUnsupported reads the connected browser's version and, if it is older than
// minimumSupportedChromeMajor, prints a one-time warning with upgrade instructions. It is
// best-effort: any probe/parse failure is silently ignored so a version check never breaks startup.
func warnIfChromeUnsupported(ginkgoT GinkgoTInterface, browserCtx context.Context, highFidelity bool) {
var product string
err := chromedp.Run(browserCtx, chromedp.ActionFunc(func(ctx context.Context) error {
_, p, _, _, _, err := browser.GetVersion().Do(ctx)
product = p
return err
}))
if err != nil {
return
}
major := chromeMajorVersion(product)
if major == 0 || major >= minimumSupportedChromeMajor {
return
}
upgrade := "npx @puppeteer/browsers install chrome-headless-shell@stable"
if highFidelity {
upgrade = "update the google-chrome on your PATH to the latest stable release"
}
ginkgoT.Printf("Biloba: detected Chrome %d, which is older than the minimum supported version (%d).\n"+
" Biloba tracks the latest stable Chrome; older versions may behave unexpectedly.\n"+
" Upgrade with: %s\n"+
" (or unset BILOBA_CHROME_HEADLESS_SHELL / clear a stale cached version, then retry).\n",
major, minimumSupportedChromeMajor, upgrade)
}
/*
GinkgoTInterface is the interface by which Biloba receives GinkgoT()
*/
type GinkgoTInterface interface {
Name() string
Helper()
Fatal(args ...any)
Fatalf(format string, args ...any)
TempDir() string
Logf(format string, args ...any)
Failed() bool
GinkgoRecover()
DeferCleanup(args ...any)
Print(args ...any)
Printf(format string, args ...any)
Println(a ...any)
F(format string, args ...any) string
Fi(indentation uint, format string, args ...any) string
Fiw(indentation uint, maxWidth uint, format string, args ...any) string
AddReportEntryVisibilityFailureOrVerbose(name string, args ...any)
ParallelProcess() int
ParallelTotal() int
AttachProgressReporter(func() string) func()
RenderTimeline() string
}
/*
ChromeConnection captures the details necessary for [ConnectToChrome] to connect to Chrome
*/
type ChromeConnection struct {
WebSocketURL string
WindowWidth int
WindowHeight int
// HighFidelity is true when Chrome was spun up in full ("new") headless mode. SpinUpChrome
// sets it and ConnectToChrome reads it to decide whether the new-headless viewport workaround
// is needed.
HighFidelity bool
}
func (gc ChromeConnection) encode() []byte {
data, _ := json.Marshal(gc)
return data
}
/*
SpinUpOption configures how [SpinUpChrome] launches Chrome. See [HighFidelityHeadless], [AutoInstallHeadlessShell], [HeadlessShellPath], [StartingWindowSize], and [ChromeFlags].
*/
type SpinUpOption func(*spinUpConfig)
type spinUpConfig struct {
execAllocatorOptions []chromedp.ExecAllocatorOption
highFidelity bool
autoInstall bool
headlessShellPath string
}
/*
HighFidelityHeadless opts out of Biloba's default lightweight chrome-headless-shell and runs the full ("new") headless Chrome - the real browser - instead.
By default Biloba favors pragmatism over realism: it drives chrome-headless-shell, the lightweight //content-based headless build, which is dramatically faster and parallelizes across processes. Pass HighFidelityHeadless to [SpinUpChrome] when you need the realism of the full browser (precise compositing/rendering, extensions, etc.) and are willing to pay for it in speed.
Read https://onsi.github.io/biloba/#headless-fidelity-chrome-headless-shell-by-default to learn more
*/
func HighFidelityHeadless() SpinUpOption {
return func(c *spinUpConfig) { c.highFidelity = true }
}
/*
AutoInstallHeadlessShell tells [SpinUpChrome] to download chrome-headless-shell (via Chrome for Testing) into Biloba's cache if it cannot be found locally, instead of failing with installation instructions. Biloba never downloads anything by default; opt in to auto-install for zero-config setups such as ephemeral CI. Has no effect under [HighFidelityHeadless].
Read https://onsi.github.io/biloba/#headless-fidelity-chrome-headless-shell-by-default to learn more
*/
func AutoInstallHeadlessShell() SpinUpOption {
return func(c *spinUpConfig) { c.autoInstall = true }
}
/*
HeadlessShellPath explicitly points Biloba at a chrome-headless-shell binary, bypassing the search. You can also set the BILOBA_CHROME_HEADLESS_SHELL environment variable.
Read https://onsi.github.io/biloba/#headless-fidelity-chrome-headless-shell-by-default to learn more
*/
func HeadlessShellPath(path string) SpinUpOption {
return func(c *spinUpConfig) { c.headlessShellPath = path }
}
/*
StartingWindowSize sets the default window size for all tabs. Pass it to [SpinUpChrome].
*/
func StartingWindowSize(width int, height int) SpinUpOption {
return func(c *spinUpConfig) {
c.execAllocatorOptions = append(c.execAllocatorOptions, chromedp.WindowSize(width, height))
}
}
/*
ChromeFlags passes raw [chromedp.ExecAllocatorOption] flags through to the Chrome process launched by [SpinUpChrome]:
biloba.SpinUpChrome(GinkgoT(), biloba.ChromeFlags(chromedp.Flag("lang", "es")))
Read https://onsi.github.io/biloba/#configuration to learn more
*/
func ChromeFlags(options ...chromedp.ExecAllocatorOption) SpinUpOption {
return func(c *spinUpConfig) {
c.execAllocatorOptions = append(c.execAllocatorOptions, options...)
}
}
// emulateViewportMatchingScreen is a chromedp.EmulateViewportOption that, in addition to the layout
// viewport EmulateViewport already sets, overrides the emulated *screen* dimensions to match. Full
// ("new") headless Chrome composites into a small virtual screen (default 800x600) regardless of the
// requested window size, and the compositor's trusted-input surface is clamped to that screen - so a
// plain SetDeviceMetricsOverride grows the layout viewport while leaving real wheel/scroll input to
// be silently dropped below the screen's bottom edge. Growing the emulated screen to the viewport
// size lifts that clamp, keeping the layout viewport and the real input surface in agreement.
func emulateViewportMatchingScreen(p1 *emulation.SetDeviceMetricsOverrideParams, _ *emulation.SetTouchEmulationEnabledParams) {
p1.ScreenWidth = p1.Width
p1.ScreenHeight = p1.Height
}
// applyHighFidelityViewport (re)asserts the high-fidelity viewport emulation for this tab. Full
// ("new") headless renders into a small virtual screen (default 800x600) regardless of the requested
// --window-size, so an un-emulated tab reports window.innerHeight well below the window height. We
// EmulateViewport to the requested window dimensions (captured by SpinUpChrome) to give the tab the
// correct layout viewport, growing the emulated *screen* to match (emulateViewportMatchingScreen) so
// the compositor's real trusted-input surface extends to the full viewport - otherwise CDP
// wheel/scroll input is silently dropped below the small screen's bottom edge, making measurePoint's
// inViewport check lie to realistic-mode interactions. The compositor surface is (re)sized from the
// device metrics in effect at page commit, so this must be re-applied after each navigation, not just
// once at connect time. It is a no-op in the default chrome-headless-shell lane, which has no such
// clamp (and leaves WindowWidth/Height at 0).
func (b *Biloba) applyHighFidelityViewport() error {
if !b.ChromeConnection.HighFidelity || b.ChromeConnection.WindowWidth <= 0 || b.ChromeConnection.WindowHeight <= 0 {
return nil
}
return retryTransientCDP(func() error {
return chromedp.Run(b.Context, chromedp.EmulateViewport(
int64(b.ChromeConnection.WindowWidth),
int64(b.ChromeConnection.WindowHeight),
emulateViewportMatchingScreen,
))
})
}
// reassertViewportForCompositor re-applies the viewport emulation at this tab's *current* size after a
// navigation. In high-fidelity mode the override itself survives a navigation (window.innerHeight
// stays put), but the compositor's trusted-input surface is only (re)sized from the device metrics in
// effect at page commit - so without re-asserting, real wheel/scroll input is silently dropped below
// the small virtual screen even though the layout viewport says the point is in view. We re-apply at
// the current inner size (not the connect-time default) so a SetWindowSize done earlier in the spec is
// preserved. A no-op in the default chrome-headless-shell lane.
func (b *Biloba) reassertViewportForCompositor() {
if !b.ChromeConnection.HighFidelity || b.ChromeConnection.WindowWidth <= 0 || b.ChromeConnection.WindowHeight <= 0 {
return
}
var dims []int64
if err := chromedp.Run(b.Context, chromedp.Evaluate("[window.innerWidth, window.innerHeight]", &dims)); err != nil || len(dims) != 2 || dims[0] <= 0 || dims[1] <= 0 {
return
}
_ = chromedp.Run(b.Context, chromedp.EmulateViewport(dims[0], dims[1], emulateViewportMatchingScreen))
}
// applyFocusEmulation makes this tab behave as though its page always holds the system focus. An
// automated, headless Chrome window never actually has OS focus, and full ("new") headless Chrome - the
// high-fidelity lane, and what Chrome is steadily moving the default toward - gates focus/blur *event*
// dispatch on the page being focused: element.focus() still sets document.activeElement, but the focus
// and blur events never fire, so onBlur commit/validate handlers (the entire reason Blur exists) silently
// never run. setFocusEmulationEnabled removes that gate. It is a per-target setting, so every tab (root
// and spawned) must opt in; it is harmless in the chrome-headless-shell lane, which already treats its
// page as focused.
func (b *Biloba) applyFocusEmulation() error {
return retryTransientCDP(func() error {
return chromedp.Run(b.Context, emulation.SetFocusEmulationEnabled(true))
})
}
func gooseConfigPath(process int) string {
return fmt.Sprintf("./.biloba-config-%d", process)
}
/*
Call SpinUpChrome(GinkgoT()) to spin up a Chrome browser
Read https://onsi.github.io/biloba/#bootstrapping-biloba for details on how to set up your Ginkgo suite and use SpinUpChrome correctly
*/
func SpinUpChrome(ginkgoT GinkgoTInterface, options ...SpinUpOption) ChromeConnection {
ginkgoT.Helper()
cfg := &spinUpConfig{}
for _, option := range options {
option(cfg)
}
// BILOBA_INTERACTIVE runs a real, visible browser, which is inherently high fidelity
// (chrome-headless-shell cannot run headful).
interactive := os.Getenv("BILOBA_INTERACTIVE") != ""
if interactive {
cfg.highFidelity = true
}
tmp := ginkgoT.TempDir()
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.WindowSize(1024, 768),
chromedp.UserDataDir(tmp),
// chromedp gives Chrome 20s to print its DevTools websocket URL before giving up with
// "websocket url timeout reached". A cold full ("new") headless google-chrome on a loaded
// CI runner intermittently needs longer than that to come up, which flaked the high-fidelity
// lane at suite bring-up. The lightweight chrome-headless-shell starts well within 20s, so a
// roomier ceiling only ever buys slow launches headroom - it never slows a fast one.
chromedp.WSURLReadTimeout(60*time.Second),
)
opts = append(opts, cfg.execAllocatorOptions...)
if interactive {
opts = append(opts, chromedp.Flag("headless", false))
}
if !cfg.highFidelity {
// Default (pragmatic) mode: drive the lightweight chrome-headless-shell.
shellPath, err := resolveHeadlessShellPath(ginkgoT, cfg)
if err != nil {
ginkgoT.Fatalf("%s", err.Error())
return ChromeConnection{}
}
opts = append(opts, chromedp.ExecPath(shellPath))
}
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
ginkgoT.DeferCleanup(cancel)
browserCtx, cancel := chromedp.NewContext(allocCtx)
ginkgoT.DeferCleanup(cancel)
cc := ChromeConnection{HighFidelity: cfg.highFidelity}
if cfg.highFidelity {
// Full ("new") headless renders into a small virtual screen (default 800x600) regardless of
// --window-size, so an un-emulated tab reports window.innerHeight well below the requested
// height. We capture the outer (requested) window dimensions here and have ConnectToChrome
// EmulateViewport each tab back up to that size (see applyHighFidelityViewport).
// chrome-headless-shell has no such virtual-screen clamp, so this probe and the EmulateViewport
// workaround are skipped in the default mode.
var outerDims []int
if err := chromedp.Run(browserCtx, chromedp.Evaluate("[window.outerWidth, window.outerHeight]", &outerDims)); err != nil {
ginkgoT.Fatalf("failed to spin up chrome: %w", err)
return ChromeConnection{}
}
if len(outerDims) == 2 {
cc.WindowWidth = outerDims[0]
cc.WindowHeight = outerDims[1]
}
} else if err := chromedp.Run(browserCtx, chromedp.Evaluate("1", nil)); err != nil {
ginkgoT.Fatalf("failed to spin up chrome: %w", err)
return ChromeConnection{}
}
// We're connected; warn (once, here on the spin-up process) if this Chrome is too old.
warnIfChromeUnsupported(ginkgoT, browserCtx, cfg.highFidelity)
bs, err := os.ReadFile(filepath.Join(tmp, "DevToolsActivePort"))
if err != nil {
ginkgoT.Fatalf("failed to spin up chrome: %w", err)
return ChromeConnection{}
}
components := strings.Split(string(bs), "\n")
cc.WebSocketURL = fmt.Sprintf("ws://127.0.0.1:%s%s", components[0], components[1])
os.WriteFile(gooseConfigPath(ginkgoT.ParallelProcess()), cc.encode(), 0744)
ginkgoT.DeferCleanup(func() error {
chromedp.Cancel(browserCtx)
// The config file is a throwaway; if something already removed it, that's success, not a
// teardown failure (a missing file here has shown up intermittently under `ginkgo --repeat`).
if err := os.Remove(gooseConfigPath(ginkgoT.ParallelProcess())); err != nil && !os.IsNotExist(err) {
return err
}
return nil
})
return cc
}
/*
BilobaConfigOptions are passed in to [ConnectToChrome] to configure a given connection to Chrome
*/
type BilobaConfigOption func(*Biloba)
// defaultAutomationScreenshotsDir is where failure screenshots are written, by default, when
// Biloba detects it's running under automation (CI or an AI agent) and neither the suite nor
// BILOBA_SCREENSHOTS_DIR specified a directory. It is workspace-relative so CI can collect it
// (e.g. via actions/upload-artifact).
const defaultAutomationScreenshotsDir = "./biloba-screenshots"
// automationDetected reports whether Biloba is running in a non-interactive context - CI or under
// an AI coding agent - in which case the failure-artifact defaults flip to text-friendly output.
// It is a package var so the suite can pin it for deterministic tests (see export_test.go).
var automationDetected = func() bool {
return os.Getenv("CI") != "" || agentdetection.IsAgent()
}
// boolArg resolves the variadic argument the boolean BilobaConfig options take: passing no
// argument means true (BilobaConfigFailureOutlines() enables outlines), while an explicit value
// is honored as-is (BilobaConfigFailureOutlines(false) disables them).
func boolArg(args []bool) bool {
if len(args) > 0 {
return args[0]
}
return true
}
/*
Pass BilobaConfigDebugLogging to [ConnectToChrome] to send all Chrome debug logging to the GinkgoWriter.
Like all the boolean BilobaConfig options it takes an optional bool: BilobaConfigDebugLogging() turns it on, BilobaConfigDebugLogging(false) turns it off.
*/
func BilobaConfigDebugLogging(enabled ...bool) func(*Biloba) {
return func(b *Biloba) {
b.debugLogging = boolArg(enabled)
}
}
/*
Pass BilobaConfigWithChromeConnection to [ConnectToChrome] to provide your own [ChromeConnection] details
*/
func BilobaConfigWithChromeConnection(cc ChromeConnection) func(*Biloba) {
return func(b *Biloba) {
b.ChromeConnection = cc
}
}
/*
Pass BilobaConfigFailureScreenshots to [ConnectToChrome] to control whether Biloba captures a screenshot of every tab on failure.
It is on by default; BilobaConfigFailureScreenshots(false) turns it off.
*/
func BilobaConfigFailureScreenshots(enabled ...bool) func(*Biloba) {
return func(b *Biloba) {
b.failureScreenshots = boolArg(enabled)
}
}
/*
Pass BilobaConfigFailureOutlines to [ConnectToChrome] to control whether Biloba attaches a DOM outline of every tab on failure.
When a human is driving, outlines are off by default (the screenshot is the more useful artifact); under automation (CI or an AI agent) Biloba turns them on automatically. Set this explicitly to override that default in either direction: BilobaConfigFailureOutlines() forces them on for an interactive run, BilobaConfigFailureOutlines(false) forces them off under automation.
See https://onsi.github.io/biloba/#failure-artifacts-humans-ci-and-agents for how the human/automation defaults are resolved.
*/
func BilobaConfigFailureOutlines(enabled ...bool) func(*Biloba) {
return func(b *Biloba) {
b.failureOutlines = boolArg(enabled)
b.failureOutlinesSet = true
}
}
/*
Pass BilobaConfigPollTrajectory to [ConnectToChrome] to control whether Biloba records the (elapsed, value) trajectory of polled reads and attaches the most-recent series to the failure block.
When an Eventually(...) over a polled read times out, the trajectory is the diagnosis: a flat line means the product computed a value once and never reconciled (a product bug, not a short timeout); a monotone approach means latency (it nearly made it); a dip-then-rebound means a late reflow shoved it back. Biloba records the trajectory of the most-recently-polled entity (a [Biloba.Run]/[Biloba.RunAsync] script, or a value/geometry getter) and, on failure, attaches it run-length-collapsed so equal values fold into one row.
It is on by default; BilobaConfigPollTrajectory(false) turns it off.
See https://onsi.github.io/biloba/#failure-artifacts-humans-ci-and-agents for the rest of the failure block.
*/
func BilobaConfigPollTrajectory(enabled ...bool) func(*Biloba) {
return func(b *Biloba) {
b.pollTrajectory = boolArg(enabled)
}
}
/*
Pass BilobaConfigFailureScreenshotsSize to [ConnectToChrome] to set the size for the screenshots generated on failure
*/
func BilobaConfigFailureScreenshotsSize(width, height int) func(*Biloba) {
return func(b *Biloba) {
b.failureScreenshotWidth = width
b.failureScreenshotHeight = height
}
}
/*
Pass BilobaConfigProgressReportScreenshots to [ConnectToChrome] to control whether Biloba emits screenshots when Progress Reports are requested.
It is on by default; BilobaConfigProgressReportScreenshots(false) turns it off.
*/
func BilobaConfigProgressReportScreenshots(enabled ...bool) func(*Biloba) {
return func(b *Biloba) {
b.progressReportScreenshots = boolArg(enabled)
}
}
/*
Pass BilobaConfigProgressReportScreenshotSize to [ConnectToChrome] to set the size for the screenshots generated when a progress report is requested
*/
func BilobaConfigProgressReportScreenshotSize(width, height int) func(*Biloba) {
return func(b *Biloba) {
b.progressReportScreenshotWidth = width
b.progressReportScreenshotHeight = height
}
}
/*
Pass BilobaConfigInlineScreenshots to [ConnectToChrome] to control whether Biloba emits inline-image escape sequences in failure and progress-report output.
It governs both the on-failure/progress-report screenshots and the diff image [Biloba.HaveScreenshot] draws under a failed visual comparison.
It is on by default (subject to terminal support) when a human is driving, and off under automation (CI or an AI agent). BilobaConfigInlineScreenshots(false) suppresses the inline blob explicitly; BilobaConfigInlineScreenshots() forces it on even under automation. When inline images are off, Biloba still captures screenshots and writes them to the configured directory (if any); the file path is printed to test output. A visual comparison's written diagnosis - what changed, where, and by how much - is printed either way.
The BILOBA_INLINE_SCREENSHOTS=iterm|kitty|sixel|none environment variable selects (or disables, with "none") the inline-image protocol at runtime.
Read https://onsi.github.io/biloba/#capturing-screenshots for details.
*/
func BilobaConfigInlineScreenshots(enabled ...bool) func(*Biloba) {
return func(b *Biloba) {
b.inlineScreenshots = boolArg(enabled)
b.inlineScreenshotsSet = true
}
}
/*
Pass BilobaConfigScreenshotsToDir to [ConnectToChrome] to write failure screenshots to PNG files in the specified directory.
When set, each tab's screenshot is written to <dir>/screenshot-<spec>-<tab>.png on failure, and the absolute path is printed to the test output.
This is complementary to the inline imgcat path: both run when a dir is configured.
The directory is created if it does not already exist.
The BILOBA_SCREENSHOTS_DIR environment variable does the same thing at runtime (this option wins if both are set), and is also the way to point automation's default screenshots directory somewhere specific.
Read https://onsi.github.io/biloba/#capturing-screenshots for details.
*/
func BilobaConfigScreenshotsToDir(dir string) func(*Biloba) {
return func(b *Biloba) {
b.screenshotsDir = dir
}
}
/*
Pass BilobaConfigScreenshotBaselinesDir to [ConnectToChrome] to tell [Biloba.HaveScreenshot] where the committed visual-regression baselines live. It defaults to ./biloba-baselines.
Baselines are a different kind of file from failure screenshots: they are few, small, reviewed, and checked in, which is why they get their own directory instead of sharing the (gitignored) screenshots directory that the actual/diff artifacts are written to.
The BILOBA_SCREENSHOT_BASELINES_DIR environment variable does the same thing at runtime (this option wins if both are set).
Read https://onsi.github.io/biloba/#visual-assertions to learn more about visual assertions
*/
func BilobaConfigScreenshotBaselinesDir(dir string) func(*Biloba) {
return func(b *Biloba) {
b.baselinesDir = dir
}
}
/*
Pass BilobaConfigScreenshotTolerance to [ConnectToChrome] to set the suite-wide default for how much of a [Biloba.HaveScreenshot] comparison may differ: at most fraction (0..1) of the compared pixels. It defaults to 0 - exact.
This is the number worth tuning, once, for a whole suite; [Biloba.Tolerance] overrides it for a single assertion that genuinely needs different slack.
Read https://onsi.github.io/biloba/#visual-assertions to learn more about visual assertions
*/
func BilobaConfigScreenshotTolerance(fraction float64) func(*Biloba) {
return func(b *Biloba) {
b.screenshotTolerance.fraction = fraction
}
}
/*
Pass BilobaConfigScreenshotChannelTolerance to [ConnectToChrome] to set the suite-wide default per-channel slack for [Biloba.HaveScreenshot]: a pixel only counts as differing when one of its R/G/B/A channels differs by more than delta. It defaults to 0 - exact.
This is the antialiasing absorber; [Biloba.ChannelTolerance] overrides it for a single assertion.
Read https://onsi.github.io/biloba/#visual-assertions to learn more about visual assertions
*/
func BilobaConfigScreenshotChannelTolerance(delta int) func(*Biloba) {
return func(b *Biloba) {
b.screenshotTolerance.channel = delta
}
}
/*
Call ConnectToChrome(GinkgoT()) to connect to a Chrome browser
Returns a *Biloba struct that you use to interact with the browser
Read https://onsi.github.io/biloba/#bootstrapping-biloba for details on how to set up your Ginkgo suite and use ConnectToChrome correctly
*/
func ConnectToChrome(ginkgoT GinkgoTInterface, options ...BilobaConfigOption) *Biloba {
ginkgoT.Helper()
b := newBiloba(ginkgoT)
b.root = b
for _, option := range options {
option(b)
}
// Resolve the on-failure artifact policy from the environment, filling in only what the suite
// left unconfigured (explicit options always win - each artifact knob is one-directional, so a
// non-zero value means the user set it). Interactive humans keep the defaults: inline
// screenshots, no DOM outline. Under automation (CI or an AI agent) the artifacts flip to
// text-friendly output: outlines on, inline image blobs off (they're noise in a log), and
// screenshots written to disk so they can be inspected/uploaded after the run.
if automationDetected() {
if !b.failureOutlinesSet {
b.failureOutlines = true
}
if !b.inlineScreenshotsSet {
b.inlineScreenshots = false
}
}
if b.screenshotsDir == "" {
if dir := os.Getenv("BILOBA_SCREENSHOTS_DIR"); dir != "" {
b.screenshotsDir = dir
} else if automationDetected() {
b.screenshotsDir = defaultAutomationScreenshotsDir
}
}
// Visual-regression baselines resolve the same explicit-option-wins way, but they always land
// somewhere: unlike failure screenshots (which a human happily reads inline and never writes), a
// baseline has to be a file, whether or not this is an automated run.
if b.baselinesDir == "" {
if dir := os.Getenv("BILOBA_SCREENSHOT_BASELINES_DIR"); dir != "" {
b.baselinesDir = dir
} else {
b.baselinesDir = defaultScreenshotBaselinesDir
}
}
b.updateScreenshots = b.truthyEnv("BILOBA_UPDATE_SCREENSHOTS")
if b.ChromeConnection.WebSocketURL == "" {
var cc ChromeConnection
configFilePath := gooseConfigPath(ginkgoT.ParallelProcess())
if _, err := os.Stat(configFilePath); err != nil {
configFilePath = gooseConfigPath(1)
}
data, err := os.ReadFile(configFilePath)
if err != nil {
ginkgoT.Fatalf("failed to load ChromeConnection: %w", err)
return nil
}
err = json.Unmarshal(data, &cc)
if err != nil {
ginkgoT.Fatalf("failed to decode ChromeConnection: %w", err)
return nil
}
b.ChromeConnection = cc
}
allocatorContext, cancel := chromedp.NewRemoteAllocator(context.Background(), b.ChromeConnection.WebSocketURL)
b.gt.DeferCleanup(cancel)
// Chrome 149+ rejects Target.createTarget with a browserContextId unless newWindow:true is used,
// so we can't use chromedp.WithNewBrowserContext() directly. Instead we bootstrap a throwaway
// default-context tab to initialize the Browser connection, then manually create the isolated
// browser context and target, and attach via WithTargetID.
var bootstrapOpts []chromedp.ContextOption
if b.debugLogging {
bootstrapOpts = append(bootstrapOpts,
chromedp.WithDebugf(b.gt.Logf),
chromedp.WithLogf(b.gt.Logf),
chromedp.WithErrorf(b.gt.Logf),
)
}
if err := b.bootstrapIsolatedTab(allocatorContext, bootstrapOpts); err != nil {
ginkgoT.Fatalf("failed to connect to chrome: %w", err)
return nil
}
// Give this root tab the high-fidelity viewport emulation (see applyHighFidelityViewport); a no-op
// in the default chrome-headless-shell lane.
if err := b.applyHighFidelityViewport(); err != nil {
ginkgoT.Fatalf("failed to set initial window size: %w", err)
return nil
}
// Make focus/blur events fire even though this headless tab never holds OS focus (see
// applyFocusEmulation).
if err := b.applyFocusEmulation(); err != nil {
ginkgoT.Fatalf("failed to enable focus emulation: %w", err)
return nil
}
b.downloadDir = b.gt.TempDir()
b.setUpListeners()
b.lock.Lock()
b.tabs[chromedp.FromContext(b.Context).Target.TargetID] = b
b.lock.Unlock()
return b
}
// connectAttempts is the total number of times bootstrapIsolatedTab tries to bring up the root tab
// before giving up (an initial attempt plus retries). connectBackoffBase is the first retry's
// backoff ceiling; it doubles on each subsequent retry.
const (
connectAttempts = 4
connectBackoffBase = 50 * time.Millisecond
)
// bootstrapIsolatedTab connects to Chrome and brings up this root tab's isolated browser context +
// target. Each step is a CDP round-trip against the single shared Chrome and can fail transiently
// when many parallel processes connect at once, so we retry with exponential backoff + full jitter -
// the jitter de-correlates the retries of processes that collided together, so they stop colliding
// instead of retrying in lockstep. On a failed attempt we cancel whatever we created (cancelling the
// bootstrap connection disposes the isolated browser context via WithDisposeOnDetach) so retries
// don't leak contexts or targets. On success b is wired up and the surviving contexts are kept alive
// for the life of the spec; on exhaustion the last error is returned.
func (b *Biloba) bootstrapIsolatedTab(allocatorContext context.Context, bootstrapOpts []chromedp.ContextOption) error {
var lastErr error
for attempt := range connectAttempts {
if attempt > 0 {
// full jitter: a random wait in [0, base*2^(attempt-1))
ceiling := connectBackoffBase << (attempt - 1)
time.Sleep(time.Duration(rand.Int64N(int64(ceiling))))
}
bootstrapCtx, cancelBootstrap := chromedp.NewContext(allocatorContext, bootstrapOpts...)
if err := chromedp.Run(bootstrapCtx, chromedp.Evaluate("1", nil)); err != nil {
cancelBootstrap()
lastErr = err
continue
}
browserContextID, isolatedTargetID, err := newIsolatedBrowserContextAndTarget(bootstrapCtx)
if err != nil {
cancelBootstrap()
lastErr = err
continue
}
tabCtx, cancelTab := chromedp.NewContext(bootstrapCtx, chromedp.WithTargetID(isolatedTargetID))
b.Context = tabCtx
if _, err := b.RunErr("1"); err != nil {
cancelTab()
cancelBootstrap()
lastErr = err
continue
}
// success - keep the bootstrap connection and isolated tab alive for the life of the spec
// (LIFO cleanup: tab detaches first, then the bootstrap connection tears down)
b.gt.DeferCleanup(cancelBootstrap)
b.gt.DeferCleanup(cancelTab)
b.targetID = chromedp.FromContext(b.Context).Target.TargetID
b.browserContextID = browserContextID
return nil
}
return lastErr
}
// retryTransientCDP retries an idempotent setup round-trip against the single shared Chrome using the
// same full-jitter backoff as bootstrapIsolatedTab. These steps (viewport emulation, focus emulation,
// target-info lookup) are each a single CDP call that normally succeeds instantly, but under heavy
// parallel load any one can transiently fail - and because they sit *outside* the bootstrap retry, an
// un-retried failure surfaces as a spurious connect/tab-setup failure (and, in the failure-capturing
// test harness, a nil tab). Returns nil on the first success, or the last error after exhausting
// attempts.
func retryTransientCDP(fn func() error) error {
var lastErr error
for attempt := range connectAttempts {
if attempt > 0 {
ceiling := connectBackoffBase << (attempt - 1)
time.Sleep(time.Duration(rand.Int64N(int64(ceiling))))
}
if lastErr = fn(); lastErr == nil {
return nil
}
}
return lastErr
}
/*
Biloba is the main object provided by Biloba for interacting with Chrome. You get an instance of Biloba when you [ConnectToChrome]. This instance is the reusable root tab and cannot be closed.
Any new tabs created or spawned while your tests run will be represented as different instances of Biloba.
To send commands to a particular tab you use the Biloba instance associated with that tab.
Read https://onsi.github.io/biloba/#parallelization-how-biloba-manages-browsers-and-tabs to build a mental model of how Biloba manages tabs
*/
type Biloba struct {
//Context is the underlying chromedp context. Pass this in to chromedp to be take actions on this tab
Context context.Context
gt GinkgoTInterface
ChromeConnection ChromeConnection
targetID target.ID
browserContextID cdp.BrowserContextID
lock *sync.Mutex
root *Biloba
tabs map[target.ID]*Biloba
close context.CancelFunc
bilobaIsInstalled bool
// realistic routes DOM interactions (Click/Hover) through real CDP input instead of the
// fast atomic JS simulations. Set on the lightweight view returned by Realistic().
realistic bool
// poll-config knobs set by Immediate()/WithTimeout()/WithPolling()/WithContext(). They ride the
// shallow clone those return (like realistic) and are NOT reset by Prepare(). A nil pointer / false
// means "unset" - polling methods then inherit Gomega's global Eventually defaults.
immediate bool
timeout *time.Duration
pollingInterval *time.Duration
pollingCtx context.Context
downloadDir string
downloads map[string]*Download
downloadHistory map[string]time.Time
dialogHandlers []*DialogHandler
dialogs []*Dialog
// consoleErrors accumulates rendered console.error / console.assert messages seen on this tab so
// attachFailureArtifactsIfFailed can replay them at the top of the failure block - the originating
// error is usually the root cause and is otherwise buried in the streamed timeline. Reset by Prepare().
consoleErrors []string
requests []*Request
inflightRequests map[network.RequestID]bool
requestHandlers []*requestHandler // ordered, first-match-wins: stub / abort / modify-request
responseHandlers []*ResponseModification // ordered, first-match-wins: modify-response (response stage)
fetchEnabled bool
// The boolean failure-artifact knobs are stored positive-sense and default to their human
// (interactive) values, set in newBiloba; ConnectToChrome adjusts them for automation.
debugLogging bool // default false
failureScreenshots bool // default true
failureOutlines bool // default false
failureOutlinesSet bool // whether the suite set failureOutlines explicitly
progressReportScreenshots bool // default true
inlineScreenshots bool // default true (subject to terminal support)
inlineScreenshotsSet bool // whether the suite set inlineScreenshots explicitly
failureScreenshotWidth int
failureScreenshotHeight int
progressReportScreenshotWidth int
progressReportScreenshotHeight int
screenshotsDir string
// Visual regression (see HaveScreenshot). baselinesDir holds the committed baselines and is
// resolved in ConnectToChrome; screenshotTolerance is the suite-wide default that a per-assertion
// Tolerance/ChannelTolerance overrides; updateScreenshots is BILOBA_UPDATE_SCREENSHOTS. All three
// live on the root tab and are read through b.root.
baselinesDir string
screenshotTolerance screenshotTolerance
updateScreenshots bool
// colorSchemeEmulated records whether this TARGET currently carries an emulated
// prefers-color-scheme (see InColorSchemes). Unlike the freeze stylesheet, which lives in the
// page and dies with a navigation, emulation.SetEmulatedMedia is a target-level override that
// outlives Navigate - so Prepare() clears it, but only when this flag says there is something to
// clear (Prepare runs before every spec and an unconditional CDP round trip there is not free).
// It is a pointer so the shallow clone-with-a-flag views (Realistic() and friends) share the
// tab's one flag instead of each getting a copy that the tab never sees.
colorSchemeEmulated *bool
// pollTrajectory opts a suite into recording the (elapsed, value) trajectory of polled reads and
// attaching the most-recent series on failure (see BilobaConfigPollTrajectory). Off by default.
pollTrajectory bool
probes *probeRecorder // the most-recent-polled-entity trajectory recorder for this tab
// occlusions holds the most recent clicks this tab dispatched onto a covered element. Always
// recorded (one synchronous elementFromPoint inside the click's own snippet), rendered only when a
// spec fails - plain Click stays occlusion-blind by design, this just leaves a trail.
occlusions *occlusionRecorder
}
// inlineScreenshotsEnabled returns true when inline-image output should be
// emitted. It respects the per-instance inlineScreenshots flag (cleared by
// BilobaConfigInlineScreenshots(false) or automation) and the package-level
// inlineImagesSupported helper (which checks BILOBA_INLINE_SCREENSHOTS / TERM_PROGRAM).
func (b *Biloba) inlineScreenshotsEnabled() bool {
if !b.root.inlineScreenshots {
return false
}
return inlineImagesSupported()
}
func (b *Biloba) GomegaString() string {
s := &strings.Builder{}
if b.isRootTab() {
s.WriteString("Root ")
}
title, _ := b.title()
fmt.Fprintf(s, "Biloba Tab %p: %s (TargetID=%s, BrowserContextID=%s)", b, title, b.targetID, b.browserContextID)
return s.String()
}
func newBiloba(ginkgoT GinkgoTInterface) *Biloba {
b := &Biloba{
gt: ginkgoT,
lock: &sync.Mutex{},
downloads: map[string]*Download{},
downloadHistory: map[string]time.Time{},
tabs: map[target.ID]*Biloba{},
inflightRequests: map[network.RequestID]bool{},
failureScreenshots: true,
progressReportScreenshots: true,
inlineScreenshots: true,
pollTrajectory: true,
probes: &probeRecorder{},
occlusions: &occlusionRecorder{},
colorSchemeEmulated: new(bool),
}
return b
}
/*
The Chrome DevTools BrowserContextID() associated with this Biloba tab.
BrowserContextID is an isolation mechanism provided by Chrome DevTools - you may need to pass this in explicitly if you intend to make some low-level calls to chromedp.
*/
func (b *Biloba) BrowserContextID() cdp.BrowserContextID {
b.guardConfig("BrowserContextID")
return b.browserContextID
}
/*
Prepare() should be called before every spec. It prepares the reusable Biloba tab for reuse.
Read https://onsi.github.io/biloba/#bootstrapping-biloba for details on how to set up your Ginkgo suite and use Prepare() correctly
Read https://onsi.github.io/biloba/#parallelization-how-biloba-manages-browsers-and-tabs to build a mental model of how Biloba manages tabs
*/
func (b *Biloba) Prepare() {
b.guardConfig("Prepare")
if !b.isRootTab() {
return
}
//close all tabs
closedTargetIDs := []target.ID{}
for _, tab := range b.AllTabs() {
if !tab.isRootTab() {
tid := chromedp.FromContext(tab.Context).Target.TargetID
b.root.lock.Lock()
delete(b.root.tabs, tid)
b.root.lock.Unlock()
tab.close()
closedTargetIDs = append(closedTargetIDs, tid)
}
}
if len(closedTargetIDs) > 0 {
// Closing is async (see Close): wait until Chrome has truly destroyed these targets so a
// fast-following spec's AllTabs() can't re-discover a dying tab and wedge attaching to it.
b.waitUntilTargetsGone(closedTargetIDs)
//closing all those tabs means we may have nuked our download config, so we reset it
b.configureDownloadBehavior()
}
b.lock.Lock()
b.downloads = map[string]*Download{}
b.downloadHistory = map[string]time.Time{}
b.dialogHandlers = []*DialogHandler{}
b.dialogs = Dialogs{}
b.consoleErrors = nil
b.requests = nil
b.inflightRequests = map[network.RequestID]bool{}
b.requestHandlers = nil
discardedResponseHandlers := b.responseHandlers
b.responseHandlers = nil
wasFetchEnabled := b.fetchEnabled
b.fetchEnabled = false
b.lock.Unlock()
// belt and braces with the DeferCleanup HoldResponse registers: a response held hostage by a
// previous spec is a real Chrome Fetch pause, and disabling Fetch underneath one would wedge
// the tab. Force-release before we tear interception down.
releaseHeldResponses(discardedResponseHandlers)
// disable request interception if a previous spec stubbed requests, so the catch-all
// pause doesn't carry into specs that don't stub - and restore the HTTP cache that
// ensureFetchEnabled turned off (a cached response raises no Fetch event, so interception
// would silently miss it)
if wasFetchEnabled {
chromedp.Run(b.Context, fetch.Disable(), network.SetCacheDisabled(false))
}
// attachFailureArtifactsIfFailed clears the per-spec poll diagnostics on its way out, but it is
// only registered when failure artifacts are on - so clear them here too. A detached-node signal
// or an occluded click carried over from the previous spec would diagnose the wrong spec.
b.resetPollDiagnostics()
if b.failureScreenshots || b.failureOutlines {
b.gt.DeferCleanup(b.attachFailureArtifactsIfFailed)
}
if b.progressReportScreenshots {
b.gt.DeferCleanup(b.gt.AttachProgressReporter(b.progressReporter))
}
if os.Getenv("BILOBA_INTERACTIVE") != "" {
b.gt.DeferCleanup(func(ctx context.Context) {
if b.gt.Failed() {
fmt.Println(b.gt.F("{{red}}{{bold}}This spec failed and you are running in interactive mode. Here's a timeline of the spec:{{/}}"))
fmt.Println(b.gt.Fi(1, b.gt.Name()))
fmt.Println(b.gt.Fi(1, b.gt.RenderTimeline()))
fmt.Println(b.gt.F("{{red}}{{bold}}Biloba will now sleep so you can interact with the browser. Hit ^C when you're done to shut down the suite{{/}}"))
<-ctx.Done()
}
})
}
// the root tab is reused between specs, so clear cookies and web storage (which otherwise
// persist in the browser context / on the origin) to keep specs independent
b.resetBrowsingState()
b.Navigate("about:blank")
}