Repository navigation
Expand file tree
/
Copy pathjavascript.go
More file actions
454 lines (368 loc) · 19.1 KB
/
Copy pathjavascript.go
File metadata and controls
454 lines (368 loc) · 19.1 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
package biloba
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"sync/atomic"
"github.com/chromedp/cdproto"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/cdproto/runtime"
"github.com/onsi/biloba/engine"
"github.com/onsi/gomega/gcustom"
)
/*
EvaluateTo is a matcher that asserts that the result of running the script passed to Gomega matches expected:
Eventually("app.users.map(user => user.name)").Should(tab.EvaluateTo(ConsistOf("George", "Sally", "Bob")))
EvaluateTo can be passed a Gomega matcher to assert against the returned value from the script. Or it can be passed an arbitrary value in which case Equal() is used.
Note: the returned value is JSON-decoded, so JavaScript numbers come back as float64. b.EvaluateTo(1) (an int) will therefore fail against a returned float64(1) - prefer a numeric matcher like BeNumerically("==", 1).
EvaluateTo returns a [ValueMatcher], so you can [ValueMatcher.Capture] the value that satisfied the assertion instead of re-reading the expression with a second call (a second read of a page that may have changed in between):
var log []FoldEntry
Eventually(`window.__foldLog`).Should(b.EvaluateTo(ContainElement(HaveKeyWithValue("fidelity", "text"))).Capture(&log))
Note the asymmetry: the matcher you pass in is applied to the raw JSON-decoded value (maps and float64s), while Capture decodes into the Go type you asked for - so the float64 caveat above goes away for the value you keep.
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) EvaluateTo(expected any) *ValueMatcher {
var data = map[string]any{}
var matcher = matcherOrEqual(expected)
data["Matcher"] = matcher
return capturableResult(b.probing("EvaluateTo", gcustom.MakeMatcher(func(script string) (bool, error) {
r, err := b.RunErr(script)
if err != nil {
return false, fmt.Errorf("Failed to run script:\n%s\n\n%w", script, err)
}
data["Result"] = r
b.recordProbe(probeKey("EvaluateTo", script), r)
return matcher.Match(data["Result"])
}).WithTemplate("Return value for script:\n{{.Actual}}\nFailed with:\n{{if .Failure}}{{.Data.Matcher.FailureMessage .Data.Result}}{{else}}{{.Data.Matcher.NegatedFailureMessage .Data.Result}}{{end}}", data)), data)
}
/*
RunErr() runs the passed in script and returns the result as well as an error
You should generally use [Biloba.Run] instead of RunErr and let Biloba handle errors for you
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) RunErr(script string, args ...any) (any, error) {
return b.runErr(script, false, args...)
}
func (b *Biloba) runErr(script string, awaitPromise bool, args ...any) (any, error) {
b.blockIfNecessaryToEnsureSuccessfulDownloads()
var encodedResult []byte
timeout := cdpTimeout
what := "evaluate JavaScript in the page"
if awaitPromise {
timeout, what = cdpAwaitTimeout, "settle the promise the script awaited"
}
ctx, cancel := b.cdpContext(timeout)
defer cancel()
evaluate := func(runCtx context.Context) error {
var err error
encodedResult, err = engine.EvaluateRawContext(runCtx, script, awaitPromise)
return err
}
err := b.runEngineIn(ctx, timeout, what, evaluate)
if err != nil {
if strings.Contains(err.Error(), "_biloba is not defined") {
b.reloadBiloba()
err = b.runEngineIn(ctx, timeout, what, evaluate)
}
if err != nil {
return nil, err
}
}
// A nil decode target means "discard the result" - decode into a throwaway any
// and return it, just as the no-arg form does. This lets `b.Run(script, nil)`
// work for side-effect-only scripts instead of failing with `json: Unmarshal(nil)`.
if len(args) == 0 || args[0] == nil {
var result any
json.Unmarshal(encodedResult, &result)
return result, nil
}
// An undefined JS result decodes to empty bytes; unmarshaling that into a real
// pointer yields a cryptic `unexpected end of JSON input`. Give a directive error
// instead - the usual cause is a side-effect-only script that forgot to `return`.
if len(encodedResult) == 0 {
return nil, fmt.Errorf("the script returned undefined, so there is nothing to decode into the pointer you provided.\nIf this script runs purely for its side effects, omit the decode target (or pass nil).\nOtherwise make sure the script returns a JSON-serializable value (e.g. `return true`).")
}
err = json.Unmarshal(encodedResult, args[0])
return args[0], err
}
/*
RunErrAsync() runs the passed in script in an async function and awaits the result, returning the result as well as an error
A script that doesn't use return and is a single expression returns its value, as with [Biloba.Run]:
b.RunErrAsync(`(await fetch("/api/entries")).status`)
Any other script is the body of the function: use await freely and return the value you want out of it:
b.RunErrAsync(`const res = await fetch("/api/entries"); return res.status`)
You should generally use [Biloba.RunAsync] instead of RunErrAsync and let Biloba handle errors for you
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) RunErrAsync(script string, args ...any) (any, error) {
// A script that uses `return` is a function body, and runs as one. Any other script is tried as a
// single expression first, so it returns its value without a `return`. A script with statements
// fails to parse that way, and nothing has run yet, so it is safe to run it again as the function
// body. Checking for `return` first spares the common `return` form that second round trip.
body := "(async () => {" + script + "\n})()"
if returnKeyword.MatchString(script) {
return b.runErr(body, true, args...)
}
expression := strings.TrimRight(script, "; \t\n")
res, err := b.runErr("(async () => (\n"+expression+"\n))()", true, args...)
if isParseError(err) {
return b.runErr(body, true, args...)
}
// A side-effect script such as `await store.dispatch(action)` may resolve to a value Chrome can't
// return (a cyclic object). It already ran, and as a function body it would have resolved to
// undefined, so without a decode target treat it that way.
if (len(args) == 0 || args[0] == nil) && isUnreturnableValue(err) {
return nil, nil
}
return res, err
}
var returnKeyword = regexp.MustCompile(`\breturn\b`)
// isParseError reports whether err is Chrome failing to parse the script it was asked to evaluate.
// Chrome reports a parse error as a synchronous "Uncaught" SyntaxError. Anything the script throws
// once it runs inside RunErrAsync's async wrapper rejects the promise instead, which Chrome reports as
// "Uncaught (in promise) ..." - so a SyntaxError thrown by, say, JSON.parse is never mistaken for one.
func isParseError(err error) bool {
var details *runtime.ExceptionDetails
if !errors.As(err, &details) || details.Exception == nil {
return false
}
return details.Text == "Uncaught" && details.Exception.ClassName == "SyntaxError"
}
// isUnreturnableValue reports whether err is Chrome refusing to return the script's value by value.
func isUnreturnableValue(err error) bool {
var cdpErr *cdproto.Error
if !errors.As(err, &cdpErr) {
return false
}
return strings.Contains(cdpErr.Message, "Object reference chain is too long") || strings.Contains(cdpErr.Message, "Object couldn't be returned by value")
}
/*
Run() runs the passed in script and returns the result (as type any):
tab.Run("1+3") // returns 4.0
You can also pass a single pointer argument if you would like Biloba to decode the result into a specific type (a la json.Unmarshal):
var result int
tab.Run("1+3", &result) // result is now 4
Note: the result is JSON-decoded, so a returned number comes back as a float64 when you don't pass a typed pointer. tab.Run("1+3") returns float64(4), which will not Equal(4) (an int) - use BeNumerically("==", 4) or decode into a typed pointer as above.
For a side-effect-only script you don't need a decode target at all - just omit it (or pass nil): tab.Run("app.redraw()"). If you do pass a non-nil pointer the script must return a JSON-serializable value, otherwise Run fails with a directive error.
# If an error occurs Run() will fail the spec
Run does not poll: a thrown error is usually a real bug, not a not-ready condition, so auto-polling would mask it. For a polling path use [Biloba.RunErr] + Eventually, or [Biloba.EvaluateTo]. Configuring Run (WithTimeout/WithPolling/WithContext/Immediate) is a hard error.
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) Run(script string, args ...any) any {
b.gt.Helper()
b.guardConfig("Run")
return b.run(script, args...)
}
// run is the unguarded substrate behind Run. Internal callers (e.g. reloadBiloba on the polling hot
// path, the Storage and WindowSize helpers) use it directly so the public Run's config guard does not
// fire for Biloba's own internal scripting - only for a user who explicitly misconfigures a Run call.
func (b *Biloba) run(script string, args ...any) any {
b.gt.Helper()
res, err := b.RunErr(script, args...)
if err != nil {
b.gt.Fatalf("Failed to run script:\n%s\n\n%s%s", script, err.Error(), illegalReturnHint(err))
}
return res
}
// illegalReturnHint returns a hint to append to a Run failure when the script used a top-level return.
// Run evaluates a synchronous expression (Runtime.evaluate), so a top-level `return` is a syntax error;
// RunAsync wraps the script in a function body where `return` is allowed.
func illegalReturnHint(err error) string {
if err == nil || !strings.Contains(err.Error(), "Illegal return statement") {
return ""
}
return "\n\nHint: Run evaluates a synchronous expression, so a top-level `return` is not allowed. Use b.RunAsync (which wraps your script in a function body) or wrap the script in an IIFE: `(() => { ... })()`."
}
/*
RunAsync() runs the passed in script in an async function, awaits the result, and returns it.
A script that doesn't use return and is a single expression returns its value, just as with [Biloba.Run], and can use await:
status := b.RunAsync(`(await fetch("/api/entries/X", {method: "DELETE"})).status`)
Any other script is the body of the function, so you return the value you care about:
users := b.RunAsync(`
const response = await fetch("/api/users")
return await response.json()
`)
As with Run you can pass a single pointer argument to decode the result into a specific type:
var users []User
b.RunAsync(`return await app.load()`, &users)
# If the script throws or the awaited promise rejects RunAsync will fail the spec
Like [Biloba.Run], RunAsync does not poll and configuring it (WithTimeout/WithPolling/WithContext/Immediate) is a hard error; for a polling path use [Biloba.RunErrAsync] + Eventually.
A promise that never settles would otherwise hang your suite, so RunAsync carries a backstop deadline of two minutes - generous, since the page's own JavaScript sets how long the await takes. See https://onsi.github.io/biloba/#when-chrome-stops-responding
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) RunAsync(script string, args ...any) any {
b.gt.Helper()
b.guardConfig("RunAsync")
res, err := b.RunErrAsync(script, args...)
if err != nil {
b.gt.Fatalf("Failed to run async script:\n%s\n\n%s", script, err.Error())
}
return res
}
/*
AddInitScript(script) installs script to run at the start of every document this tab loads from now
on - before the page's own scripts, in the document and in each of its frames:
b.AddInitScript(`window.__seed = 42`)
b.Navigate(url) // the page's scripts see window.__seed
It does not run in the document that is already loaded, so call it before Navigate. The script stays
installed across navigations until the spec ends, when a DeferCleanup removes it. AddInitScript applies
to a tab, not a frame handle, and configuring it (WithTimeout/WithPolling/WithContext/Immediate) is a
hard error.
Read https://onsi.github.io/biloba/#init-scripts to learn more about init scripts
*/
func (b *Biloba) AddInitScript(script string) {
b.gt.Helper()
if b.refusedOnFrame("AddInitScript") {
return
}
b.guardConfig("AddInitScript")
var identifier page.ScriptIdentifier
err := b.runEngine("add an init script", func(ctx context.Context) error {
var err error
identifier, err = engine.AddInitScriptContext(ctx, script)
return err
})
if err != nil {
b.gt.Fatalf("Failed to add init script:\n%s\n\n%s", script, err.Error())
return
}
b.gt.DeferCleanup(func() {
err := b.runEngine("remove an init script", func(ctx context.Context) error {
return engine.RemoveInitScriptContext(ctx, identifier)
})
if err != nil {
b.gt.Fatalf("Failed to remove init script:\n%s\n\n%s", script, err.Error())
}
})
}
/*
GetJSValue(expression, args...) polls expression - a JavaScript expression, typically a global path the
app under test exposes (window.__storeLog, app.store.lastFold) - and returns its value once defined.
GetJSValue is the blessed **app-state barrier**: a spec-visible proof that the *browser* actually
processed something, as opposed to two traps that only look like proof. The DOM shows the *optimistic*
value a click handler sets synchronously, before any round trip - asserting on it proves the handler
ran, not that a response was folded in. A Go-side HTTP read bypasses the tab's event loop entirely - it
proves the server persisted something, not that the tab ever processed it. Both can be true while the
tab has done nothing with the result. Have the app log its own fold decisions to a window.__x path and
poll that with GetJSValue - that's proof the renderer acted:
b.Run(`app.store.on("fold", () => { window.__storeLog = window.__storeLog || []; window.__storeLog.push(app.store.state) })`)
b.Click("#save")
var log []string
b.GetJSValue("window.__storeLog", &log) // blocks until the fold actually happens
Ω(log).Should(HaveExactElements("saving", "saved"))
GetJSValue polls by default: it retries while expression evaluates to undefined (the barrier has not
been reached yet) or throws (e.g. a ReferenceError because a global does not exist yet - not ready, not
a bug). A null result is legitimate and returns immediately without retrying. Configure the wait with
WithTimeout/WithPolling/WithContext, or opt into act-once/fail-fast with Immediate().
As with [Biloba.Run] you may pass a single pointer to decode the result into a specific type:
var log []string
b.GetJSValue("window.__storeLog", &log)
Without a decode target, JS numbers come back as float64 - prefer a numeric matcher like
BeNumerically("==", 3) or decode into a typed pointer as above.
To poll until a *condition* holds rather than fetch a value, reach for the sibling matcher form:
Eventually("window.__storeLog.length").Should(b.EvaluateTo(BeNumerically(">", 0))).
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) GetJSValue(expression string, args ...any) any {
b.gt.Helper()
script := "(() => {\n const __biloba_v = (" + expression + ");\n return {defined: __biloba_v !== undefined, value: __biloba_v};\n})()"
var result any
matcher := gcustom.MakeMatcher(func(expr string) (bool, error) {
var envelope struct {
Defined bool `json:"defined"`
Value json.RawMessage `json:"value"`
}
if _, err := b.RunErr(script, &envelope); err != nil {
b.recordProbe(probeKey("GetJSValue", expr), fmt.Sprintf("error: %s", err))
return false, err
}
if !envelope.Defined {
b.recordProbe(probeKey("GetJSValue", expr), "<undefined>")
return false, nil
}
if len(args) > 0 && args[0] != nil {
if err := json.Unmarshal(envelope.Value, args[0]); err != nil {
return false, err
}
result = args[0]
} else {
var v any
if len(envelope.Value) > 0 {
if err := json.Unmarshal(envelope.Value, &v); err != nil {
return false, err
}
}
result = v
}
b.recordProbe(probeKey("GetJSValue", expr), result)
return true, nil
}).WithMessage(fmt.Sprintf("evaluate %q to a defined value", expression))
b.pollOrImmediate(expression, b.probing("GetJSValue", matcher))
return result
}
type JSFunc string
/*
JSFunc() allows you to write Javascript functions that are invoked with Go arguments and then passed in to Run:
adder := b.JSFunc("(...nums) => nums.reduce((s, n) => s + n, 0)")
var result int
b.Run(adder.Invoke(1, 2, 3, 4, 5, 10), &result)
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) JSFunc(f string) JSFunc {
return JSFunc("(" + f + ")")
}
/*
Invoke() interpolates the passed-in args into the JSFunc and generates a script that cna be passed to Run()
adder := b.JSFunc("(...nums) => nums.reduce((s, n) => s + n, 0)")
var result int
tab.Run(adder.Invoke(1, 2, 3, 4, 5, 10), &result)
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (j JSFunc) Invoke(args ...any) string {
if len(args) == 0 {
return string(j) + "()"
}
// the encoding is shared with the biloba.js handler path (dom.go's runBilobaHandler), so a
// JSVar interpolates the same way no matter which path carries the argument
encodedArgs, err := engine.EncodeArgs(args...)
if err != nil {
panic(err)
}
return string(j) + "(..." + encodedArgs + ")"
}
/*
JSVar() allows you to indicate that the wrapped variable should be not be interpolated by Invoke(), instead it should be provided by the JavaScript environment and not Go.
This is perhaps best understood with an example:
adder := tab.JSFunc("(...nums) => nums.reduce((s, n) => s + n, 0)")
tab.Run(adder.Invoke(15, 10, tab.JSVar("app.numRecords"), tab.JSVar("app.numUsers + 10")))
This call to invoke will generate the following literal script:
((...nums) => nums.reduce((s, n) => s + n, 0))(...[15, 10, app.numRecords, app.numUsers + 10])
which, when eval()'d in JavaScript will pull in the app variable from the global window object and grab the numRecords and numUsers properties.
If were to not wrap these variables in JSVar:
tab.Run(adder.Invoke(15, 10, "app.numRecords", "app.numUsers + 10")) //wrong!
then the generated script would be:
//not what you want!
((...nums) => nums.reduce((s, n) => s + n, 0))(...[15, 10, "app.numRecords", "app.numUsers + 10"])
which would (of course) evaluate to "25app.numRecordsapp.numUsers + 10". (of course).
Read https://onsi.github.io/biloba/#running-arbitrary-javascript to learn more about running JavaScript in Biloba
*/
func (b *Biloba) JSVar(v string) JSVar {
return JSVar{
v: v,
identifier: fmt.Sprintf(`"__biloba_var_%d"`, atomic.AddInt64(&jsVarCounter, 1)),
}
}
var jsVarCounter int64
type JSVar struct {
v string
identifier string
}
func (j JSVar) MarshalJSON() ([]byte, error) { return []byte(j.identifier), nil }
// RawJSPlaceholder and RawJSExpression implement engine.RawJSArg: they are how the shared
// argument encoder recognizes a JSVar and swaps its JSON placeholder back out for the raw
// JavaScript expression the user asked for.
func (j JSVar) RawJSPlaceholder() string { return j.identifier }
func (j JSVar) RawJSExpression() string { return j.v }