test(tauri): add native WebView interaction harness (#532)

This commit is contained in:
Danila Poyarkov 2026-08-15 14:17:05 +03:00 committed by GitHub
parent e7c408a28f
commit 780221b006
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2173 additions and 199 deletions

View file

@ -31,6 +31,31 @@ jobs:
- name: Build Storybook
run: bun run build-storybook
native-test-contracts:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: desktop -> target
key: native-test
- name: Install Tauri dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev
- uses: ./.github/actions/setup-bun
- name: Typecheck native tests
run: bun run check:native-test
- name: Check test-only Tauri feature
run: cargo check --manifest-path desktop/Cargo.toml --features native-test
unit-tests:
runs-on: ubuntu-latest
strategy:

View file

@ -235,6 +235,12 @@ Self-review checklist:
- If upstream docs contradict local patterns, prefer current upstream APIs and update local wrappers deliberately.
- `es-toolkit` is available in core for small, focused utility helpers when it clearly improves readability. Prefer subpath imports such as `es-toolkit/object`, `es-toolkit/array`, and `es-toolkit/predicate`; good fits include `omit` / `pick` for object key selection, `uniq` for dedupe, and `isNotNil` for typed nullish filtering. Do not replace clear native JavaScript just for consistency, and avoid `es-toolkit/compat` unless deliberately migrating lodash-compatible behavior.
### Native WebView tests
Native desktop interaction checks live under `tests/e2e/native/**` and run through WebdriverIO against an explicit test-only Tauri binary. Use `bun run test:native` to build and run them, or `bun run build:native-test` when only the binary is needed. The embedded WebDriver plugin is compiled only with the `native-test` Cargo feature and must never be enabled in normal development or production binaries.
Keep responsibilities distinct: engine tests cover state contracts, Playwright browser E2E covers application integration, and native tests answer only whether the real platform WebView and Tauri shell deliver an interaction correctly. Platform-limited checks must skip rather than claim coverage. Synthetic composition tests do not prove real IME behavior, and native clipboard behavior remains a separate acceptance gap unless the test receives trusted OS clipboard events.
## Rendering
- Canvas is CanvasKit (Skia WASM) on a WebGL surface, not DOM

1016
bun.lock

File diff suppressed because it is too large Load diff

377
desktop/Cargo.lock generated
View file

@ -237,7 +237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"glib 0.18.5",
"libc",
]
@ -247,10 +247,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]]
@ -265,6 +265,58 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[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",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "base64"
version = "0.21.7"
@ -394,7 +446,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.11.0",
"cairo-sys-rs",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
"thiserror 1.0.69",
@ -406,9 +458,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]]
@ -498,7 +550,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.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [
"smallvec",
"target-lexicon 0.13.5",
]
[[package]]
@ -1384,7 +1446,7 @@ dependencies = [
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"pango",
]
@ -1397,7 +1459,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"once_cell",
]
@ -1408,11 +1470,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]]
@ -1423,13 +1485,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]]
@ -1439,11 +1501,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]]
@ -1455,7 +1517,7 @@ dependencies = [
"gdk",
"gdkx11-sys",
"gio",
"glib",
"glib 0.18.5",
"libc",
"x11",
]
@ -1467,9 +1529,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",
]
@ -1554,8 +1616,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",
@ -1569,13 +1631,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.8",
"windows-sys 0.61.2",
]
[[package]]
name = "glib"
version = "0.18.5"
@ -1588,10 +1663,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",
@ -1599,6 +1674,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"
@ -1613,6 +1709,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.4.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
@ -1620,7 +1729,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.8",
]
[[package]]
@ -1635,9 +1754,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.8",
]
[[package]]
@ -1653,7 +1783,7 @@ dependencies = [
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"glib 0.18.5",
"gtk-sys",
"gtk3-macros",
"libc",
@ -1671,12 +1801,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]]
@ -1826,6 +1956,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
@ -1839,6 +1975,7 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@ -2158,7 +2295,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",
]
@ -2168,10 +2305,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]]
@ -2320,7 +2457,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",
@ -2435,6 +2572,12 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.0"
@ -2969,6 +3112,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-wdio-webdriver",
"which",
"zip 2.4.2",
"zstd",
@ -3027,7 +3171,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",
@ -3039,10 +3183,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]]
@ -3819,8 +3963,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",
@ -4174,6 +4318,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"
@ -4407,7 +4562,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
dependencies = [
"futures-channel",
"gio",
"glib",
"glib 0.18.5",
"libc",
"soup3-sys",
]
@ -4418,11 +4573,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]]
@ -4536,13 +4691,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.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
dependencies = [
"cfg-expr 0.20.8",
"heck 0.5.0",
"pkg-config",
"toml 1.0.6+spec-1.1.0",
"version-compare",
]
[[package]]
name = "tao"
version = "0.34.5"
@ -4611,6 +4779,12 @@ version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tauri"
version = "2.10.2"
@ -4900,6 +5074,39 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "tauri-plugin-wdio-webdriver"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ad665bda48567107b6a3d070fe6d58a98e21f3ebf52abe41308fa4d5919f61b"
dependencies = [
"async-trait",
"axum",
"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",
"webkit2gtk",
"webview2-com",
"windows",
"windows-core 0.61.2",
]
[[package]]
name = "tauri-runtime"
version = "2.10.0"
@ -5146,9 +5353,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@ -5199,6 +5418,21 @@ dependencies = [
"winnow 0.7.14",
]
[[package]]
name = "toml"
version = "1.0.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc"
dependencies = [
"indexmap 2.13.0",
"serde_core",
"serde_spanned 1.0.4",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.14",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
@ -5217,6 +5451,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_datetime"
version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
@ -5281,6 +5524,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -5319,6 +5563,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@ -5817,10 +6062,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",
@ -5839,15 +6084,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]]

