chore(testing): add Tauri integration tests and E2E test infrastructure (#3483)

Set up WebDriver-based testing for the Tauri app with two tiers:
- Vitest browser-mode tests (*.tauri.test.ts) running inside the Tauri WebView
  for plugin IPC testing (libsql, smoke tests)
- WDIO E2E tests (*.e2e.ts) for UI-level interaction testing

Key changes:
- Add webdriver Cargo feature gating tauri-plugin-webdriver
- Add runtime capability for remote URLs (webdriver builds only)
- Add vitest.tauri.config.mts and wdio.conf.ts connecting to embedded
  WebDriver server on port 4445
- Add shared tauri-invoke helper for IPC from Vitest iframe context
- Add testing documentation in docs/testing.md
This commit is contained in:
Huang Xin
2026-03-06 02:14:01 +08:00
committed by GitHub
parent 0f5bd5ca1c
commit 13588b4a65
17 changed files with 3606 additions and 131 deletions
Generated
+284 -69
View File
@@ -52,6 +52,7 @@ dependencies = [
"tauri-plugin-sign-in-with-apple",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-webdriver",
"tauri-plugin-websocket",
"tauri-plugin-window-state",
"thiserror 2.0.18",
@@ -411,7 +412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"glib 0.18.5",
"libc",
]
@@ -421,10 +422,10 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -446,7 +447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
dependencies = [
"async-trait",
"axum-core",
"axum-core 0.3.4",
"bitflags 1.3.2",
"bytes",
"futures-util",
@@ -454,7 +455,7 @@ dependencies = [
"http-body 0.4.6",
"hyper 0.14.32",
"itoa",
"matchit",
"matchit 0.7.3",
"memchr",
"mime",
"percent-encoding",
@@ -467,6 +468,39 @@ dependencies = [
"tower-service",
]
[[package]]
name = "axum"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core 0.5.6",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.1",
"hyper-util",
"itoa",
"matchit 0.8.4",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.3.4"
@@ -484,6 +518,25 @@ dependencies = [
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.4.0",
"http-body 1.0.1",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper 1.0.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@@ -752,7 +805,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.11.0",
"cairo-sys-rs",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"thiserror 1.0.69",
@@ -764,9 +817,9 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -863,7 +916,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon",
"target-lexicon 0.12.16",
]
[[package]]
name = "cfg-expr"
version = "0.20.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a"
dependencies = [
"smallvec",
"target-lexicon 0.13.3",
]
[[package]]
@@ -2059,7 +2122,7 @@ dependencies = [
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"pango",
]
@@ -2072,7 +2135,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
]
@@ -2083,11 +2146,11 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2098,13 +2161,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2114,11 +2177,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
dependencies = [
"gdk-sys",
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pkg-config",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2130,7 +2193,7 @@ dependencies = [
"gdk",
"gdkx11-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"x11",
]
@@ -2142,9 +2205,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
dependencies = [
"gdk-sys",
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
"x11",
]
@@ -2252,8 +2315,8 @@ dependencies = [
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"gio-sys 0.18.1",
"glib 0.18.5",
"libc",
"once_cell",
"pin-project-lite",
@@ -2267,13 +2330,26 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
"winapi",
]
[[package]]
name = "gio-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22"
dependencies = [
"glib-sys 0.21.5",
"gobject-sys 0.21.5",
"libc",
"system-deps 7.0.7",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.18.5"
@@ -2286,10 +2362,10 @@ dependencies = [
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-macros 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"memchr",
"once_cell",
@@ -2297,6 +2373,27 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "glib"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
dependencies = [
"bitflags 2.11.0",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys 0.21.5",
"glib-macros 0.21.5",
"glib-sys 0.21.5",
"gobject-sys 0.21.5",
"libc",
"memchr",
"smallvec",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
@@ -2311,6 +2408,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "glib-macros"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17"
dependencies = [
"heck 0.5.0",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
@@ -2318,7 +2428,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
name = "glib-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c"
dependencies = [
"libc",
"system-deps 7.0.7",
]
[[package]]
@@ -2333,9 +2453,20 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys",
"glib-sys 0.18.1",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
name = "gobject-sys"
version = "0.21.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294"
dependencies = [
"glib-sys 0.21.5",
"libc",
"system-deps 7.0.7",
]
[[package]]
@@ -2351,7 +2482,7 @@ dependencies = [
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"glib 0.18.5",
"gtk-sys",
"gtk3-macros",
"libc",
@@ -2369,12 +2500,12 @@ dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"pango-sys",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -2633,6 +2764,7 @@ dependencies = [
"http 1.4.0",
"http-body 1.0.1",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2995,7 +3127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
dependencies = [
"bitflags 1.3.2",
"glib",
"glib 0.18.5",
"javascriptcore-rs-sys",
]
@@ -3005,10 +3137,10 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -3123,7 +3255,7 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
dependencies = [
"glib",
"glib 0.18.5",
"gtk",
"gtk-sys",
"libappindicator-sys",
@@ -3454,6 +3586,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memalloc"
version = "0.1.0"
@@ -3969,6 +4107,16 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-javascript-core"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
dependencies = [
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
@@ -3999,6 +4147,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
]
@@ -4046,6 +4195,8 @@ dependencies = [
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"objc2-javascript-core",
"objc2-security",
]
[[package]]
@@ -4220,7 +4371,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"pango-sys",
@@ -4232,10 +4383,10 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -5257,8 +5408,8 @@ checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk-sys",
"js-sys",
"log",
@@ -5760,6 +5911,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -6046,7 +6208,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
dependencies = [
"futures-channel",
"gio",
"glib",
"glib 0.18.5",
"libc",
"soup3-sys",
]
@@ -6057,11 +6219,11 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"libc",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
@@ -6259,13 +6421,26 @@ version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"cfg-expr 0.15.8",
"heck 0.5.0",
"pkg-config",
"toml 0.8.2",
"version-compare",
]
[[package]]
name = "system-deps"
version = "7.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f"
dependencies = [
"cfg-expr 0.20.6",
"heck 0.5.0",
"pkg-config",
"toml 0.9.12+spec-1.1.0",
"version-compare",
]
[[package]]
name = "tao"
version = "0.34.6"
@@ -6338,6 +6513,12 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "target-lexicon"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
[[package]]
name = "tauri"
version = "2.10.3"
@@ -6852,6 +7033,39 @@ dependencies = [
"zip",
]
[[package]]
name = "tauri-plugin-webdriver"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a130e5cc896b52a87d618b53e1ba025954af7c004b8781a4ddee7298f7b3749a"
dependencies = [
"async-trait",
"axum 0.8.8",
"base64 0.22.1",
"block2",
"cairo-rs",
"glib 0.21.5",
"gtk",
"javascriptcore-rs",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"objc2-web-kit",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
"uuid 1.21.0",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows-core 0.61.2",
]
[[package]]
name = "tauri-plugin-websocket"
version = "2.4.2"
@@ -7341,7 +7555,7 @@ checksum = "76c4eb7a4e9ef9d4763600161f12f5070b92a578e1b634db88a6887844c91a13"
dependencies = [
"async-stream",
"async-trait",
"axum",
"axum 0.6.20",
"base64 0.21.7",
"bytes",
"h2 0.3.27",
@@ -7413,6 +7627,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -7995,10 +8210,10 @@ dependencies = [
"gdk",
"gdk-sys",
"gio",
"gio-sys",
"glib",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib 0.18.5",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk",
"gtk-sys",
"javascriptcore-rs",
@@ -8017,15 +8232,15 @@ dependencies = [
"bitflags 1.3.2",
"cairo-sys-rs",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gio-sys 0.18.1",
"glib-sys 0.18.1",
"gobject-sys 0.18.0",
"gtk-sys",
"javascriptcore-rs-sys",
"libc",
"pkg-config",
"soup3-sys",
"system-deps",
"system-deps 6.2.2",
]
[[package]]
+5 -2
View File
@@ -13,10 +13,13 @@ pnpm tauri dev # Desktop dev with Tauri (compiles Rust backend)
pnpm build # Build Next.js for Tauri
pnpm build-web # Build Next.js for web deployment
# Testing
pnpm test # Run all tests (vitest + jsdom)
# Testing (see [docs/testing.md](docs/testing.md) for full details)
pnpm test # Unit tests (vitest + jsdom)
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single test file
pnpm test -- --watch # Watch mode
pnpm test:browser # Browser tests (Chromium via Playwright)
pnpm tauri:dev:test # Start Tauri app with webdriver
pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # ESLint
+110
View File
@@ -0,0 +1,110 @@
# Testing
Readest uses three test tiers, all powered by [Vitest](https://vitest.dev/).
## Unit Tests (`pnpm test`)
Runs tests in a **jsdom** environment. No browser or Tauri runtime required.
```bash
pnpm test # Run all unit tests
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single file
pnpm test -- --watch # Watch mode
```
- **Config:** `vitest.config.mts`
- **Pattern:** `src/**/*.test.ts` (excludes `*.browser.test.ts` and `*.tauri.test.ts`)
- **Environment:** jsdom
- **Use for:** Pure logic, utilities, services that don't need real browser APIs or Tauri IPC.
## Browser Tests (`pnpm test:browser`)
Runs tests in a **real Chromium** browser via Playwright. Required for code that depends on Web Workers, SharedArrayBuffer, OPFS, or other browser-only APIs.
```bash
pnpm test:browser
```
- **Config:** `vitest.browser.config.mts`
- **Pattern:** `src/**/*.browser.test.ts`
- **Browser:** Chromium (headless, via `@vitest/browser-playwright`)
- **Use for:** WASM modules (e.g. `@tursodatabase/database-wasm`), Web Worker integration, browser-specific storage APIs.
## Tauri Integration Tests (`pnpm test:tauri`)
Runs Vitest tests **inside the Tauri WebView**, with access to Tauri IPC and native plugin commands. Tests execute in the actual app environment.
### Step 1: Start the Tauri App
In one terminal, start the app with the `webdriver` feature enabled:
```bash
pnpm tauri:dev:test # Dev mode (uses tauri dev server, faster iteration)
pnpm tauri:build:test # Debug release build (closer to production)
```
These commands compile the Rust backend with `--features webdriver`, which:
- Includes `tauri-plugin-webdriver` (embeds a W3C WebDriver server on port 4445)
- Adds a runtime capability granting plugin permissions to remote URLs (`http://127.0.0.1:*`), so Vitest's browser-mode iframe can call Tauri IPC
Keep this running while you run tests.
### Step 2: Run Tests
In another terminal:
```bash
pnpm test:tauri
```
Vitest connects directly to the embedded WebDriver server (port 4445) in the running Tauri app and executes tests inside its WebView.
- **Config:** `vitest.tauri.config.mts`
- **Pattern:** `src/**/*.tauri.test.ts`
- **Browser provider:** `@vitest/browser-webdriverio` (connects to port 4445)
- **Use for:** Tauri plugin commands (libsql, native-tts, etc.), native filesystem, Tauri IPC.
### Writing Tauri Tests
Tests access Tauri IPC via a shared helper:
```typescript
import { invoke } from '../tauri/tauri-invoke';
it('calls a plugin command', async () => {
const result = await invoke('plugin:libsql|load', { options: { path: 'sqlite::memory:' } });
expect(result).toBeDefined();
});
```
The `invoke()` helper accesses `window.top.__TAURI_INTERNALS__` (Vitest runs in an iframe, Tauri injects IPC into the main frame).
**Limitations:** Only custom invoke commands and plugin commands listed in the webdriver capability work. Standard Tauri JS APIs (e.g. `@tauri-apps/api`) that rely on `URL: local` may not work from the Vitest iframe.
## E2E Tests (WDIO)
Full end-to-end tests using WebDriverIO, for UI-level testing against the running Tauri app. Same two-step workflow as Tauri integration tests.
```bash
# Terminal 1: start the app (same as for Tauri integration tests)
pnpm tauri:dev:test
# Terminal 2: run E2E tests
pnpm test:e2e
```
- **Config:** `wdio.conf.ts`
- **Pattern:** `e2e/**/*.e2e.ts`
- **Framework:** Mocha (via `@wdio/mocha-framework`)
- **Connects to:** port 4445 (embedded WebDriver server)
- **Use for:** UI interaction tests, window management, navigation flows.
## Test File Naming
| Suffix | Runner | Environment |
| ------------------- | ------------------- | --------------------- |
| `*.test.ts` | `pnpm test` | jsdom |
| `*.browser.test.ts` | `pnpm test:browser` | Chromium (Playwright) |
| `*.tauri.test.ts` | `pnpm test:tauri` | Tauri WebView |
| `*.e2e.ts` | `pnpm test:e2e` | Tauri app (WDIO) |
+123
View File
@@ -0,0 +1,123 @@
describe('Readest App Launch', () => {
it('should have a visible body element', async () => {
const body = await $('body');
await body.waitForDisplayed({ timeout: 10000 });
expect(await body.isDisplayed()).toBe(true);
});
it('should have the correct window handle', async () => {
const handle = await browser.getWindowHandle();
expect(handle).toBeTruthy();
});
it('should return the page source', async () => {
const source = await browser.getPageSource();
expect(source).toContain('html');
});
});
describe('Library Page', () => {
it('should navigate to the library page', async () => {
const url = await browser.getUrl();
expect(url).toMatch(/library|localhost/);
});
it('should display the library container', async () => {
const library = await $('[aria-label="Your Library"]');
await library.waitForExist({ timeout: 15000 });
expect(await library.isExisting()).toBe(true);
});
it('should display the library header', async () => {
const header = await $('[aria-label="Library Header"]');
await header.waitForExist({ timeout: 10000 });
expect(await header.isExisting()).toBe(true);
});
it('should display the bookshelf area', async () => {
const bookshelf = await $('[aria-label="Bookshelf"]');
await bookshelf.waitForExist({ timeout: 10000 });
expect(await bookshelf.isExisting()).toBe(true);
});
it('should have a search input', async () => {
const searchInput = await $('.search-input');
await searchInput.waitForExist({ timeout: 10000 });
expect(await searchInput.isExisting()).toBe(true);
});
it('should allow typing in the search input', async () => {
const searchInput = await $('.search-input');
await searchInput.waitForDisplayed({ timeout: 10000 });
await searchInput.setValue('test search');
const value = await searchInput.getValue();
expect(value).toBe('test search');
});
it('should show the clear search button after typing', async () => {
const clearBtn = await $('[aria-label="Clear Search"]');
await clearBtn.waitForExist({ timeout: 5000 });
expect(await clearBtn.isExisting()).toBe(true);
});
it('should clear the search input when clear button is clicked', async () => {
const clearBtn = await $('[aria-label="Clear Search"]');
await clearBtn.click();
const searchInput = await $('.search-input');
const value = await searchInput.getValue();
expect(value).toBe('');
});
it('should have a select books button', async () => {
const selectBtn = await $('[aria-label="Select Books"]');
await selectBtn.waitForExist({ timeout: 10000 });
expect(await selectBtn.isExisting()).toBe(true);
});
it('should have an import books button', async () => {
const importBtn = await $('[aria-label="Import Books"]');
await importBtn.waitForExist({ timeout: 10000 });
expect(await importBtn.isExisting()).toBe(true);
});
});
describe('Window Management', () => {
it('should return the window size', async () => {
const size = await browser.getWindowSize();
expect(size.width).toBeGreaterThan(0);
expect(size.height).toBeGreaterThan(0);
});
});
describe('JavaScript Execution', () => {
it('should execute JavaScript in the app context', async () => {
const result = await browser.execute(() => {
return document.readyState;
});
expect(result).toBe('complete');
});
it('should access the document title via JS', async () => {
const title = await browser.execute(() => {
return document.title;
});
expect(title).toContain('Readest');
});
it('should detect the app platform globals', async () => {
const hasCLIAccess = await browser.execute(() => {
return (window as unknown as Record<string, unknown>).__READEST_CLI_ACCESS === true;
});
expect(hasCLIAccess).toBe(true);
});
});
describe('Navigation', () => {
it('should navigate back to library after visiting another route', async () => {
const currentUrl = await browser.getUrl();
await browser.url(currentUrl.replace(/\/[^/]*$/, '/library'));
const library = await $('[aria-label="Your Library"]');
await library.waitForExist({ timeout: 15000 });
expect(await library.isExisting()).toBe(true);
});
});
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"types": ["mocha", "@wdio/globals/types"]
},
"include": ["./**/*.ts"]
}
+12
View File
@@ -14,6 +14,10 @@
"lint": "eslint .",
"test": "dotenv -e .env -e .env.test.local vitest",
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
"test:tauri": "vitest --config vitest.tauri.config.mts --watch=false",
"test:e2e": "wdio run wdio.conf.ts",
"tauri:dev:test": "dotenv -e .env.tauri -- tauri dev --features webdriver",
"tauri:build:test": "dotenv -e .env.tauri -- tauri build --debug --features webdriver",
"tauri": "tauri",
"clippy": "cargo clippy -p Readest --no-deps -- -D warnings",
"format": "pnpm -w format",
@@ -170,6 +174,7 @@
"@types/cors": "^2.8.17",
"@types/cssbeautify": "^0.3.5",
"@types/lunr": "^2.3.7",
"@types/mocha": "^10.0.10",
"@types/node": "^22.15.31",
"@types/nunjucks": "^3.2.6",
"@types/react": "^19.0.0",
@@ -184,6 +189,13 @@
"@typescript-eslint/parser": "^8.48.0",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/browser-webdriverio": "^4.0.18",
"@wdio/cli": "^9.24.0",
"@wdio/globals": "^9.23.0",
"@wdio/local-runner": "^9.24.0",
"@wdio/mocha-framework": "^9.24.0",
"@wdio/spec-reporter": "^9.24.0",
"@wdio/types": "^9.24.0",
"autoprefixer": "^10.4.20",
"caniuse-lite": "^1.0.30001746",
"cpx2": "^8.0.0",
+3
View File
@@ -17,6 +17,8 @@ crate-type = ["staticlib", "cdylib", "lib"]
[features]
# Internal feature to suppress warnings from old objc crate
cargo-clippy = []
# Enable WebDriver plugin for E2E testing (use with `tauri build --debug --features webdriver`)
webdriver = ["tauri-plugin-webdriver"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -57,6 +59,7 @@ tauri-plugin-websocket = "2"
tauri-plugin-sharekit = "0.3"
tauri-plugin-device-info = "1.0.1"
tauri-plugin-libsql = { path = "./plugins/tauri-plugin-libsql" }
tauri-plugin-webdriver = { version = "0.2", optional = true }
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
@@ -0,0 +1,23 @@
{
"identifier": "webdriver-testing",
"description": "Grants plugin permissions to remote URLs for Vitest browser-mode tests. Only loaded at runtime when the webdriver feature is enabled.",
"remote": {
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
},
"local": false,
"windows": ["main"],
"permissions": [
"core:default",
"fs:default",
"os:default",
"dialog:default",
"core:window:default",
"core:path:allow-resolve-directory",
"log:default",
"shell:default",
"process:default",
"libsql:default",
"native-tts:default",
"native-bridge:default"
]
}
+11
View File
@@ -228,8 +228,19 @@ pub fn run() {
#[cfg(any(target_os = "ios", target_os = "android"))]
let builder = builder.plugin(tauri_plugin_haptics::init());
#[cfg(feature = "webdriver")]
let builder = builder.plugin(tauri_plugin_webdriver::init());
builder
.setup(|#[allow(unused_variables)] app| {
// When running with the webdriver feature (E2E/integration tests),
// grant all default permissions to remote URLs (http://127.0.0.1:*)
// so that Vitest browser-mode tests can call plugin commands.
#[cfg(feature = "webdriver")]
{
use tauri::Manager;
app.add_capability(include_str!("../capabilities-extra/webdriver.json"))?;
}
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
{
use std::sync::{Arc, Mutex};
@@ -0,0 +1,366 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { invoke } from '../tauri/tauri-invoke';
/**
* Integration tests for tauri-plugin-libsql running inside the Tauri WebView.
* These call plugin IPC commands directly via __TAURI_INTERNALS__.invoke().
*/
describe('libsql plugin (native Tauri)', () => {
const DB_PATH = 'sqlite::memory:';
let dbPath: string;
beforeEach(async () => {
dbPath = (await invoke('plugin:libsql|load', {
options: { path: DB_PATH },
})) as string;
});
afterEach(async () => {
await invoke('plugin:libsql|close', { db: dbPath });
});
// ---------------------------------------------------------------------------
// Connection lifecycle
// ---------------------------------------------------------------------------
it('loads an in-memory database and returns a path', () => {
expect(typeof dbPath).toBe('string');
expect(dbPath.length).toBeGreaterThan(0);
});
// ---------------------------------------------------------------------------
// Schema & basic operations
// ---------------------------------------------------------------------------
it('creates a table and inserts a row', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['apple'],
})) as { rowsAffected: number; lastInsertId: number };
expect(result.rowsAffected).toBe(1);
expect(result.lastInsertId).toBe(1);
});
it('inserts multiple rows with auto-incrementing ids', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
const r1 = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['a'],
})) as { lastInsertId: number };
const r2 = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['b'],
})) as { lastInsertId: number };
const r3 = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['c'],
})) as { lastInsertId: number };
expect(r1.lastInsertId).toBe(1);
expect(r2.lastInsertId).toBe(2);
expect(r3.lastInsertId).toBe(3);
});
// ---------------------------------------------------------------------------
// SELECT queries
// ---------------------------------------------------------------------------
it('select returns typed rows', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, qty INTEGER)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name, qty) VALUES (?, ?)',
values: ['apple', 10],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name, qty) VALUES (?, ?)',
values: ['banana', 20],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM items ORDER BY id',
values: [],
})) as Array<{ id: number; name: string; qty: number }>;
expect(rows).toHaveLength(2);
expect(rows[0]!.name).toBe('apple');
expect(rows[0]!.qty).toBe(10);
expect(rows[1]!.name).toBe('banana');
expect(rows[1]!.qty).toBe(20);
});
it('select with WHERE and params', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['apple'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['banana'],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM items WHERE name = ?',
values: ['banana'],
})) as Array<{ id: number; name: string }>;
expect(rows).toHaveLength(1);
expect(rows[0]!.name).toBe('banana');
});
it('select returns empty array for no matching rows', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM items',
values: [],
})) as unknown[];
expect(rows).toEqual([]);
});
// ---------------------------------------------------------------------------
// UPDATE & DELETE
// ---------------------------------------------------------------------------
it('UPDATE returns rowsAffected', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['old'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['old'],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'UPDATE items SET name = ? WHERE name = ?',
values: ['new', 'old'],
})) as { rowsAffected: number };
expect(result.rowsAffected).toBe(2);
});
it('DELETE returns rowsAffected', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['a'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['b'],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO items (name) VALUES (?)',
values: ['c'],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'DELETE FROM items WHERE name IN (?, ?)',
values: ['a', 'c'],
})) as { rowsAffected: number };
expect(result.rowsAffected).toBe(2);
const remaining = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT name FROM items',
values: [],
})) as Array<{ name: string }>;
expect(remaining).toHaveLength(1);
expect(remaining[0]!.name).toBe('b');
});
// ---------------------------------------------------------------------------
// Batch execution
// ---------------------------------------------------------------------------
it('batch executes multiple statements atomically', async () => {
await invoke('plugin:libsql|batch', {
db: dbPath,
queries: [
'CREATE TABLE t1 (id INTEGER PRIMARY KEY, val TEXT)',
"INSERT INTO t1 (val) VALUES ('one')",
"INSERT INTO t1 (val) VALUES ('two')",
"INSERT INTO t1 (val) VALUES ('three')",
],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT val FROM t1 ORDER BY id',
values: [],
})) as Array<{ val: string }>;
expect(rows).toHaveLength(3);
expect(rows.map((r) => r.val)).toEqual(['one', 'two', 'three']);
});
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
it('handles NULL values correctly', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t (val) VALUES (?)',
values: [null],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM t',
values: [],
})) as Array<{ id: number; val: string | null }>;
expect(rows).toHaveLength(1);
expect(rows[0]!.val).toBeNull();
});
it('handles integer and real types', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE nums (i INTEGER, r REAL)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO nums (i, r) VALUES (?, ?)',
values: [42, 3.14],
});
const rows = (await invoke('plugin:libsql|select', {
db: dbPath,
query: 'SELECT * FROM nums',
values: [],
})) as Array<{ i: number; r: number }>;
expect(rows[0]!.i).toBe(42);
expect(rows[0]!.r).toBeCloseTo(3.14);
});
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
it('throws on invalid SQL', async () => {
await expect(
invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INVALID SQL STATEMENT',
values: [],
}),
).rejects.toThrow();
});
it('throws on constraint violation', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT UNIQUE)',
values: [],
});
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t (val) VALUES (?)',
values: ['unique_val'],
});
await expect(
invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t (val) VALUES (?)',
values: ['unique_val'],
}),
).rejects.toThrow();
});
// ---------------------------------------------------------------------------
// Result contract
// ---------------------------------------------------------------------------
it('execute result always has rowsAffected and lastInsertId as numbers', async () => {
await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'CREATE TABLE t (id INTEGER PRIMARY KEY)',
values: [],
});
const result = (await invoke('plugin:libsql|execute', {
db: dbPath,
query: 'INSERT INTO t DEFAULT VALUES',
values: [],
})) as { rowsAffected: number; lastInsertId: number };
expect(typeof result.rowsAffected).toBe('number');
expect(typeof result.lastInsertId).toBe('number');
});
// ---------------------------------------------------------------------------
// Plugin config
// ---------------------------------------------------------------------------
it('get_config returns encryption status', async () => {
const config = (await invoke('plugin:libsql|get_config')) as { encrypted: boolean };
expect(typeof config.encrypted).toBe('boolean');
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import { getTauri, invoke } from './tauri-invoke';
describe('Tauri Smoke Tests', () => {
it('should have __TAURI_INTERNALS__ available via window.top', () => {
const tauri = getTauri();
expect(tauri).toBeDefined();
expect(typeof tauri.invoke).toBe('function');
});
it('should invoke get_executable_dir', async () => {
const execDir = (await invoke('get_executable_dir')) as string;
expect(typeof execDir).toBe('string');
expect(execDir.length).toBeGreaterThan(0);
});
it('should invoke get_environment_variable for HOME', async () => {
const home = (await invoke('get_environment_variable', { name: 'HOME' })) as string;
expect(typeof home).toBe('string');
expect(home.length).toBeGreaterThan(0);
});
it('should return empty string for non-existent env var', async () => {
const result = await invoke('get_environment_variable', {
name: '__TAURI_SMOKE_TEST_NONEXISTENT__',
});
expect(result).toBe('');
});
it('should invoke get_environment_variable for PATH', async () => {
const pathVar = (await invoke('get_environment_variable', { name: 'PATH' })) as string;
expect(typeof pathVar).toBe('string');
expect(pathVar).toContain('/');
});
it('should get executable dir that contains the app name', async () => {
const execDir = (await invoke('get_executable_dir')) as string;
expect(execDir.toLowerCase()).toMatch(/readest|target/);
});
});
+7
View File
@@ -0,0 +1,7 @@
interface TauriInternals {
invoke(cmd: string, args?: Record<string, unknown>): Promise<unknown>;
}
interface Window {
__TAURI_INTERNALS__: TauriInternals;
}
@@ -0,0 +1,19 @@
// Vitest runs tests inside an iframe. Tauri injects __TAURI_INTERNALS__
// into the main frame only, so we access it via window.top.
export function getTauri(): TauriInternals {
const top = window.top ?? window;
const tauri = (top as unknown as Record<string, unknown>)['__TAURI_INTERNALS__'] as
| TauriInternals
| undefined;
if (!tauri) {
throw new Error(
'__TAURI_INTERNALS__ not found. Are tests running inside a Tauri WebView? ' +
'(checked both window and window.top)',
);
}
return tauri;
}
export function invoke(cmd: string, args?: Record<string, unknown>): Promise<unknown> {
return getTauri().invoke(cmd, args);
}
+1 -1
View File
@@ -7,6 +7,6 @@ export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/*.browser.test.ts'],
exclude: ['**/node_modules/**', '**/dist/**', '**/*.browser.test.ts', '**/*.tauri.test.ts'],
},
});
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';
import { webdriverio } from '@vitest/browser-webdriverio';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
resolve: {
conditions: ['development'],
},
test: {
include: ['src/**/*.tauri.test.ts'],
testTimeout: 30000,
browser: {
enabled: true,
provider: webdriverio({
hostname: '127.0.0.1',
port: 4445,
capabilities: {
browserName: 'chrome',
} as WebdriverIO.Capabilities,
}),
instances: [{ browser: 'chrome' }],
},
},
});
+25
View File
@@ -0,0 +1,25 @@
import type { Options } from '@wdio/types';
export const config: Options.Testrunner & { capabilities: unknown } = {
runner: 'local',
specs: ['./e2e/**/*.e2e.ts'],
maxInstances: 1,
capabilities: [
{
browserName: 'chrome',
} as WebdriverIO.Capabilities,
],
logLevel: 'error',
bail: 0,
waitforTimeout: 10000,
connectionRetryTimeout: 120000,
connectionRetryCount: 3,
hostname: '127.0.0.1',
port: 4445,
framework: 'mocha',
reporters: ['spec'],
mochaOpts: {
ui: 'bdd',
timeout: 60000,
},
};
+2541 -59
View File
File diff suppressed because it is too large Load Diff