create end to end tests using playwright

This commit is contained in:
Evan Almloff 2023-06-05 13:48:58 -05:00
parent 0fec47db72
commit 741ff97882
18 changed files with 732 additions and 2 deletions

40
.github/workflows/playwright.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
if: github.event.pull_request.draft == false
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- uses: Swatinem/rust-cache@v2
- uses: actions/checkout@v3
- uses: actions-rs/cargo@v1
with:
command: install
args: --git https://github.com/DioxusLabs/cli
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

8
.gitignore vendored
View file

@ -1,4 +1,6 @@
/target
/playwrite-tests/web/dist
/playwrite-tests/fullstack/dist
/dist
Cargo.lock
.DS_Store
@ -11,4 +13,8 @@ Cargo.lock
tarpaulin-report.html
# Jetbrain
.idea/
.idea/
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/

View file

@ -33,6 +33,10 @@ members = [
# Full project examples
"examples/tailwind",
"examples/PWA-example",
# Playwrite tests
"playwrite-tests/liveview",
"playwrite-tests/web",
"playwrite-tests/fullstack",
]
# This is a "virtual package"

View file

@ -41,6 +41,12 @@ cargo check --workspace --examples --tests
cargo clippy --workspace --examples --tests -- -D warnings
```
- Browser tests are automated with [Playwrite](https://playwright.dev/docs/intro#installing-playwright)
```sh
npx playwright test
```
- Crates that use unsafe are checked for undefined behavior with [MIRI](https://github.com/rust-lang/miri). MIRI can be helpful to debug what unsafe code is causing issues. Only code that does not interact with system calls can be checked with MIRI. Currently, this is used for the two MIRI tests in `dioxus-core` and `dioxus-native-core`.
```sh

67
package-lock.json generated Normal file
View file

@ -0,0 +1,67 @@
{
"name": "dioxus",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dioxus",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.34.3"
}
},
"node_modules/@playwright/test": {
"version": "1.34.3",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.3.tgz",
"integrity": "sha512-zPLef6w9P6T/iT6XDYG3mvGOqOyb6eHaV9XtkunYs0+OzxBtrPAAaHotc0X+PJ00WPPnLfFBTl7mf45Mn8DBmw==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.34.3"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@types/node": {
"version": "20.2.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.2.5.tgz",
"integrity": "sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==",
"dev": true
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright-core": {
"version": "1.34.3",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.3.tgz",
"integrity": "sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=14"
}
}
}
}

17
package.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "dioxus",
"version": "1.0.0",
"description": "<p align=\"center\"> <img src=\"./notes/header.svg\"> </p>",
"main": "index.js",
"directories": {
"doc": "docs",
"example": "examples"
},
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.34.3"
}
}

View file

@ -78,7 +78,7 @@ fn app(cx: Scope<AppProps>) -> Element {
}
}
},
"Run a server function! testing1234"
"Run a server function!"
}
"Server said: {text}"
})

99
playwright.config.js Normal file
View file

@ -0,0 +1,99 @@
// @ts-check
const { defineConfig, devices } = require('@playwright/test');
const path = require('path');
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* @see https://playwright.dev/docs/test-configuration
*/
module.exports = defineConfig({
testDir: './playwrite-tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// {
// name: 'firefox',
// use: { ...devices['Desktop Firefox'] },
// },
// {
// name: 'webkit',
// use: { ...devices['Desktop Safari'] },
// },
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ..devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
webServer: [
{
command: 'cargo run --package dioxus-playwrite-liveview-test --bin dioxus-playwrite-liveview-test',
port: 3030,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
stdout: "pipe",
},
{
cwd: path.join(process.cwd(), 'playwrite-tests', 'web'),
command: 'dioxus serve',
port: 8080,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
stdout: "pipe",
},
{
cwd: path.join(process.cwd(), 'playwrite-tests', 'fullstack'),
command: 'dioxus build --features web\ncargo run --release --features ssr --no-default-features',
port: 3333,
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI,
stdout: "pipe",
}
],
});

View file