View file

@ -5,6 +5,10 @@ description = "OpenPencil desktop app"
authors = ["Danila Poyarkov"]
edition = "2021"
[features]
default = []
native-test = ["dep:tauri-plugin-wdio-webdriver"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[[bin]]
@ -36,6 +40,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-clipboard-manager = "2"
keyring = "4.1.5"
tauri-plugin-wdio-webdriver = { version = "1.3.0", optional = true }
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-single-instance = "2"

View file

@ -117,7 +117,15 @@ pub fn run() {
let mut builder = tauri::Builder::default();
#[cfg(any(target_os = "macos", windows, target_os = "linux"))]
#[cfg(feature = "native-test")]
{
builder = builder.plugin(tauri_plugin_wdio_webdriver::init());
}
#[cfg(all(
any(target_os = "macos", windows, target_os = "linux"),
not(feature = "native-test")
))]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
queue_open_paths(app, open_paths_from_args(args, Path::new(&cwd)));

View file

@ -0,0 +1,20 @@
{
"$schema": "https://schema.tauri.app/config/2",
"build": {
"beforeBuildCommand": "bun run generate:tauri-menu && vite build --mode native-test"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "OpenPencil Native Test",
"width": 1280,
"height": 800,
"dragDropEnabled": false
}
]
},
"bundle": {
"active": false
}
}

View file

@ -25,16 +25,19 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"tauri": "tauri",
"build:native-test": "bun run build:packages && bun tauri build --debug --no-bundle --features native-test --config desktop/tauri.native-test.conf.json",
"test:native": "bun run build:native-test && wdio run wdio.conf.ts",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/scene-graph/src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ packages/pen/src/ packages/kiwi/src/ packages/fig/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:docs && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:deps": "knip --include unlisted,unresolved,binaries",
"check:docs": "bun --filter @open-pencil/docs check",
"check:audit": "bun audit --audit-level=critical",
"check:secrets": "bun tools/secret-scan/src/index.ts",
"check:monorepo": "sherif --ignore-rule root-package-dependencies",
"check:native-test": "tsc --noEmit -p tests/e2e/native/tsconfig.json",
"check:i18n": "bun tools/i18n/src/check-locales.ts",
"check:packages": "bun tools/package-quality/src/check/metadata.ts && bun tools/package-quality/src/check/publint.ts && bun tools/package-quality/src/check/attw.ts",
"check:arch": "steiger .",
@ -153,8 +156,15 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
"@vitejs/plugin-vue": "^6.0.5",
"@wdio/cli": "9.30.1",
"@wdio/globals": "9.29.1",
"@wdio/local-runner": "9.30.1",
"@wdio/mocha-framework": "9.30.1",
"@wdio/spec-reporter": "9.30.1",
"@wdio/tauri-service": "1.3.0",
"cloudflare-redirect-parser": "^1.0.0",
"esbuild": "^0.27.3",
"expect-webdriverio": "5.7.0",
"fake-indexeddb": "^6.2.5",
"franc": "^6.2.0",
"hono": "^4.12.5",
@ -185,7 +195,6 @@
"ws": "^8.19.0"
},
"overrides": {
"brace-expansion": "5.0.6",
"protobufjs": "7.5.5",
"websocket-driver": "0.7.5"
}

View file

