@venn-lang/browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +641 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/actions/capture.ts +50 -0
- package/src/actions/frame.ts +18 -0
- package/src/actions/index.ts +26 -0
- package/src/actions/input.ts +105 -0
- package/src/actions/navigation.ts +89 -0
- package/src/actions/support.ts +17 -0
- package/src/actions/wait.ts +24 -0
- package/src/drivers/fake-driver.ts +101 -0
- package/src/drivers/fake-driver.types.ts +64 -0
- package/src/drivers/index.ts +9 -0
- package/src/drivers/real-driver.ts +28 -0
- package/src/index.ts +10 -0
- package/src/matchers/index.ts +6 -0
- package/src/matchers/text.ts +22 -0
- package/src/matchers/visible.ts +18 -0
- package/src/plugin.ts +25 -0
- package/src/plugin.types.ts +13 -0
- package/src/port/browser-driver.port.ts +33 -0
- package/src/port/browser-driver.types.ts +108 -0
- package/src/port/index.ts +21 -0
- package/src/port/preview-provider.port.ts +13 -0
- package/src/port/preview-provider.types.ts +28 -0
- package/src/preview/fake-preview.ts +37 -0
- package/src/preview/index.ts +2 -0
- package/src/preview/none-preview.ts +15 -0
- package/src/resources/browser-resource.ts +15 -0
- package/src/resources/index.ts +6 -0
- package/src/resources/page-resource.ts +15 -0
- package/src/types.ts +28 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { VennError } from "@venn-lang/contracts";
|
|
2
|
+
import { Duration, arg, defineAction, defineMatcher, definePlugin, defineResource, z } from "@venn-lang/sdk";
|
|
3
|
+
import { t } from "@venn-lang/types";
|
|
4
|
+
//#region src/drivers/fake-driver.ts
|
|
5
|
+
/**
|
|
6
|
+
* Discards an already-evaluated side effect and resolves void. Lets a method
|
|
7
|
+
* body stay a single expression whatever the mutation returned.
|
|
8
|
+
*/
|
|
9
|
+
function after(_effect) {
|
|
10
|
+
return Promise.resolve();
|
|
11
|
+
}
|
|
12
|
+
function navigate(state, url) {
|
|
13
|
+
state.url = url;
|
|
14
|
+
state.history.push(url);
|
|
15
|
+
}
|
|
16
|
+
function recordFill(state, args) {
|
|
17
|
+
state.fills.push({
|
|
18
|
+
selector: args.selector,
|
|
19
|
+
value: args.value
|
|
20
|
+
});
|
|
21
|
+
const element = state.elements.get(args.selector);
|
|
22
|
+
if (element) element.value = args.value;
|
|
23
|
+
}
|
|
24
|
+
function enterFrame(state, name) {
|
|
25
|
+
state.frame = name;
|
|
26
|
+
}
|
|
27
|
+
function bumpCookies(state) {
|
|
28
|
+
state.cookiesCleared += 1;
|
|
29
|
+
}
|
|
30
|
+
function toElements(input) {
|
|
31
|
+
const elements = /* @__PURE__ */ new Map();
|
|
32
|
+
for (const [selector, partial] of Object.entries(input)) elements.set(selector, {
|
|
33
|
+
visible: true,
|
|
34
|
+
text: "",
|
|
35
|
+
value: "",
|
|
36
|
+
...partial
|
|
37
|
+
});
|
|
38
|
+
return elements;
|
|
39
|
+
}
|
|
40
|
+
function initState(options) {
|
|
41
|
+
const url = options.url ?? "about:blank";
|
|
42
|
+
return {
|
|
43
|
+
url,
|
|
44
|
+
history: options.url ? [url] : [],
|
|
45
|
+
clicks: [],
|
|
46
|
+
fills: [],
|
|
47
|
+
frame: void 0,
|
|
48
|
+
cookiesCleared: 0,
|
|
49
|
+
evalResult: { ok: true },
|
|
50
|
+
elements: toElements(options.elements ?? {})
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function coreMethods(state) {
|
|
54
|
+
return {
|
|
55
|
+
launch: (opts) => Promise.resolve({
|
|
56
|
+
id: "browser-1",
|
|
57
|
+
engine: opts.engine ?? "chromium"
|
|
58
|
+
}),
|
|
59
|
+
newContext: () => Promise.resolve({
|
|
60
|
+
id: "page-1",
|
|
61
|
+
url: state.url
|
|
62
|
+
}),
|
|
63
|
+
visit: (args) => after(navigate(state, args.url)),
|
|
64
|
+
click: (selector) => after(state.clicks.push(selector)),
|
|
65
|
+
fill: (args) => after(recordFill(state, args)),
|
|
66
|
+
select: (args) => after(recordFill(state, args)),
|
|
67
|
+
hover: () => Promise.resolve(),
|
|
68
|
+
press: () => Promise.resolve()
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function extraMethods(state) {
|
|
72
|
+
return {
|
|
73
|
+
upload: () => Promise.resolve(),
|
|
74
|
+
download: () => Promise.resolve({
|
|
75
|
+
path: "/artifacts/download.bin",
|
|
76
|
+
bytes: 0
|
|
77
|
+
}),
|
|
78
|
+
screenshot: (name) => Promise.resolve({
|
|
79
|
+
name,
|
|
80
|
+
path: `/artifacts/${name}.png`
|
|
81
|
+
}),
|
|
82
|
+
waitFor: () => Promise.resolve(),
|
|
83
|
+
waitForUrl: (url) => after(navigate(state, url)),
|
|
84
|
+
evaluate: () => Promise.resolve(state.evalResult),
|
|
85
|
+
frame: (args) => after(enterFrame(state, args.name)),
|
|
86
|
+
clearCookies: () => after(bumpCookies(state))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The offline `BrowserDriver`. Every method resolves at once against an
|
|
91
|
+
* in-memory page, so a test never waits on a real browser.
|
|
92
|
+
*
|
|
93
|
+
* @param options elements to preload and the URL to start at.
|
|
94
|
+
* @returns a fresh driver whose `state` records what the flow did.
|
|
95
|
+
*/
|
|
96
|
+
function createFakeBrowserDriver(options = {}) {
|
|
97
|
+
const state = initState(options);
|
|
98
|
+
return {
|
|
99
|
+
...coreMethods(state),
|
|
100
|
+
...extraMethods(state),
|
|
101
|
+
state,
|
|
102
|
+
element: (selector) => state.elements.get(selector)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/port/browser-driver.port.ts
|
|
107
|
+
/**
|
|
108
|
+
* The port every `browser` verb reaches through. Requires the `net` capability,
|
|
109
|
+
* so a host without it fails to load the plugin rather than failing mid-run.
|
|
110
|
+
*
|
|
111
|
+
* `methods` must list every method a verb calls. An omission is not checked at
|
|
112
|
+
* load time, so it surfaces as a TypeError mid-run instead of a legible VN2011.
|
|
113
|
+
*/
|
|
114
|
+
const BrowserDriverPort = {
|
|
115
|
+
id: "venn.port.browser-driver",
|
|
116
|
+
version: 1,
|
|
117
|
+
requires: ["net"],
|
|
118
|
+
methods: [
|
|
119
|
+
"launch",
|
|
120
|
+
"newContext",
|
|
121
|
+
"visit",
|
|
122
|
+
"click",
|
|
123
|
+
"fill",
|
|
124
|
+
"select",
|
|
125
|
+
"hover",
|
|
126
|
+
"press",
|
|
127
|
+
"upload",
|
|
128
|
+
"download",
|
|
129
|
+
"screenshot",
|
|
130
|
+
"waitFor",
|
|
131
|
+
"waitForUrl",
|
|
132
|
+
"evaluate",
|
|
133
|
+
"frame",
|
|
134
|
+
"clearCookies"
|
|
135
|
+
]
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/port/preview-provider.port.ts
|
|
139
|
+
/**
|
|
140
|
+
* The port a live view of a run pulls frames from. Requires no capability: both
|
|
141
|
+
* implementations here are in-process, so the port loads anywhere.
|
|
142
|
+
*/
|
|
143
|
+
const PreviewProviderPort = {
|
|
144
|
+
id: "venn.port.preview-provider",
|
|
145
|
+
version: 1,
|
|
146
|
+
requires: [],
|
|
147
|
+
methods: [
|
|
148
|
+
"start",
|
|
149
|
+
"stop",
|
|
150
|
+
"latestFrame"
|
|
151
|
+
]
|
|
152
|
+
};
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/drivers/real-driver.ts
|
|
155
|
+
function notImplemented() {
|
|
156
|
+
return new VennError({
|
|
157
|
+
code: "VN8090",
|
|
158
|
+
message: "browser real driver not implemented in this build"
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The engine-driving `BrowserDriver`. Not wired up in this repo, which ships
|
|
163
|
+
* the language rather than an automation backend.
|
|
164
|
+
*
|
|
165
|
+
* Methods are generated from the port descriptor, so the stub cannot fall
|
|
166
|
+
* behind the interface.
|
|
167
|
+
*
|
|
168
|
+
* @returns a driver whose every method throws, so the gap is a legible failure
|
|
169
|
+
* rather than a test that passes against nothing.
|
|
170
|
+
* @throws {VennError} `VN8090` on any call.
|
|
171
|
+
*/
|
|
172
|
+
function createRealBrowserDriver() {
|
|
173
|
+
const fail = () => {
|
|
174
|
+
throw notImplemented();
|
|
175
|
+
};
|
|
176
|
+
const entries = BrowserDriverPort.methods.map((method) => [method, fail]);
|
|
177
|
+
return Object.fromEntries(entries);
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/actions/support.ts
|
|
181
|
+
/** The browser driver bound to this run. */
|
|
182
|
+
function browserDriver(ctx) {
|
|
183
|
+
return ctx.port(BrowserDriverPort);
|
|
184
|
+
}
|
|
185
|
+
/** The first positional argument as a string. A missing one reads as `""`. */
|
|
186
|
+
function arg0(input) {
|
|
187
|
+
return String(input.args[0] ?? "");
|
|
188
|
+
}
|
|
189
|
+
/** The second positional argument as a string. A missing one reads as `""`. */
|
|
190
|
+
function arg1(input) {
|
|
191
|
+
return String(input.args[1] ?? "");
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/actions/capture.ts
|
|
195
|
+
/**
|
|
196
|
+
* `browser.screenshot "checkout-empty"`.
|
|
197
|
+
*
|
|
198
|
+
* Captures the page as it stands and files it under the given name.
|
|
199
|
+
*
|
|
200
|
+
* @returns a `browser.Screenshot` saying where the image landed.
|
|
201
|
+
*/
|
|
202
|
+
const screenshot = defineAction({
|
|
203
|
+
name: "screenshot",
|
|
204
|
+
doc: "Capture a screenshot by name.",
|
|
205
|
+
args: [arg("name", t.string, "What to file it under.")],
|
|
206
|
+
result: t.ref("browser.Screenshot"),
|
|
207
|
+
run: (ctx, input) => browserDriver(ctx).screenshot(arg0(input))
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* `browser.download "#invoice-pdf"`.
|
|
211
|
+
*
|
|
212
|
+
* Clicks something that starts a download and waits for the file to land.
|
|
213
|
+
*
|
|
214
|
+
* @returns a `browser.Download` saying where the file landed and how big it is.
|
|
215
|
+
*/
|
|
216
|
+
const download = defineAction({
|
|
217
|
+
name: "download",
|
|
218
|
+
doc: "Trigger and capture a download from a selector.",
|
|
219
|
+
args: [arg("selector", t.string, "What to act on: a CSS selector, or visible text.")],
|
|
220
|
+
result: t.ref("browser.Download"),
|
|
221
|
+
run: (ctx, input) => browserDriver(ctx).download({ selector: arg0(input) })
|
|
222
|
+
});
|
|
223
|
+
/**
|
|
224
|
+
* `browser.evaluate "document.title"`.
|
|
225
|
+
*
|
|
226
|
+
* Runs JavaScript inside the page and hands back what it evaluated to. The
|
|
227
|
+
* escape hatch for whatever the other verbs cannot reach.
|
|
228
|
+
*
|
|
229
|
+
* The result is `dynamic`: narrowing it would be a guess about someone else's
|
|
230
|
+
* script.
|
|
231
|
+
*/
|
|
232
|
+
const evaluate = defineAction({
|
|
233
|
+
name: "evaluate",
|
|
234
|
+
doc: "Evaluate a script in the page and return its value.",
|
|
235
|
+
args: [arg("script", t.string, "JavaScript to run in the page.")],
|
|
236
|
+
result: t.dynamic,
|
|
237
|
+
run: (ctx, input) => browserDriver(ctx).evaluate({ script: arg0(input) })
|
|
238
|
+
});
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/actions/frame.ts
|
|
241
|
+
/**
|
|
242
|
+
* `browser.frame "payment-iframe"`.
|
|
243
|
+
*
|
|
244
|
+
* Points the following steps at an iframe, by its name or a selector for it.
|
|
245
|
+
* Selectors do not cross a frame boundary, so anything inside one needs this
|
|
246
|
+
* first.
|
|
247
|
+
*/
|
|
248
|
+
const frame = defineAction({
|
|
249
|
+
name: "frame",
|
|
250
|
+
doc: "Enter an iframe by name or selector for nested steps.",
|
|
251
|
+
args: [arg("name", t.string, "The iframe's name, or a selector for it.")],
|
|
252
|
+
result: t.void,
|
|
253
|
+
run: (ctx, input) => browserDriver(ctx).frame({ name: arg0(input) })
|
|
254
|
+
});
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/actions/input.ts
|
|
257
|
+
const uploadParams = z.object({ file: z.string() });
|
|
258
|
+
function pressArgs(input) {
|
|
259
|
+
return input.args.length > 1 ? {
|
|
260
|
+
selector: arg0(input),
|
|
261
|
+
key: arg1(input)
|
|
262
|
+
} : { key: arg0(input) };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* `browser.click "#submit"`.
|
|
266
|
+
*
|
|
267
|
+
* Clicks the matching element, waiting for it to be ready first.
|
|
268
|
+
*/
|
|
269
|
+
const click = defineAction({
|
|
270
|
+
name: "click",
|
|
271
|
+
doc: "Click the element matching a selector.",
|
|
272
|
+
args: [arg("selector", t.string, "What to act on: a CSS selector, or visible text.")],
|
|
273
|
+
result: t.void,
|
|
274
|
+
run: (ctx, input) => browserDriver(ctx).click(arg0(input))
|
|
275
|
+
});
|
|
276
|
+
/**
|
|
277
|
+
* `browser.fill "#email" "ada@example.test"`.
|
|
278
|
+
*
|
|
279
|
+
* Replaces whatever an input holds with the given value.
|
|
280
|
+
*/
|
|
281
|
+
const fill = defineAction({
|
|
282
|
+
name: "fill",
|
|
283
|
+
doc: "Fill an input with a value.",
|
|
284
|
+
args: [arg("selector", t.string, "What to act on: a CSS selector, or visible text."), arg("value", t.string, "What to type into it.")],
|
|
285
|
+
result: t.void,
|
|
286
|
+
run: (ctx, input) => browserDriver(ctx).fill({
|
|
287
|
+
selector: arg0(input),
|
|
288
|
+
value: arg1(input)
|
|
289
|
+
})
|
|
290
|
+
});
|
|
291
|
+
/**
|
|
292
|
+
* `browser.select "#country" "BR"`.
|
|
293
|
+
*
|
|
294
|
+
* Picks an option in a dropdown. The second argument is the option's value
|
|
295
|
+
* attribute, not the label the user sees.
|
|
296
|
+
*/
|
|
297
|
+
const select = defineAction({
|
|
298
|
+
name: "select",
|
|
299
|
+
doc: "Select an option by value.",
|
|
300
|
+
args: [arg("selector", t.string, "What to act on: a CSS selector, or visible text."), arg("value", t.string, "The option's value, not its label.")],
|
|
301
|
+
result: t.void,
|
|
302
|
+
run: (ctx, input) => browserDriver(ctx).select({
|
|
303
|
+
selector: arg0(input),
|
|
304
|
+
value: arg1(input)
|
|
305
|
+
})
|
|
306
|
+
});
|
|
307
|
+
/**
|
|
308
|
+
* `browser.hover ".menu-trigger"`.
|
|
309
|
+
*
|
|
310
|
+
* Moves the pointer over an element, which is how a hover menu or tooltip is
|
|
311
|
+
* made to appear.
|
|
312
|
+
*/
|
|
313
|
+
const hover = defineAction({
|
|
314
|
+
name: "hover",
|
|
315
|
+
doc: "Hover over an element.",
|
|
316
|
+
args: [arg("selector", t.string, "What to act on: a CSS selector, or visible text.")],
|
|
317
|
+
result: t.void,
|
|
318
|
+
run: (ctx, input) => browserDriver(ctx).hover(arg0(input))
|
|
319
|
+
});
|
|
320
|
+
/**
|
|
321
|
+
* `browser.press "Enter"`, or `browser.press "#search" "Enter"`.
|
|
322
|
+
*
|
|
323
|
+
* Sends a keystroke. Called with one argument, the page has focus and the
|
|
324
|
+
* argument is the key. Called with two, the first is a selector to focus and
|
|
325
|
+
* the second is the key.
|
|
326
|
+
*
|
|
327
|
+
* Both shapes are strings, so a single signature of two strings covers them.
|
|
328
|
+
*/
|
|
329
|
+
const press = defineAction({
|
|
330
|
+
name: "press",
|
|
331
|
+
doc: "Press a key, optionally focused on a selector.",
|
|
332
|
+
args: [arg("key", t.string, "The key to press: `Enter`, `Escape`, `Control+A`."), arg("selector", t.string, "What to focus first. Omit it and the page has focus.")],
|
|
333
|
+
result: t.void,
|
|
334
|
+
run: (ctx, input) => browserDriver(ctx).press(pressArgs(input))
|
|
335
|
+
});
|
|
336
|
+
/**
|
|
337
|
+
* `browser.upload "#avatar" { file: "./fixtures/face.png" }`.
|
|
338
|
+
*
|
|
339
|
+
* Hands a file to a file input, as picking one in the OS dialog would.
|
|
340
|
+
*/
|
|
341
|
+
const upload = defineAction({
|
|
342
|
+
name: "upload",
|
|
343
|
+
doc: "Upload a file to a file input.",
|
|
344
|
+
params: uploadParams,
|
|
345
|
+
args: [arg("selector", t.string, "What to act on: a CSS selector, or visible text.")],
|
|
346
|
+
result: t.void,
|
|
347
|
+
run: (ctx, input) => browserDriver(ctx).upload({
|
|
348
|
+
selector: arg0(input),
|
|
349
|
+
file: input.params.file
|
|
350
|
+
})
|
|
351
|
+
});
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/actions/navigation.ts
|
|
354
|
+
const launchParams = z.object({
|
|
355
|
+
engine: z.string().optional(),
|
|
356
|
+
headless: z.boolean().optional()
|
|
357
|
+
});
|
|
358
|
+
const contextParams = z.object({
|
|
359
|
+
locale: z.string().optional(),
|
|
360
|
+
viewport: z.object({
|
|
361
|
+
width: z.number(),
|
|
362
|
+
height: z.number()
|
|
363
|
+
}).optional()
|
|
364
|
+
});
|
|
365
|
+
const visitParams = z.object({ headers: z.record(z.string(), z.string()).optional() });
|
|
366
|
+
/**
|
|
367
|
+
* `browser.launch { engine: "chromium", headless: true }`.
|
|
368
|
+
*
|
|
369
|
+
* Starts a browser engine. Everything else in the namespace needs one running.
|
|
370
|
+
*
|
|
371
|
+
* @returns a `browser.Browser` handle.
|
|
372
|
+
*/
|
|
373
|
+
const launch = defineAction({
|
|
374
|
+
name: "launch",
|
|
375
|
+
doc: "Launch a browser engine.",
|
|
376
|
+
params: launchParams.optional(),
|
|
377
|
+
result: t.ref("browser.Browser"),
|
|
378
|
+
run: (ctx, input) => browserDriver(ctx).launch(input.params ?? {})
|
|
379
|
+
});
|
|
380
|
+
/**
|
|
381
|
+
* `browser.visit "/checkout" { headers: { "X-Debug": "1" } }`.
|
|
382
|
+
*
|
|
383
|
+
* Navigates the page and waits for it to load. A relative path joins the base
|
|
384
|
+
* URL; an absolute one is used as given.
|
|
385
|
+
*/
|
|
386
|
+
const visit = defineAction({
|
|
387
|
+
name: "visit",
|
|
388
|
+
doc: "Navigate the page to a URL.",
|
|
389
|
+
params: visitParams.optional(),
|
|
390
|
+
args: [arg("url", t.string, "Where to go. Relative paths join the base URL.")],
|
|
391
|
+
result: t.void,
|
|
392
|
+
run: (ctx, input) => browserDriver(ctx).visit({
|
|
393
|
+
url: arg0(input),
|
|
394
|
+
headers: input.params?.headers
|
|
395
|
+
})
|
|
396
|
+
});
|
|
397
|
+
/**
|
|
398
|
+
* `browser.waitForUrl "/orders/*"`.
|
|
399
|
+
*
|
|
400
|
+
* Blocks until the page lands on a matching URL. Use it after an action that
|
|
401
|
+
* navigates on its own, such as a form submit or a redirect.
|
|
402
|
+
*/
|
|
403
|
+
const waitForUrl = defineAction({
|
|
404
|
+
name: "waitForUrl",
|
|
405
|
+
doc: "Wait until the page URL matches.",
|
|
406
|
+
args: [arg("url", t.string, "The URL to wait for. A pattern is allowed.")],
|
|
407
|
+
result: t.void,
|
|
408
|
+
run: (ctx, input) => browserDriver(ctx).waitForUrl(arg0(input))
|
|
409
|
+
});
|
|
410
|
+
/**
|
|
411
|
+
* `browser.newContext { locale: "pt-BR", viewport: { width: 1280, height: 800 } }`.
|
|
412
|
+
*
|
|
413
|
+
* Opens an isolated context: its own cookies, storage and viewport. Two
|
|
414
|
+
* contexts in one browser cannot see each other's session.
|
|
415
|
+
*
|
|
416
|
+
* @returns a `browser.Page` handle.
|
|
417
|
+
*/
|
|
418
|
+
const newContext = defineAction({
|
|
419
|
+
name: "newContext",
|
|
420
|
+
doc: "Open an isolated browser context (a Page).",
|
|
421
|
+
params: contextParams.optional(),
|
|
422
|
+
result: t.ref("browser.Page"),
|
|
423
|
+
run: (ctx, input) => browserDriver(ctx).newContext(input.params ?? {})
|
|
424
|
+
});
|
|
425
|
+
/**
|
|
426
|
+
* `browser.clearCookies`.
|
|
427
|
+
*
|
|
428
|
+
* Drops every cookie in the current context, logging the session out. Storage
|
|
429
|
+
* is left alone.
|
|
430
|
+
*/
|
|
431
|
+
const clearCookies = defineAction({
|
|
432
|
+
name: "clearCookies",
|
|
433
|
+
doc: "Clear all cookies in the current context.",
|
|
434
|
+
result: t.void,
|
|
435
|
+
run: (ctx) => browserDriver(ctx).clearCookies()
|
|
436
|
+
});
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/actions/index.ts
|
|
439
|
+
/** Every verb in the `browser` namespace, in the order the plugin registers them. */
|
|
440
|
+
const browserActions = [
|
|
441
|
+
launch,
|
|
442
|
+
visit,
|
|
443
|
+
click,
|
|
444
|
+
fill,
|
|
445
|
+
select,
|
|
446
|
+
hover,
|
|
447
|
+
press,
|
|
448
|
+
upload,
|
|
449
|
+
download,
|
|
450
|
+
screenshot,
|
|
451
|
+
defineAction({
|
|
452
|
+
name: "waitFor",
|
|
453
|
+
doc: "Wait for a condition (text or selector) on the page.",
|
|
454
|
+
params: z.object({
|
|
455
|
+
text: z.string().optional(),
|
|
456
|
+
selector: z.string().optional(),
|
|
457
|
+
timeout: Duration.optional()
|
|
458
|
+
}).optional(),
|
|
459
|
+
result: t.void,
|
|
460
|
+
run: (ctx, input) => browserDriver(ctx).waitFor(input.params ?? {})
|
|
461
|
+
}),
|
|
462
|
+
waitForUrl,
|
|
463
|
+
evaluate,
|
|
464
|
+
frame,
|
|
465
|
+
newContext,
|
|
466
|
+
clearCookies
|
|
467
|
+
];
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/matchers/text.ts
|
|
470
|
+
/**
|
|
471
|
+
* `expect element text "Order confirmed"`.
|
|
472
|
+
*
|
|
473
|
+
* Passes when the element's text equals or contains the expected string.
|
|
474
|
+
* Containment is deliberate: assertions about copy should survive a stray
|
|
475
|
+
* space or a wrapping node.
|
|
476
|
+
*/
|
|
477
|
+
const text = defineMatcher({
|
|
478
|
+
name: "text",
|
|
479
|
+
args: [arg("value", t.string, "The text the element should carry.")],
|
|
480
|
+
appliesTo: "Element",
|
|
481
|
+
test: ({ subject, args }) => matchesText(subject, String(args[0])),
|
|
482
|
+
message: ({ args }) => `expected the element text to contain "${String(args[0])}"`
|
|
483
|
+
});
|
|
484
|
+
function matchesText(subject, expected) {
|
|
485
|
+
const actual = subject?.text ?? "";
|
|
486
|
+
return actual === expected || actual.includes(expected);
|
|
487
|
+
}
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/matchers/visible.ts
|
|
490
|
+
/**
|
|
491
|
+
* `expect element visible`.
|
|
492
|
+
*
|
|
493
|
+
* Passes when the element is on screen. An element that is absent, or whose
|
|
494
|
+
* subject is not an element at all, fails rather than erroring.
|
|
495
|
+
*/
|
|
496
|
+
const visible = defineMatcher({
|
|
497
|
+
name: "visible",
|
|
498
|
+
appliesTo: "Element",
|
|
499
|
+
test: ({ subject }) => isVisible(subject),
|
|
500
|
+
message: () => "expected the element to be visible"
|
|
501
|
+
});
|
|
502
|
+
function isVisible(subject) {
|
|
503
|
+
return subject?.visible === true;
|
|
504
|
+
}
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/matchers/index.ts
|
|
507
|
+
/** Every matcher the `browser` namespace contributes to `expect`. */
|
|
508
|
+
const browserMatchers = [visible, text];
|
|
509
|
+
/** Runtime validators for the nominal types the `browser` namespace registers. */
|
|
510
|
+
const browserTypes = {
|
|
511
|
+
Browser: z.object({
|
|
512
|
+
id: z.string(),
|
|
513
|
+
engine: z.string()
|
|
514
|
+
}),
|
|
515
|
+
Page: z.object({
|
|
516
|
+
id: z.string(),
|
|
517
|
+
url: z.string()
|
|
518
|
+
}),
|
|
519
|
+
Element: z.object({
|
|
520
|
+
visible: z.boolean(),
|
|
521
|
+
text: z.string(),
|
|
522
|
+
value: z.string()
|
|
523
|
+
})
|
|
524
|
+
};
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/plugin.ts
|
|
527
|
+
/**
|
|
528
|
+
* The `@venn-lang/browser` plugin. Registers the `browser` namespace: sixteen verbs,
|
|
529
|
+
* the `visible` and `text` matchers, the `Browser` and `Page` resources, and
|
|
530
|
+
* the nominal types those hand around. Requires the `net` capability.
|
|
531
|
+
*/
|
|
532
|
+
const browserPlugin = definePlugin({
|
|
533
|
+
name: "venn/browser",
|
|
534
|
+
version: "0.0.0",
|
|
535
|
+
namespace: "browser",
|
|
536
|
+
requires: ["net"],
|
|
537
|
+
actions: browserActions,
|
|
538
|
+
matchers: browserMatchers,
|
|
539
|
+
resources: [defineResource({
|
|
540
|
+
name: "Browser",
|
|
541
|
+
scope: "worker",
|
|
542
|
+
open: () => ({
|
|
543
|
+
id: "browser-1",
|
|
544
|
+
engine: "chromium"
|
|
545
|
+
}),
|
|
546
|
+
close: () => void 0
|
|
547
|
+
}), defineResource({
|
|
548
|
+
name: "Page",
|
|
549
|
+
scope: "flow",
|
|
550
|
+
open: () => ({
|
|
551
|
+
id: "page-1",
|
|
552
|
+
url: "about:blank"
|
|
553
|
+
}),
|
|
554
|
+
close: () => void 0
|
|
555
|
+
})],
|
|
556
|
+
types: browserTypes,
|
|
557
|
+
typeDefs: {
|
|
558
|
+
/** A launched engine. Held, passed to the `browser` verbs, closed by scope. */
|
|
559
|
+
Browser: t.opaque("browser.Browser", {
|
|
560
|
+
id: t.string,
|
|
561
|
+
engine: t.string
|
|
562
|
+
}),
|
|
563
|
+
/** An isolated context: which one it is, and where it currently points. */
|
|
564
|
+
Page: t.opaque("browser.Page", {
|
|
565
|
+
id: t.string,
|
|
566
|
+
url: t.string
|
|
567
|
+
}),
|
|
568
|
+
/**
|
|
569
|
+
* A resolved element: the subject `visible` and `text` match against. The
|
|
570
|
+
* selector is how an element is found, not something it carries, so what the
|
|
571
|
+
* matchers are handed is `{ visible, text, value }`, all three always set.
|
|
572
|
+
*/
|
|
573
|
+
Element: t.record({
|
|
574
|
+
visible: t.bool,
|
|
575
|
+
text: t.string,
|
|
576
|
+
value: t.string
|
|
577
|
+
}, { open: true }),
|
|
578
|
+
/** What `browser.download` captured: where it landed, and how big it was. */
|
|
579
|
+
Download: t.record({
|
|
580
|
+
path: t.string,
|
|
581
|
+
bytes: t.number
|
|
582
|
+
}),
|
|
583
|
+
/** What `browser.screenshot` captured, under the name it was asked for. */
|
|
584
|
+
Screenshot: t.record({
|
|
585
|
+
name: t.string,
|
|
586
|
+
path: t.string
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region src/preview/fake-preview.ts
|
|
592
|
+
function startStream(streaming, worker) {
|
|
593
|
+
streaming.add(worker);
|
|
594
|
+
return Promise.resolve();
|
|
595
|
+
}
|
|
596
|
+
function stopStream(streaming, worker) {
|
|
597
|
+
streaming.delete(worker);
|
|
598
|
+
return Promise.resolve();
|
|
599
|
+
}
|
|
600
|
+
function cannedFrame(worker) {
|
|
601
|
+
return {
|
|
602
|
+
seq: worker + 1,
|
|
603
|
+
width: 1280,
|
|
604
|
+
height: 800,
|
|
605
|
+
mime: "image/jpeg",
|
|
606
|
+
data: "ZmFrZS1mcmFtZQ=="
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The in-memory `PreviewProvider`. Hands back a fixed JPEG for any worker that
|
|
611
|
+
* has been started, and nothing for one that has not.
|
|
612
|
+
*
|
|
613
|
+
* @returns a fresh provider with no worker streaming.
|
|
614
|
+
*/
|
|
615
|
+
function createFakePreviewProvider() {
|
|
616
|
+
const streaming = /* @__PURE__ */ new Set();
|
|
617
|
+
return {
|
|
618
|
+
start: (target) => startStream(streaming, target.worker),
|
|
619
|
+
stop: (target) => stopStream(streaming, target.worker),
|
|
620
|
+
latestFrame: (target) => streaming.has(target.worker) ? cannedFrame(target.worker) : void 0
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/preview/none-preview.ts
|
|
625
|
+
/**
|
|
626
|
+
* The `PreviewProvider` that captures nothing. The default on CI, where nobody
|
|
627
|
+
* is watching and a frame stream is pure cost.
|
|
628
|
+
*
|
|
629
|
+
* @returns a provider that accepts every call and never yields a frame.
|
|
630
|
+
*/
|
|
631
|
+
function createNonePreviewProvider() {
|
|
632
|
+
return {
|
|
633
|
+
start: () => Promise.resolve(),
|
|
634
|
+
stop: () => Promise.resolve(),
|
|
635
|
+
latestFrame: () => void 0
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
//#endregion
|
|
639
|
+
export { BrowserDriverPort, PreviewProviderPort, browserPlugin, browserPlugin as default, createFakeBrowserDriver, createFakePreviewProvider, createNonePreviewProvider, createRealBrowserDriver };
|
|
640
|
+
|
|
641
|
+
//# sourceMappingURL=index.js.map
|