Let's play with playwright using F# scripts.

Jul 16, 2021 min read

In June Microsoft announced that .NET SDK is stable. For a long time, Selenium was (as far as I know) the only feature-rich web testing framework in .NET (except paid ones like Ranorex or Telerik Test Studio). I never liked the waits I had to do, which often caused the tests to be fragile. Playwright's puppeteer-like SDKs promise automatic wait and support for Python/.Net/Node.js/Java. Let's try this stuff in F#!

Introduction

Let’s make few important points about Playwright before we start;

  1. Supports Chrome/Webkit/Firefox/Edge (with chromium of course 😁).
  2. Has sdks for .Net/Node.js/Python/Java plus Go can be found on the internet, but is not official (at least yet).
  3. Its philosophy is similar to Puppeteer (former Puppeteer devs are developing Playwright).
  4. Works internally by using Chrome DevTools Protocol, for WebKit and Firefox by extending their debugging protocol capabilities (browser behavior remains not touched!) to provide unified API. (Reminds me of Facade pattern).

The .Net SDK is of course C#-first so just follow official instructions to get started in C# (easy-peasy). Let’s try to use it from F#, and then let’s see what we can do about the “C#-first” thing. I already dream about automating stuff with F# and playwright 🙃.

Hello, world from Playwright!