@ -2,86 +2,19 @@ import type { Page } from '@playwright/test'
import { expect, test } from '#tests/e2e/fixtures'
import { CanvasHelper } from '#tests/helpers/canvas'
import { installTauriClipboardMock } from '#tests/helpers/tauri/clipboard-mock'
type PageChildSummary = {
name: string
type: string
}
async function installTauriClipboardMock(page: Page) {
await page.addInitScript(() => {
const state = {
text: '',
html: '',
writes: [] as Array<{ cmd: string; args: unknown }>
}
Object.defineProperty(window, '__OPENPENCIL_TEST_CLIPBOARD__', {
configurable: true,
value: state
})
let callbackId = 1
const callbacks = new Map<number, unknown>()
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {
callbacks,
metadata: {
currentWindow: { label: 'main' },
currentWebview: { label: 'main' }
},
convertFileSrc: (filePath: string) => filePath,
invoke: async (cmd: string, args?: Record<string, unknown>) => {
state.writes.push({ cmd, args })
switch (cmd) {
case 'plugin:clipboard-manager|write_html': {
state.html = String(args?.html ?? '')
state.text = String(args?.altText ?? state.html)
return null
}
case 'plugin:clipboard-manager|write_text': {
state.text = String(args?.text ?? '')
state.html = ''
return null
}
case 'plugin:clipboard-manager|read_text':
return state.html || state.text
case 'list_system_fonts':
case 'take_pending_open':
return []
case 'load_system_font':
return null
case 'plugin:event|listen':
case 'plugin:event|unlisten':
case 'plugin:process|exit':
case 'plugin:process|restart':
return null
default:
return null
}
},
transformCallback: (callback: unknown) => {
const id = callbackId++
callbacks.set(id, callback)
return id
},
unregisterCallback: (id: number) => {
callbacks.delete(id)
},
runCallback: () => null
}
})
})
}
async function createTauriEditorPage(page: Page) {
await installTauriClipboardMock(page)
const clipboard = await installTauriClipboardMock(page)
await page.goto('/')
const canvas = new CanvasHelper(page)
await canvas.waitForInit()
return canvas
return { canvas, clipboard }
}
function pageChildren(page: Page): Promise<PageChildSummary[]> {
@ -103,10 +36,6 @@ function selectedCount(page: Page): Promise<number> {
})
}
function testClipboard(page: Page) {
return page.evaluate(() => window.__OPENPENCIL_TEST_CLIPBOARD__)
}
async function dispatchClipboardEvent(
page: Page,
type: 'copy' | 'cut' | 'paste',
@ -125,30 +54,26 @@ async function dispatchClipboardEvent(
test('Tauri copy writes selected design HTML without requiring ClipboardEvent.clipboardData', async ({
page
}) => {
const canvas = await createTauriEditorPage(page)
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
await dispatchClipboardEvent(page, 'copy')
await expect
.poll(() => testClipboard(page))
.toMatchObject({
text: 'Rectangle'
})
const clipboard = await testClipboard(page)
expect(clipboard.html).toContain('(figma)')
expect(
clipboard.writes.some((entry) => entry.cmd === 'plugin:clipboard-manager|write_html')
).toBe(true)
await expect.poll(() => clipboard.snapshot()).toMatchObject({ text: 'Rectangle' })
const snapshot = clipboard.snapshot()
expect(snapshot.html).toContain('(figma)')
expect(snapshot.writes.some((entry) => entry.cmd === 'plugin:clipboard-manager|write_html')).toBe(
true
)
await expect(page.getByText('Clipboard access is blocked in this browser context')).toHaveCount(0)
canvas.assertNoErrors()
})
test('Tauri paste restores copied design data from plugin clipboard text', async ({ page }) => {
const canvas = await createTauriEditorPage(page)
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
await dispatchClipboardEvent(page, 'copy')
await expect.poll(() => testClipboard(page)).toMatchObject({ text: 'Rectangle' })
await expect.poll(() => clipboard.snapshot()).toMatchObject({ text: 'Rectangle' })
expect(await pageChildren(page)).toHaveLength(1)
await canvas.deleteSelection()
@ -164,27 +89,44 @@ test('Tauri paste restores copied design data from plugin clipboard text', async
canvas.assertNoErrors()
})
test('Tauri cut writes design data and deletes selection only after clipboard write succeeds', async ({
page
}) => {
const canvas = await createTauriEditorPage(page)
test('Tauri cut writes design data and deletes the selection', async ({ page }) => {
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
await dispatchClipboardEvent(page, 'cut')
await canvas.waitForRender()
await expect.poll(() => pageChildren(page)).toEqual([])
const clipboard = await testClipboard(page)
expect(clipboard.html).toContain('(figma)')
expect(clipboard.text).toBe('Rectangle')
const snapshot = clipboard.snapshot()
expect(snapshot.html).toContain('(figma)')
expect(snapshot.text).toBe('Rectangle')
await expect(page.getByText('Clipboard access is blocked in this browser context')).toHaveCount(0)
canvas.assertNoErrors()
})
test('Tauri clipboard events from editable fields keep native text behavior', async ({ page }) => {
const canvas = await createTauriEditorPage(page)
test('Tauri cut keeps the selection when the clipboard write fails', async ({ page }) => {
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
const before = await testClipboard(page)
clipboard.rejectHTMLWrites()
await dispatchClipboardEvent(page, 'cut')
await canvas.waitForRender()
await expect.poll(() => pageChildren(page)).toEqual([{ name: 'Rectangle', type: 'RECTANGLE' }])
expect(await selectedCount(page)).toBe(1)
const snapshot = clipboard.snapshot()
expect(snapshot.html).toBe('')
expect(snapshot.text).toBe('')
expect(snapshot.writes.some((entry) => entry.cmd === 'plugin:clipboard-manager|write_html')).toBe(
true
)
canvas.assertNoErrors()
})
test('Tauri clipboard events from editable fields keep native text behavior', async ({ page }) => {
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
const before = clipboard.snapshot()
await page.evaluate(() => {
const host = document.createElement('div')
@ -194,21 +136,21 @@ test('Tauri clipboard events from editable fields keep native text behavior', as
})
await dispatchClipboardEvent(page, 'copy', '[data-clipboard-probe="input"]')
expect(await testClipboard(page)).toEqual(before)
expect(clipboard.snapshot()).toEqual(before)
await dispatchClipboardEvent(page, 'copy', '[data-clipboard-probe="textarea"]')
expect(await testClipboard(page)).toEqual(before)
expect(clipboard.snapshot()).toEqual(before)
await dispatchClipboardEvent(page, 'cut', '[contenteditable] span')
expect(await testClipboard(page)).toEqual(before)
expect(clipboard.snapshot()).toEqual(before)
await dispatchClipboardEvent(page, 'paste', '[contenteditable] span')
expect(await testClipboard(page)).toEqual(before)
expect(clipboard.snapshot()).toEqual(before)
expect(await pageChildren(page)).toHaveLength(1)
})
test('Tauri context-menu copy uses plugin clipboard fallback instead of blocked browser command', async ({
page
}) => {
const canvas = await createTauriEditorPage(page)
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
const box = await canvas.canvas.boundingBox()
@ -216,7 +158,7 @@ test('Tauri context-menu copy uses plugin clipboard fallback instead of blocked
await page.mouse.click(box.x + 190, box.y + 190, { button: 'right' })
await page.getByTestId('context-copy').click()
await expect.poll(() => testClipboard(page)).toMatchObject({ text: 'Rectangle' })
await expect.poll(() => clipboard.snapshot()).toMatchObject({ text: 'Rectangle' })
await expect(page.getByText('Clipboard access is blocked in this browser context')).toHaveCount(0)
canvas.assertNoErrors()
})

View file

@ -0,0 +1,45 @@
import { strict as assert } from 'node:assert'
import { withNativeEventRecorder } from '#tests/helpers/tauri/event-recorder'
describe('native clipboard routing', () => {
it('keeps copy and paste inside a focused editable field', async function () {
if (process.platform !== 'win32') this.skip()
await browser.waitUntil(
async () => browser.execute(() => Boolean(window.openPencil?.getStore?.())),
{ timeout: 30_000, timeoutMsg: 'OpenPencil editor did not initialize' }
)
await browser.execute(() => {
const input = document.createElement('input')
input.setAttribute('data-test-id', 'native-clipboard-input')
input.value = 'Native clipboard text'
document.body.append(input)
})
const input = await $('[data-test-id="native-clipboard-input"]')
await input.waitForExist()
await withNativeEventRecorder(async (recorder) => {
await input.click()
await browser.keys(['Control', 'a'])
await recorder.clear()
await browser.keys(['Control', 'c'])
await browser.keys(['ArrowRight'])
await browser.keys(['Control', 'v'])
await browser.pause(200)
const events = await recorder.read()
const value = await input.getValue()
const clipboardEvents = events.filter(
(event) => event.type === 'copy' || event.type === 'paste'
)
if (clipboardEvents.length === 0 && value === 'Native clipboard text') this.skip()
assert.equal(value, 'Native clipboard textNative clipboard text')
assert.equal(clipboardEvents.filter((event) => event.type === 'copy').length, 1)
assert.equal(clipboardEvents.filter((event) => event.type === 'paste').length, 1)
assert.ok(
clipboardEvents.every((event) => event.target?.editable && event.isTrusted),
'clipboard events should be trusted and stay on the editable target'
)
})
})
})

View file

@ -0,0 +1,76 @@
import { strict as assert } from 'node:assert'
import {
createNativeLayerFixture,
readNativeLayerOrder
} from '#tests/helpers/tauri/editor-snapshot'
import { withNativeEventRecorder } from '#tests/helpers/tauri/event-recorder'
import { nativeDrag, readElementClientGeometry } from '#tests/helpers/tauri/windows-input'
describe('native layer dragging', () => {
it('delivers a WebView drag sequence and reorders once', async function () {
if (process.platform !== 'win32') this.skip()
await browser.waitUntil(
async () => browser.execute(() => Boolean(window.openPencil?.getStore?.())),
{ timeout: 30_000, timeoutMsg: 'OpenPencil editor did not initialize' }
)
const ids = await createNativeLayerFixture()
const source = await $(`[data-node-id="${ids.third}"] [data-test-id="layers-item"]`)
const target = await $(`[data-node-id="${ids.first}"] [data-test-id="layers-item"]`)
await source.waitForExist()
await target.waitForExist()
await withNativeEventRecorder(async (recorder) => {
await recorder.clear()
const sourceGeometry = await readElementClientGeometry(
`[data-node-id="${ids.third}"] [data-test-id="layers-item"]`
)
const targetGeometry = await readElementClientGeometry(
`[data-node-id="${ids.first}"] [data-test-id="layers-item"]`
)
await nativeDrag(
{
x: sourceGeometry.x + Math.round(sourceGeometry.width / 2),
y: sourceGeometry.y + Math.round(sourceGeometry.height / 2)
},
{
x: targetGeometry.x + Math.round(targetGeometry.width / 2),
y: targetGeometry.y + 2
}
)
await browser.pause(1_000)
const events = await recorder.read()
const order = await readNativeLayerOrder()
assert.deepEqual(order, [ids.third, ids.first, ids.second])
assert.ok(
events.some((event) => event.type === 'pointerdown'),
'expected pointerdown'
)
assert.ok(
events.some((event) => event.type === 'dragstart'),
'expected dragstart'
)
assert.ok(
events.some((event) => event.type === 'dragover'),
'expected dragover'
)
assert.ok(
events.some((event) => event.type === 'drop'),
'expected drop'
)
assert.ok(
events.some((event) => event.type === 'dragend'),
'expected dragend'
)
assert.ok(
events
.filter((event) =>
['pointerdown', 'dragstart', 'dragover', 'drop', 'dragend'].includes(event.type)
)
.every((event) => event.isTrusted),
'native drag events should be trusted'
)
})
})
})

View file

@ -0,0 +1,45 @@
import { strict as assert } from 'node:assert'
import {
createNativeTextFixture,
readNativeEditorSnapshot
} from '#tests/helpers/tauri/editor-snapshot'
import { withNativeEventRecorder } from '#tests/helpers/tauri/event-recorder'
describe('native text editing', () => {
it('commits ordinary WebView input exactly once', async () => {
await browser.waitUntil(
async () => browser.execute(() => Boolean(window.openPencil?.getStore?.())),
{ timeout: 30_000, timeoutMsg: 'OpenPencil editor did not initialize' }
)
await createNativeTextFixture('Replace me')
const textarea = await $('textarea[aria-hidden="true"]')
await textarea.waitForExist()
await withNativeEventRecorder(async (recorder) => {
await recorder.clear()
await textarea.click()
await textarea.addValue('Typed once')
await browser.waitUntil(
async () => (await readNativeEditorSnapshot()).editingText === 'Typed once',
{ timeout: 10_000, timeoutMsg: 'Native text input did not reach the graph' }
)
const snapshot = await readNativeEditorSnapshot()
const events = await recorder.read()
assert.equal(snapshot.editingText, 'Typed once')
assert.equal(snapshot.editingNodeExists, true)
assert.equal(snapshot.textNodeCount, 1)
assert.ok(
events.some((event) => ['beforeinput', 'input', 'keydown', 'keyup'].includes(event.type)),
'expected a WebDriver input or keyboard event'
)
assert.equal(
events.filter((event) => event.type === 'input').length,
1,
'WebDriver text insertion must produce one input event'
)
})
})
})

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node", "vite/client", "@wdio/globals/types", "@wdio/mocha-framework"],
"baseUrl": "../../..",
"paths": {
"#tests/*": ["tests/*"]
}
},
"include": [
"./**/*.ts",
"../../helpers/tauri/native-global.d.ts",
"../../helpers/tauri/editor-snapshot.ts",
"../../helpers/tauri/event-recorder.ts"
]
}

View file

@ -0,0 +1,98 @@
import type { Page } from '@playwright/test'
type ClipboardWrite = {
args: unknown
cmd: string
}
export type TauriClipboardSnapshot = {
html: string
text: string
writes: ClipboardWrite[]
}
export type TauriClipboardMock = {
rejectHTMLWrites: () => void
snapshot: () => TauriClipboardSnapshot
}
const IPC_BINDING = '__playwrightTauriIPC'
export async function installTauriClipboardMock(page: Page): Promise<TauriClipboardMock> {
const state: TauriClipboardSnapshot = {
text: '',
html: '',
writes: []
}
let rejectHTMLWrites = false
await page.exposeBinding(IPC_BINDING, (_, cmd: string, args?: Record<string, unknown>) => {
state.writes.push({ cmd, args })
switch (cmd) {
case 'plugin:clipboard-manager|write_html': {
if (rejectHTMLWrites) throw new Error('Clipboard write failed')
state.html = String(args?.html ?? '')
state.text = String(args?.altText ?? state.html)
return null
}
case 'plugin:clipboard-manager|write_text': {
state.text = String(args?.text ?? '')
state.html = ''
return null
}
case 'plugin:clipboard-manager|read_text':
return state.html || state.text
case 'list_system_fonts':
case 'take_pending_open':
return []
case 'load_system_font':
case 'plugin:event|listen':
case 'plugin:event|unlisten':
case 'plugin:process|exit':
case 'plugin:process|restart':
return null
default:
throw new Error(`Unexpected Tauri command: ${cmd}`)
}
})
await page.addInitScript((bindingName) => {
const invoke = (
globalThis as typeof globalThis & {
[key: string]: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>
}
)[bindingName]
if (!invoke) throw new Error(`Playwright binding not found: ${bindingName}`)
let callbackId = 1
const callbacks = new Map<number, unknown>()
Object.defineProperty(window, '__TAURI_INTERNALS__', {
configurable: true,
value: {
callbacks,
metadata: {
currentWindow: { label: 'main' },
currentWebview: { label: 'main' }
},
convertFileSrc: (filePath: string) => filePath,
invoke,
transformCallback: (callback: unknown) => {
const id = callbackId++
callbacks.set(id, callback)
return id
},
unregisterCallback: (id: number) => {
callbacks.delete(id)
},
runCallback: () => null
}
})
}, IPC_BINDING)
return {
rejectHTMLWrites: () => {
rejectHTMLWrites = true
},
snapshot: () => structuredClone(state)
}
}

View file

@ -0,0 +1,82 @@
export type NativeLayerFixture = {
first: string
second: string
third: string
}
export async function createNativeLayerFixture(): Promise<NativeLayerFixture> {
return browser.execute(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil editor is not ready')
const pageId = store.state.currentPageId
for (const node of store.graph.getChildren(pageId)) store.graph.deleteNode(node.id)
const first = store.graph.createNode('RECTANGLE', pageId, { name: 'Native Layer A' })
const second = store.graph.createNode('RECTANGLE', pageId, { name: 'Native Layer B' })
const third = store.graph.createNode('RECTANGLE', pageId, { name: 'Native Layer C' })
store.requestRender()
return { first: first.id, second: second.id, third: third.id }
})
}
export async function readNativeLayerOrder(parentId?: string): Promise<string[]> {
return browser.execute((id) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil editor is not ready')
return store.graph.getNode(id ?? store.state.currentPageId)?.childIds ?? []
}, parentId)
}
export type NativeEditorSnapshot = {
editingTextId: string | null
selectedIds: string[]
renderVersion: number
sceneVersion: number
editingText: string | null
editingNodeExists: boolean
textNodeCount: number
}
export async function readNativeEditorSnapshot(): Promise<NativeEditorSnapshot> {
return browser.execute(() => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil editor is not ready')
const editingTextId = store.state.editingTextId
const node = editingTextId ? store.graph.getNode(editingTextId) : null
let textNodeCount = 0
for (const pageNode of store.graph.getChildren(store.state.currentPageId)) {
if (pageNode.type === 'TEXT') textNodeCount++
}
return {
editingTextId,
selectedIds: [...store.state.selectedIds],
renderVersion: store.state.renderVersion,
sceneVersion: store.state.sceneVersion,
editingText: node?.type === 'TEXT' ? (node.text ?? '') : null,
editingNodeExists: editingTextId !== null && node !== undefined,
textNodeCount
}
})
}
export async function createNativeTextFixture(text = ''): Promise<string> {
return browser.execute((initialText) => {
const store = window.openPencil?.getStore?.()
if (!store) throw new Error('OpenPencil editor is not ready')
for (const node of store.graph.getChildren(store.state.currentPageId)) {
store.graph.deleteNode(node.id)
}
const id = store.createShape('TEXT', 120, 120, 320, 60)
store.graph.updateNode(id, {
text: initialText,
fontSize: 32,
fontFamily: 'Inter',
textAutoResize: 'WIDTH_AND_HEIGHT',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, visible: true, opacity: 1 }]
})
store.select([id])
store.startTextEditing(id)
store.textEditor?.selectAll()
store.requestRender()
return id
}, text)
}

View file

@ -0,0 +1,202 @@
export type NativeEventTarget = {
tag: string
testId: string | null
slot: string | null
nodeId: string | null
editable: boolean
}
export type NativeEventRecord = {
sequence: number
type: string
timeStamp: number
isTrusted: boolean
defaultPrevented: boolean
target: NativeEventTarget | null
pointerId?: number
pointerType?: string
button?: number
buttons?: number
clientX?: number
clientY?: number
detail?: number
key?: string
code?: string
repeat?: boolean
inputType?: string
data?: string | null
isComposing?: boolean
}
export type NativeEventRecorder = {
clear: () => Promise<void>
read: () => Promise<NativeEventRecord[]>
stop: () => Promise<void>
}
type NativeEventRecorderState = {
events: NativeEventRecord[]
listeners: Array<{ type: string; listener: EventListener }>
sequence: number
}
type NativeEventRecorderWindow = Window & {
__OPENPENCIL_NATIVE_EVENT_RECORDER__?: NativeEventRecorderState
}
const EVENT_TYPES = [
'pointerdown',
'pointermove',
'pointerup',
'click',
'dblclick',
'dragstart',
'dragenter',
'dragover',
'drop',
'dragend',
'focusin',
'focusout',
'keydown',
'keyup',
'beforeinput',
'input',
'compositionstart',
'compositionupdate',
'compositionend',
'copy',
'cut',
'paste'
] as const
const MAX_EVENT_RECORDS = 2_000
async function installRecorder(): Promise<void> {
await browser.execute(
(eventTypes, maxRecords) => {
const recorderWindow = window as NativeEventRecorderWindow
const existing = recorderWindow.__OPENPENCIL_NATIVE_EVENT_RECORDER__
if (existing) {
for (const { type, listener } of existing.listeners) {
window.removeEventListener(type, listener, true)
}
}
const state = {
events: [] as NativeEventRecord[],
listeners: [] as Array<{ type: string; listener: EventListener }>,
sequence: 0
}
const targetSummary = (target: EventTarget | null): NativeEventTarget | null => {
if (!(target instanceof Element)) return null
const editable =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target.closest('[contenteditable="true"], .cm-editor') !== null
const identityTarget =
target.closest('[data-test-id], [data-slot], [data-node-id]') ?? target
return {
tag: target.tagName,
testId: identityTarget.getAttribute('data-test-id'),
slot: identityTarget.getAttribute('data-slot'),
nodeId: identityTarget.closest('[data-node-id]')?.getAttribute('data-node-id') ?? null,
editable
}
}
const record = (event: Event) => {
const pointer = event instanceof PointerEvent ? event : null
const mouse = event instanceof MouseEvent ? event : null
const keyboard = event instanceof KeyboardEvent ? event : null
const input = event instanceof InputEvent ? event : null
const composition = event instanceof CompositionEvent ? event : null
const entry: NativeEventRecord = {
sequence: state.sequence++,
type: event.type,
timeStamp: event.timeStamp,
isTrusted: event.isTrusted,
defaultPrevented: event.defaultPrevented,
target: targetSummary(event.target)
}
if (pointer) {
entry.pointerId = pointer.pointerId
entry.pointerType = pointer.pointerType
}
if (mouse) {
entry.button = mouse.button
entry.buttons = mouse.buttons
entry.clientX = mouse.clientX
entry.clientY = mouse.clientY
entry.detail = mouse.detail
}
if (keyboard) {
entry.key = keyboard.key
entry.code = keyboard.code
entry.repeat = keyboard.repeat
entry.isComposing = keyboard.isComposing
}
if (input) {
entry.inputType = input.inputType
entry.data = input.data
entry.isComposing = input.isComposing
} else if (composition) {
entry.data = composition.data
}
state.events.push(entry)
if (state.events.length > maxRecords) state.events.shift()
}
for (const type of eventTypes) {
const listener: EventListener = record
window.addEventListener(type, listener, true)
state.listeners.push({ type, listener })
}
recorderWindow.__OPENPENCIL_NATIVE_EVENT_RECORDER__ = state
},
EVENT_TYPES,
MAX_EVENT_RECORDS
)
}
export async function startNativeEventRecorder(): Promise<NativeEventRecorder> {
await installRecorder()
return {
async clear() {
await browser.execute(() => {
const state = (window as NativeEventRecorderWindow).__OPENPENCIL_NATIVE_EVENT_RECORDER__
if (!state) return
state.events.length = 0
state.sequence = 0
})
},
async read() {
return browser.execute(() => {
const state = (window as NativeEventRecorderWindow).__OPENPENCIL_NATIVE_EVENT_RECORDER__
return structuredClone(state?.events ?? [])
})
},
async stop() {
await browser.execute(() => {
const recorderWindow = window as NativeEventRecorderWindow
const state = recorderWindow.__OPENPENCIL_NATIVE_EVENT_RECORDER__
if (!state) return
for (const { type, listener } of state.listeners) {
window.removeEventListener(type, listener, true)
}
delete recorderWindow.__OPENPENCIL_NATIVE_EVENT_RECORDER__
})
}
}
}
export async function withNativeEventRecorder<T>(
run: (recorder: NativeEventRecorder) => Promise<T>
): Promise<T> {
const recorder = await startNativeEventRecorder()
try {
return await run(recorder)
} finally {
await recorder.stop()
}
}

40
tests/helpers/tauri/native-global.d.ts vendored Normal file
View file

@ -0,0 +1,40 @@
export {}
type NativeTestNode = {
childIds?: string[]
id: string
name?: string
parentId?: string | null
type: string
text?: string
}
type NativeTestStore = {
state: {
currentPageId: string
editingTextId: string | null
selectedIds: Set<string>
renderVersion: number
sceneVersion: number
}
graph: {
createNode: (type: string, parentId: string, changes: Record<string, unknown>) => NativeTestNode
deleteNode: (id: string) => void
getChildren: (id: string) => NativeTestNode[]
getNode: (id: string) => NativeTestNode | undefined
updateNode: (id: string, changes: Record<string, unknown>) => void
}
createShape: (type: string, x: number, y: number, width: number, height: number) => string
requestRender: () => void
select: (ids: string[]) => void
startTextEditing: (id: string) => void
textEditor?: { selectAll: () => void }
}
declare global {
interface Window {
openPencil?: {
getStore?: () => NativeTestStore
}
}
}

View file

@ -0,0 +1,103 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
interface ClientPoint {
x: number
y: number
}
type ElementClientGeometry = Pick<DOMRect, 'height' | 'width' | 'x' | 'y'>
const USER32_DECLARATION = String.raw`
using System;
using System.Runtime.InteropServices;
public static class NativeInput {
[StructLayout(LayoutKind.Sequential)]
public struct Point {
public int X;
public int Y;
}
[DllImport("user32.dll")]
public static extern bool ClientToScreen(IntPtr window, ref Point point);
[DllImport("user32.dll")]
public static extern uint GetDpiForWindow(IntPtr window);
[DllImport("user32.dll")]
public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")]
public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
public static extern bool SetForegroundWindow(IntPtr window);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr window, int command);
[DllImport("user32.dll")]
public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extraInfo);
}`
const FOREGROUND_SCRIPT = String.raw`
$process = Get-Process OpenPencil -ErrorAction Stop |
Where-Object { $_.MainWindowTitle -eq 'OpenPencil Native Test' } |
Select-Object -First 1
if (-not $process) { throw 'OpenPencil Native Test window not found' }
[NativeInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null
[NativeInput]::ShowWindow($process.MainWindowHandle, 9) | Out-Null
[NativeInput]::SetForegroundWindow($process.MainWindowHandle) | Out-Null
Start-Sleep -Milliseconds 150
$clientOrigin = New-Object NativeInput+Point
if (-not [NativeInput]::ClientToScreen($process.MainWindowHandle, [ref]$clientOrigin)) {
throw 'ClientToScreen failed'
}
$dpi = [NativeInput]::GetDpiForWindow($process.MainWindowHandle)
if ($dpi -eq 0) { throw 'GetDpiForWindow failed' }
$scale = $dpi / 96.0
`
async function runPowerShell(script: string): Promise<void> {
if (process.platform !== 'win32') throw new Error('Win32 input is only available on Windows')
const encoded = Buffer.from(script, 'utf16le').toString('base64')
await execFileAsync('powershell.exe', ['-NoProfile', '-EncodedCommand', encoded])
}
export async function readElementClientGeometry(selector: string): Promise<ElementClientGeometry> {
return browser.execute((targetSelector) => {
const element = document.querySelector(targetSelector)
if (!(element instanceof HTMLElement)) throw new Error(`Element not found: ${targetSelector}`)
const rect = element.getBoundingClientRect()
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
}, selector)
}
export async function nativeDrag(from: ClientPoint, to: ClientPoint): Promise<void> {
const steps = Array.from({ length: 20 }, (_, index) => {
const progress = (index + 1) / 20
return {
x: Math.round(from.x + (to.x - from.x) * progress),
y: Math.round(from.y + (to.y - from.y) * progress)
}
})
const moves = steps
.map(
(point) =>
`$x = [int][Math]::Round($clientOrigin.X + (${point.x} * $scale))\n$y = [int][Math]::Round($clientOrigin.Y + (${point.y} * $scale))\n[NativeInput]::SetCursorPos($x, $y) | Out-Null\nStart-Sleep -Milliseconds 35`
)
.join('\n')
await runPowerShell(`
Add-Type -TypeDefinition '${USER32_DECLARATION}'
${FOREGROUND_SCRIPT}
$x = [int][Math]::Round($clientOrigin.X + (${from.x} * $scale))
$y = [int][Math]::Round($clientOrigin.Y + (${from.y} * $scale))
[NativeInput]::SetCursorPos($x, $y) | Out-Null
Start-Sleep -Milliseconds 100
[NativeInput]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 250
${moves}
Start-Sleep -Milliseconds 250
[NativeInput]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
`)
}

39
wdio.conf.ts Normal file
View file

@ -0,0 +1,39 @@
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { TauriCapabilities } from '@wdio/tauri-service'
const root = dirname(fileURLToPath(import.meta.url))
const binaryName = process.platform === 'win32' ? 'OpenPencil.exe' : 'OpenPencil'
const appBinary = join(root, 'desktop', 'target', 'debug', binaryName)
const capability: TauriCapabilities = {
browserName: 'tauri',
'tauri:options': { application: appBinary }
}
export const config: WebdriverIO.Config = {
runner: 'local',
tsConfigPath: join(root, 'tests', 'e2e', 'native', 'tsconfig.json'),
specs: [join(root, 'tests', 'e2e', 'native', '**', '*.spec.ts')],
maxInstances: 1,
maxInstancesPerCapability: 1,
capabilities: [capability],
services: [
[
'@wdio/tauri-service',
{
appBinaryPath: appBinary,
driverProvider: 'embedded',
startTimeout: 120_000
}
]
],
framework: 'mocha',
reporters: ['spec'],
logLevel: 'warn',
waitforTimeout: 20_000,
connectionRetryTimeout: 120_000,
connectionRetryCount: 1,
mochaOpts: { ui: 'bdd', timeout: 60_000 }
}