Repository navigation
Expand file tree
/
Copy pathjavascript.go
More file actions
359 lines (284 loc) · 15.1 KB
/
Copy pathjavascript.go
File metadata and controls
359 lines (284 loc) · 15.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
package biloba
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync/atomic"
"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 as the body of an async function and awaits the result, returning the result as well as an error
Use await freely and return the value you want out of the script:
b.RunErrAsync(`return await app.load()`)
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) {
return b.runErr("(async () => {"+script+"\n})()", true, args...)
}
/*
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 as the body of an async function, awaits the result, and returns it.
Unlike [Biloba.Run] (which evaluates a synchronous expression) RunAsync lets you use await and 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
}
/*
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 }