test(tauri): add native WebView interaction harness (#532)
This commit is contained in:
parent
e7c408a28f
commit
780221b006
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
377
desktop/Cargo.lock
generated
377
desktop/Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
20
desktop/tauri.native-test.conf.json
Normal file
20
desktop/tauri.native-test.conf.json
Normal 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
|
||||
}
|
||||
}
|
||||
13
package.json
13
package.json
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
45
tests/e2e/native/clipboard.spec.ts
Normal file
45
tests/e2e/native/clipboard.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
76
tests/e2e/native/layer-drag.spec.ts
Normal file
76
tests/e2e/native/layer-drag.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
45
tests/e2e/native/text-editing.spec.ts
Normal file
45
tests/e2e/native/text-editing.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
22
tests/e2e/native/tsconfig.json
Normal file
22
tests/e2e/native/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
98
tests/helpers/tauri/clipboard-mock.ts
Normal file
98
tests/helpers/tauri/clipboard-mock.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
82
tests/helpers/tauri/editor-snapshot.ts
Normal file
82
tests/helpers/tauri/editor-snapshot.ts
Normal 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)
|
||||
}
|
||||
202
tests/helpers/tauri/event-recorder.ts
Normal file
202
tests/helpers/tauri/event-recorder.ts
Normal 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
40
tests/helpers/tauri/native-global.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
103
tests/helpers/tauri/windows-input.ts
Normal file
103
tests/helpers/tauri/windows-input.ts
Normal 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
39
wdio.conf.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in a new issue