@ -0,0 +1,32 @@
// @ts-check
const { test, expect } = require('@playwright/test');
test('button click', async ({ page }) => {
await page.goto('http://localhost:3333');
// Expect the page to contain the counter text.
const main = page.locator('#main');
await expect(main).toContainText('hello axum! 12345');
// Click the increment button.
let button = await page.locator('button.increment-button');
await button.click();
// Expect the page to contain the updated counter text.
await expect(main).toContainText('hello axum! 12346');
});
test('fullstack communication', async ({ page }) => {
await page.goto('http://localhost:3333');
// Expect the page to contain the counter text.
const main = page.locator('#main');
await expect(main).toContainText('Server said: ...');
// Click the increment button.
let button = await page.locator('button.server-button');
await button.click();
// Expect the page to contain the updated counter text.
await expect(main).toContainText('Server said: Hello from the server!');
});

2
playwrite-tests/fullstack/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist
target

View file

@ -0,0 +1,21 @@
[package]
name = "dioxus-playwrite-fullstack-test"
version = "0.1.0"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dioxus-web = { path = "../../packages/web", features=["hydrate"], optional = true }
dioxus = { path = "../../packages/dioxus" }
dioxus-fullstack = { path = "../../packages/fullstack" }
axum = { version = "0.6.12", optional = true }
tokio = { version = "1.27.0", features = ["full"], optional = true }
serde = "1.0.159"
execute = "0.2.12"
[features]
default = ["web"]
ssr = ["axum", "tokio", "dioxus-fullstack/axum"]
web = ["dioxus-web"]

View file

@ -0,0 +1,104 @@
// This test is used by playwrite configured in the root of the repo
// Tests:
// - Server functions
// - SSR
// - Hydration
#![allow(non_snake_case)]
use dioxus::prelude::*;
use dioxus_fullstack::prelude::*;
use serde::{Deserialize, Serialize};
fn main() {
#[cfg(feature = "web")]
dioxus_web::launch_with_props(
app,
get_root_props_from_document().unwrap_or_default(),
dioxus_web::Config::new().hydrate(true),
);
#[cfg(feature = "ssr")]
{
// Start hot reloading
hot_reload_init!(dioxus_hot_reload::Config::new().with_rebuild_callback(|| {
execute::shell("dioxus build --features web")
.spawn()
.unwrap()
.wait()
.unwrap();
execute::shell("cargo run --features ssr --no-default-features")
.spawn()
.unwrap();
true
}));
PostServerData::register().unwrap();
GetServerData::register().unwrap();
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 3333));
axum::Server::bind(&addr)
.serve(
axum::Router::new()
.serve_dioxus_application(
"",
ServeConfigBuilder::new(app, AppProps { count: 12345 }).build(),
)
.into_make_service(),
)
.await
.unwrap();
});
}
}
#[derive(Props, PartialEq, Debug, Default, Serialize, Deserialize, Clone)]
struct AppProps {
count: i32,
}
fn app(cx: Scope<AppProps>) -> Element {
let mut count = use_state(cx, || cx.props.count);
let text = use_state(cx, || "...".to_string());
cx.render(rsx! {
h1 { "hello axum! {count}" }
button {
class: "increment-button",
onclick: move |_| count += 1,
"Increment"
}
button {
class: "server-button",
onclick: move |_| {
to_owned![text];
let sc = cx.sc();
async move {
if let Ok(data) = get_server_data().await {
println!("Client received: {}", data);
text.set(data.clone());
post_server_data(sc, data).await.unwrap();
}
}
},
"Run a server function!"
}
"Server said: {text}"
})
}
#[server(PostServerData)]
async fn post_server_data(cx: DioxusServerContext, data: String) -> Result<(), ServerFnError> {
// The server context contains information about the current request and allows you to modify the response.
cx.response_headers_mut()
.insert("Set-Cookie", "foo=bar".parse().unwrap());
println!("Server received: {}", data);
println!("Request parts are {:?}", cx.request_parts());
Ok(())
}
#[server(GetServerData)]
async fn get_server_data() -> Result<String, ServerFnError> {
Ok("Hello from the server!".to_string())
}