Let’s create a script file for instance playwright.fsx. So I guess we need to reference the nuget package first ;) Let’s then stick to the docs (C# docs) and open a page in headless firefox, then we will go to duckduck.go and take a screenshot of the site.

 1#r "nuget: Microsoft.Playwright"
 2
 3open Microsoft.Playwright
 4
 5let web =
 6    Playwright.CreateAsync()
 7    |> Async.AwaitTask
 8    |> Async.RunSynchronously
 9
10let browser =
11    web.Firefox.LaunchAsync(BrowserTypeLaunchOptions(Headless = true))
12    |> Async.AwaitTask
13    |> Async.RunSynchronously
14
15let page =
16    browser.NewPageAsync()
17    |> Async.AwaitTask
18    |> Async.RunSynchronously
19
20page.GotoAsync("https://duckduckgo.com/")
21|> Async.AwaitTask
22|> Async.RunSynchronously
23
24let screenshotOptions =
25    PageScreenshotOptions(Path = "a_screenshot.png")
26
27page.ScreenshotAsync(screenshotOptions)
28|> Async.AwaitTask
29|> Async.RunSynchronously

I know that this code isn’t the best - lots of Task to FSharpAsync conversion and Synchronous waits, but let’s get back to this later. Let’s run this with dotnet fsi playwright.fsx. … Kaboom 💥! Probably You see this;

1System.AggregateException: One or more errors occurred. (Driver not found: /.nuget/packages/microsoft.playwright/1.12.2/lib/net5.0/.playwright/node/linux/playwright.sh)
2 ---> Microsoft.Playwright.PlaywrightException: Driver not found: .nuget/packages/microsoft.playwright/1.12.2/lib/net5.0/.playwright/node/linux/playwright.sh

This is because we didn’t install the dependencies as stated in the docs We don’t need playwright .net tool for that, it is enough to go to the nuget package;

1/home/marcin/.nuget/packages/microsoft.playwright/1.12.2/Drivers/node/linux

and install dependencies by executing the script. /playwright.sh install. This is one-time operation, You won’t to have bother with this anymore.

Problem with version < 1.13

At the time of writing this post (16-07-2021), 1.13 version is still in preview, in couple of weeks You won’t need this step. For now, keep reding;

Nothing has changed after installation of dependencies? Well - I was a little bit confused, that is why I decided to ask for help on GitHub here; https://github.com/microsoft/playwright-dotnet/issues/1590 So the problem is that by default playwright is looking for .playwright folder in the project location - it relies on the build task to copy the .playwright to the bin/Debug/{framework} location. Since we do scripting here, there is no build task. As You may read in the issue version 1.13 fixes this by defaulting to the .playwright folder in the nuget cache. Let’s add this version to the nuget reference in script;

1#r "nuget: Microsoft.Playwright, 1.13-next-1"

Let’s execute the script so that we will find a new version downloaded in nuget cache. The script will fail, we have to install the dependencies again.

1~/.nuget/packages/microsoft.playwright/1.13.0-next-1/.playwright/node/linux $ ./playwright.sh install

Let’s taste the soup

This should be enough, works for me:

Can You feel the power already? Your head is full of ideas with what can be automated that way? Do You already want to write user-journey tests using this? I do 😀 Let’s try to make the code a little bit more F# friendly first.

Let’s try to make the code more F# friendly.

At first, I came into an idea to create a custom computation expression for playwright with custom syntax that will allow me to do the stuff more o less like this:

1playwright {
2    visit "https://duckduckgo.com/"
3    write "input" "mcode.it"
4...

Wouldn’t be that cool? Let’s try! I will leave some comments for You in the code.

 1#r "nuget: Microsoft.Playwright, 1.13-next-1"
 2// I am lazy and I don't want to convert Task to FSharpAsync all the time. Let's use Task computation expression from Ply nuget package:
 3#r "nuget: Ply"
 4
 5open Microsoft.Playwright
 6open FSharp.Control.Tasks
 7open System.Threading.Tasks
 8
 9type PlaywrightBuilder() =
10    // Required - creates default value, which returned value can be passed over next custom keywords. The type
11    // returned by the next methods has to conform to the returned value type. So each function will have such signature;
12    // Task<IPage> * ... (our parameters) ... -> Task<IPage>.
13    member _.Yield _ =
14        task {
15            let! web = Playwright.CreateAsync()
16            let! browser = web.Firefox.LaunchAsync(BrowserTypeLaunchOptions(Headless = true))
17            return! browser.NewPageAsync()
18        }
19
20    // CustomOperation attribute is the keyword definition that makes it possible to use in our computation expression.
21    [<CustomOperation "visit">]
22    member _.Visit(page: Task<IPage>, url) =
23        task {
24            let! page = page
25            let! _ = page.GotoAsync(url)
26            return page
27        }
28    
29    // And now we go with the repeatable boring stuff...
30    [<CustomOperation "screenshot">]
31    member _.Screenshot(page: Task<IPage>, name) =
32        task {
33            let! page = page
34            let! _ = page.ScreenshotAsync(PageScreenshotOptions(Path = $"{name}.png"))
35            return page
36        }
37
38    [<CustomOperation "write">]
39    member _.Write(page: Task<IPage>, selector, value) =
40        task {
41            let! page = page
42            let! _ = page.FillAsync(selector, value)
43            return page
44        }
45
46    [<CustomOperation "click">]
47    member _.Click(page: Task<IPage>, selector) =
48        task {
49            let! page = page
50            let! _ = page.ClickAsync(selector)
51            return page
52        }
53
54    [<CustomOperation "wait">]
55    member _.Wait(page: Task<IPage>, seconds) =
56        task {
57            let! page = page
58            let! _ = page.WaitForTimeoutAsync(seconds)
59            return page
60        }
61
62    [<CustomOperation "waitFor">]
63    member _.WaitFor(page: Task<IPage>, selector) =
64        task {
65            let! page = page
66            let! _ = page.WaitForSelectorAsync(selector)
67            return page
68        }
69// Let's create our computation expression and use it!
70let playwright = PlaywrightBuilder()
71
72playwright {
73    visit "https://duckduckgo.com/"
74    write "input" "mcode.it"
75    click "input[type='submit']"
76    click "text=mcode.it - Marcin Golenia Blog"
77    waitFor "text=Yet another IT blog?"
78    screenshot "mcode-screen"
79}
80|> Async.AwaitTask
81|> Async.RunSynchronously
82

Isn’t that cool?!?!?!?!?!!?! Well… yes and no 😉. Before I write about the drawback let me give You two more points on the code above;

  1. Here I’ve used Ply - so our expression is dependent on external library and another computation expression, so the task CE is inside another CE - we have hidden some complexity behind abstraction (I dare to say that computation expression is a kind of abstraction). 2a. Computation Expression is (at least in my opinion) somehow advanced mechanism. To grasp the how-to You might want to check the whole blog-series by Scott Wlaschin. 2b. When You are done with Scott, actually farmer docs in Contributing section has a nice example on how to write computation expression with custom keywords.

Drawbacks

First, the thing about computation expressions is that they do not compose well, mixing them is painful. The best example is this; Try to work with Async and Result without FsToolkit.ErrorHandling. Actually nesting them also hurts my eyes - we are back to a lot of curly braces (Hello C#! 😁). However, I am not sure if this is a problem here - I don’t see any reasons to mix the playwright computation expression with another one - but that is just a fresh opinion and I might be just wrong.

The true problem is that We have hidden the native playwright api. If I would publish this code as nuget package I am sure that I will receive tons of issues “This is missing”, “That is missing”, “I can’t do that”, “Omg are You dumb? why not chrome?” and so on and so on. Can we fix that? Maybe - I tried to pass the page as a parameter to the computation expression like so;

 1type PlaywrightBuilder(page: IPage) =
 2    member val Page = page
 3
 4    member this.Yield _ = this.Page
 5
 6    [<CustomOperation "visit">]
 7    member this.Visit(_, url) =
 8        this.Page.GotoAsync(url)
 9        |> Async.AwaitTask
10        |> Async.RunSynchronously
11        |> ignore
12
13        this.Page

and wanted to remove the Synchronous waits later, but I couldn’t figure out how to allow acting on the page in between my computation expression keywords (see comment in code);

 1let page =
 2    task {
 3        let! web = Playwright.CreateAsync()
 4        let! browser = web.Firefox.LaunchAsync(BrowserTypeLaunchOptions(Headless = true))
 5        return! browser.NewPageAsync()
 6    }
 7    |> Async.AwaitTask
 8    |> Async.RunSynchronously
 9
10let playwright = PlaywrightBuilder(page)
11
12playwright {
13    visit "https://duckduckgo.com/"
14    // This doesn't work. :O Need load more stuff into the Computation expression + refactor.
15    page.ScreenshotAsync(ScreenshotOptions(Path="screenshotFromNativeAPI.png"))
16    screenshot "screen.png"
17}

And I gave up. I am sure that an F# Zealot can handle this but… is it worth it? I just thought to myself, why the hell I decided to do a custom computation expression in the first place?

A better solution?

I have a strong opinion that better = simpler. What do You think about this code?

 1#r "nuget: Microsoft.Playwright, 1.13-next-1"
 2#r "nuget: Ply"
 3
 4open Microsoft.Playwright
 5open FSharp.Control.Tasks
 6
 7type IPlaywright with
 8    member this.FFoxPage() =
 9        task {
10            let! ff = this.Firefox.LaunchAsync(BrowserTypeLaunchOptions(Headless = true))
11            return! ff.NewPageAsync()
12        }
13
14type IPage with
15    member this.Screenshot(name) =
16        task {
17            let! _ = this.ScreenshotAsync(PageScreenshotOptions(Path = $"{name}.png"))
18            ()
19        }
20
21task {
22    use! web = Playwright.CreateAsync()
23    let! page = web.FFoxPage()
24    let! _ = page.GotoAsync("https://duckduckgo.com/")
25    do! page.FillAsync("input", "mcode.it")
26    do! page.ClickAsync("input[type='submit']")
27    do! page.ClickAsync("text=mcode.it - Marcin Golenia Blog")
28    let! _ = page.WaitForSelectorAsync("text=Yet another IT blog?")
29    do! page.Screenshot("mcode_page")
30}

Naaah… toooo simple 🙃 isn’t it? 30 lines of code without hard stuff like Custom Computation Expression. Old good extensions methods + full access to native Playwright SDK. I will go with that.

Conclusions

It was a fun ride with custom computation expressions and hopefully I’ve inspired You to meet them in person. I believe I have to check Scott Wlaschin posts about them again myself ;) It is easy to forget - CEs are rather hard and not something You use at daily coding (Thank God! Can You imagine learning custom computation expressions built by bunch of different people? And maybe custom keywords?). I am not saying custom expressions are bad, we can do amazing stuff thanks to them like this dapper wrapper or Farmer. Just make sure that You won’t bring more problems to the table than improvements - like I did with playwright CE.

The most important conclusion that I want You to get from this post is that: 👉 AIM FOR SIMPLICITY 👈. Still, I would use my custom computation expression for simple things and scripts where I need only 10% of playwright rich possibilities (because I already wrote it 🤓). For more complex tasks I would stick to native API with possible extension methods that will make my F# live easier.