microsoft / playwright-java

Java version of the Playwright testing and automation library
https://playwright.dev/java/
Apache License 2.0
1.12k stars 204 forks source link

[Feature] Kotlin support / extensions #1293

Open KotlinIsland opened 1 year ago

KotlinIsland commented 1 year ago

Kotlin is a very popular JVM language, and playwright could expose some Kotlin extensions that augment the existing API.

A small example would be infix/operator functions for or and and:

infix fun Locator.or(other: Locator) = or(other)
infix fun Locator.and(other: Locator) = and(other)

// usage like
println(page.locator("div") or page.locator("span"))

Because Kotlin is backwards compatible with Java, it's a possibility that this entire library could be converted to Kotlin, and there would be no disruption to end users using Java (or Scala, or Groovy etc).

yury-s commented 1 year ago

Can it be achieved without providing full-fledged Kotlin API?

KotlinIsland commented 1 year ago

Can it be achieved without providing full-fledged Kotlin API?

Yes, because Kotlin is forward and backward compatible with Java, you can include only a set of Kotlin modules within a Java project.

Although, because Kotlin is designed to be so compatible, any Kotlin API can be consumed by Java/Scala/Groovy code, eg:

Here for demonstration purposes we use Kotlin features such as declaration site variance, inheritance via delegation, and extension functions. Then this code is easily interfaced with from Java without any friction at all.

// utils.kotlin

// unmodifiable collection types, null safe types, declaration site variance, inheritance via delegation
class Foo<T>(val data: MutableList<out T>): List<T> by data

// extension function
fun Locator.doSomething(value: String) { }
// Bar.java
public class Bar {
    List<Integer> myList;
    Locator myLocator;

    void something() {
        Foo<Integer> foo = new Foo<Integer>(myList);
        System.out.println(foo.get(1));
        doSomething(myLocator, "hello");
    }
}
yury-s commented 1 year ago

Here for demonstration purposes we use Kotlin features such as declaration site variance, inheritance via delegation, and extension functions. Then this code is easily interfaced with from Java without any friction at all.

This code has to be compiled with Kotlin going forward and trying to feed it into javac will fail, which essentially means switching Playwright to Kotlin or am I missing something?

helpermethod commented 1 year ago

I wonder if it wouldn't be better to publish a bunch of Kotlin extension functions as a separate module. Where I would see most benefit by providing options as extension function literals, e.g.

instead of

playwright.chromium().launch(BrowserType.LaunchOptions().setHeadless(false).setSlowMo(500.0))

you could write

playwright.chromium().launch { 
    headless = false
    slowMo = 500.0
}

I guess with a little bit of classpath scanning and something like KotlinPoet you may even be able to generate such extensions functions from the existing API.

KotlinIsland commented 1 year ago

This code has to be compiled with Kotlin going forward and trying to feed it into javac will fail, which essentially means switching Playwright to Kotlin or am I missing something?

Well, in a gradle project, you can take an existing java project, remove the java plugin and add the kotlin plugin, and build would generate the same jvm outputs.

twadzins commented 1 year ago

I guess with a little bit of classpath scanning and something like KotlinPoet you may even be able to generate such extensions functions from the existing API.

Something like KotlinPoet would be nicest, but here's a "typed builder" way to get most of what it sounds like you are looking for. This uses is a new function "options()" which works for any playwright Option argument:

Usage of "options() builder"

val browser = playwright.chromium().launch(options {
    // note that command completion of option variables works here
    headless = false 
    args = listOf("--allow-file-access-from-files")
})

OptionsBuilder.kt

inline fun <reified T> options(noinline init: T.() -> Unit): T? {
    if (init == NOOP) {
        return null
    }
    val options = T::class.java.getDeclaredConstructor().newInstance()
    options.init()
    return options
}

object NOOP : (Any) -> Unit {
    override fun invoke(p1: Any) {
        throw RuntimeException("This line should never be reached under normal use")
    }
}

I use the NOOP for creating extension functions with an optional argument for the "Options" object builder) like the following (though there might be a cleaner way to deal with the no-op situation):

fun Locator.shouldBeVisible(init: IsVisibleOptions.() -> Unit = NOOP) = assertThat(this).isVisible(options(init))

//Usage: 
    @Test
    fun `show shouldBeVisible example`() {
        //given
        val page = ...

        //when
        page.navigate("https://www.google.com")

        //then
        // (with no options passed in to shouldBeVisible() )
        page.getByRole(BUTTON, options { name = "I'm Feeling Lucky" }).shouldBeVisible()

        // or (with options passed in to shouldBeVisible() )
        page.getByRole(BUTTON, options { name = "I'm Feeling Lucky" }).shouldBeVisible { timeout = 2000.0 }

        // or (introducing another extension function, which would likely live in some supporting file)
        fun Page.getButtonByName(buttonName: String) = getByRole(BUTTON, options { name = buttonName })

        page.getButtonByName("I'm Feeling Lucky").shouldBeVisible()    
    }