View file

@ -0,0 +1,72 @@
// @ts-check
const { test, expect } = require('@playwright/test');
test('button click', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the counter text.
const main = page.locator('#main');
await expect(main).toContainText('hello axum! 0');
// Click the increment button.
await page.getByRole('button', { name: 'Increment' }).click();
// Expect the page to contain the updated counter text.
await expect(main).toContainText('hello axum! 1');
});
test('svg', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the svg.
const svg = page.locator('svg');
// Expect the svg to contain the circle.
const circle = svg.locator('circle');
await expect(circle).toHaveAttribute('cx', '50');
await expect(circle).toHaveAttribute('cy', '50');
await expect(circle).toHaveAttribute('r', '40');
await expect(circle).toHaveAttribute('stroke', 'green');
await expect(circle).toHaveAttribute('fill', 'yellow');
});
test('raw attribute', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the div with the raw attribute.
const div = page.locator('div.raw-attribute-div');
await expect(div).toHaveAttribute('raw-attribute', 'raw-attribute-value');
});
test('hidden attribute', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the div with the hidden attribute.
const div = page.locator('div.hidden-attribute-div');
await expect(div).toHaveAttribute('hidden', 'true');
});
test('dangerous inner html', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the div with the dangerous inner html.
const div = page.locator('div.dangerous-inner-html-div');
await expect(div).toContainText('hello dangerous inner html');
});
test('input value', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the input with the value.
const input = page.locator('input');
await expect(input).toHaveValue('hello input');
});
test('style', async ({ page }) => {
await page.goto('http://127.0.0.1:3030');
// Expect the page to contain the div with the style.
const div = page.locator('div.style-div');
await expect(div).toHaveText('colored text');
await expect(div).toHaveCSS('color', 'rgb(255, 0, 0)');
});

View file

@ -0,0 +1,13 @@
[package]
name = "dioxus-playwrite-liveview-test"
version = "0.0.1"
edition = "2021"
description = "Playwrite test for Dioxus Liveview"
license = "MIT/Apache-2.0"
publish = false
[dependencies]
dioxus = { path = "../../packages/dioxus" }
dioxus-liveview = { path = "../../packages/liveview", features = ["axum"] }
tokio = { version = "1.19.2", features = ["full"] }
axum = { version = "0.6.1", features = ["ws"] }

View file

