@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/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