@ -0,0 +1,78 @@
// This test is used by playwrite configured in the root of the repo
use axum::{extract::ws::WebSocketUpgrade, response::Html, routing::get, Router};
use dioxus::prelude::*;
fn app(cx: Scope) -> Element {
let mut num = use_state(cx, || 0);
cx.render(rsx! {
div {
"hello axum! {num}"
button { onclick: move |_| num += 1, "Increment" }
}
svg {
circle { cx: 50, cy: 50, r: 40, stroke: "green", fill: "yellow" }
}
div {
class: "raw-attribute-div",
"raw-attribute": "raw-attribute-value",
}
div {
class: "hidden-attribute-div",
hidden: true,
}
div {
class: "dangerous-inner-html-div",
dangerous_inner_html: "<p>hello dangerous inner html</p>",
}
input {
value: "hello input",
}
div {
class: "style-div",
color: "red",
"colored text"
}
})
}
#[tokio::main]
async fn main() {
let addr: std::net::SocketAddr = ([127, 0, 0, 1], 3030).into();
let view = dioxus_liveview::LiveViewPool::new();
let app = Router::new()
.route(
"/",
get(move || async move {
Html(format!(
r#"
<!DOCTYPE html>
<html>
<head> <title>Dioxus LiveView with axum</title> </head>
<body> <div id="main"></div> </body>
{glue}
</html>
"#,
glue = dioxus_liveview::interpreter_glue(&format!("ws://{addr}/ws"))
))
}),
)
.route(
"/ws",
get(move |ws: WebSocketUpgrade| async move {
ws.on_upgrade(move |socket| async move {
_ = view.launch(dioxus_liveview::axum_socket(socket), app).await;
})
}),
);
println!("Listening on http://{addr}");
axum::Server::bind(&addr.to_string().parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}

View file

@ -0,0 +1,94 @@
// @ts-check
const { test, expect, defineConfig } = require('@playwright/test');
test('button click', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the counter text.
const main = page.locator('#main');
await expect(main).toContainText('hello axum! 0');
// Click the increment button.
let button = await page.locator('button.increment-button');
await button.click();
// Expect the page to contain the updated counter text.
await expect(main).toContainText('hello axum! 1');
});
test('svg', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the svg.
const svg = page.locator('svg');
// Expect the svg to contain the circle.
const circle = svg.locator('circle');
await expect(circle).toHaveAttribute('cx', '50');
await expect(circle).toHaveAttribute('cy', '50');
await expect(circle).toHaveAttribute('r', '40');
await expect(circle).toHaveAttribute('stroke', 'green');
await expect(circle).toHaveAttribute('fill', 'yellow');
});
test('raw attribute', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the div with the raw attribute.
const div = page.locator('div.raw-attribute-div');
await expect(div).toHaveAttribute('raw-attribute', 'raw-attribute-value');
});
test('hidden attribute', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the div with the hidden attribute.
const div = page.locator('div.hidden-attribute-div');
await expect(div).toHaveAttribute('hidden', 'true');
});
test('dangerous inner html', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the div with the dangerous inner html.
const div = page.locator('div.dangerous-inner-html-div');
await expect(div).toContainText('hello dangerous inner html');
});
test('input value', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the input with the value.
const input = page.locator('input');
await expect(input).toHaveValue('hello input');
});
test('style', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the div with the style.
const div = page.locator('div.style-div');
await expect(div).toHaveText('colored text');
await expect(div).toHaveCSS('color', 'rgb(255, 0, 0)');
});
test('eval', async ({ page }) => {
await page.goto('http://localhost:8080');
// Expect the page to contain the div with the eval and have no text.
const div = page.locator('div.eval-result');
await expect(div).toHaveText('');
// Click the button to run the eval.
let button = await page.locator('button.eval-button');
await button.click();
// Check that the title changed.
await expect(page).toHaveTitle('Hello from Dioxus Eval!');
// Check that the div has the eval value.
await expect(div).toHaveText('returned eval value');
});
// Shutdown the li

View file

@ -0,0 +1,12 @@
[package]
name = "dioxus-playwrite-web-test"
version = "0.0.1"
edition = "2021"
description = "Playwrite test for Dioxus Web"
license = "MIT/Apache-2.0"
publish = false
[dependencies]
dioxus = { path = "../../packages/dioxus" }
dioxus-web = { path = "../../packages/web" }
serde_json = "1.0.96"

View file

@ -0,0 +1,63 @@
// This test is used by playwrite configured in the root of the repo
use dioxus::prelude::*;
use dioxus_web::use_eval;
fn app(cx: Scope) -> Element {
let mut num = use_state(cx, || 0);
let eval = use_eval(cx);
let eval_result = use_state(cx, String::new);
cx.render(rsx! {
div {
"hello axum! {num}"
button {
class: "increment-button",
onclick: move |_| num += 1, "Increment"
}
}
svg {
circle { cx: 50, cy: 50, r: 40, stroke: "green", fill: "yellow" }
}
div {
class: "raw-attribute-div",
"raw-attribute": "raw-attribute-value",
}
div {
class: "hidden-attribute-div",
hidden: true,
}
div {
class: "dangerous-inner-html-div",
dangerous_inner_html: "<p>hello dangerous inner html</p>",
}
input {
value: "hello input",
}
div {
class: "style-div",
color: "red",
"colored text"
}
button {
class: "eval-button",
onclick: move |_| {
// Set the window title
let result = eval(r#"window.document.title = 'Hello from Dioxus Eval!';
return "returned eval value";"#.to_string());
if let Ok(serde_json::Value::String(string)) = result.get() {
eval_result.set(string);
}
},
"Eval"
}
div {
class: "eval-result",
"{eval_result}"
}
})
}
fn main() {
dioxus_web::launch(app);
}