@iyulab/tauri-kit-dev
v0.9.0
Published
Development and verification tools for Tauri desktop apps: drive the WebView2 window over CDP, check installers, and keep public text clean. Nothing here ships inside an app.
Readme
tauri-kit-dev
Development and verification tools for desktop apps made with Tauri — the scripts most such apps end up writing for themselves to drive their window in end-to-end runs, check their installers and keep their public repositories clean. Nothing here ships inside an app; for runtime building blocks, see tauri-kit.
npm install --save-dev @iyulab/tauri-kit-devRequires Node 22.4 or later. What it gives an app, and what it deliberately leaves to the app, is in docs/SCOPE.md.
Driving the window over CDP
WebView2 opens a Chrome DevTools Protocol port when its browser starts with
--remote-debugging-port=<port>. How a Tauri app's window gets that argument is under Opening the
debugging port below — the environment variable alone is not enough.
import { writeFile } from 'node:fs/promises'
import { Cdp, findPage } from '@iyulab/tauri-kit-dev/cdp'
const page = await findPage(9222)
const cdp = await Cdp.connect(page.webSocketDebuggerUrl)
await cdp.waitFor(`document.readyState === 'complete'`, 'the page')
await cdp.evaluate(`document.querySelector('input').focus()`)
await cdp.insertText('hello')
await writeFile('window.png', await cdp.screenshot())
cdp.close()portAnswers(port) tells whether something is already listening — a window left over from an
earlier run would otherwise answer in place of the one just started.
Opening the debugging port
App.launch passes the port in WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS (debugEnv). Current WebView2
runtimes do not apply that variable to an app that passes browser arguments of its own, and a Tauri
app always does: its web view library passes defaults. The variable reaches the app's process but not
its browser, the port never opens, and the launch fails — saying so, read from the browser's command
line. The window has to carry the port in its own browser arguments, in one of two ways:
The debug build forwards the variable — the port is still chosen per launch. Declare the window with
"create": false, and open it insetupwith the variable's arguments added to its own:for declared in &app.config().app.windows { let mut window = declared.clone(); // `None` in a release build: an installed app never opens a debugging port because of a variable. if let Some(arguments) = tauri_kit_webview::forwarded_browser_arguments(DEFAULT_ARGUMENTS) { window.additional_browser_args = Some(arguments); } tauri::WebviewWindowBuilder::from_config(app.handle(), &window)?.build()?; }(
forwarded_browser_argumentsis in tauri-kit'stauri-kit-webviewfrom 0.15; before that, read the variable in a debug build and append it the same way.)Giving a window browser arguments replaces the ones the web view library passes by default, so
DEFAULT_ARGUMENTSrepeats them:--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection, and--autoplay-policy=no-user-gesture-requiredwhile autoplay is on.A test configuration carries a fixed port —
tauri dev --config <file>(or a build with that configuration) whose windows have"additionalBrowserArgs"with--remote-debugging-port=<port>and the same defaults. Launch with that port anddebugPortFromEnv: false.
Running scenarios against the app
import { App, runScenarios } from '@iyulab/tauri-kit-dev/app'
const options = {
exe: 'target/debug/my-app.exe',
port: 9224,
ready: `!!document.querySelector('my-app')`, // when the window counts as ready is the app's to say
}
process.exitCode = await runScenarios(
{
'opens the settings': async (app) => {
await app.click('button', 'Settings')
await app.cdp.waitFor(`__e2e.one('h1', 'Settings')`, 'the settings page')
},
'saves a name': async (app) => {
await app.fill('input[aria-label="Name"]', 'Ada')
await app.cdp.press('Enter')
},
},
{ start: async () => ({ app: await App.launch(options) }) },
)Scenarios run in order against one window, each building on what the last left. --through
<part of a name> stops after the first scenario whose name contains it; --only <part of a name>
runs just the scenarios whose names contain it, for ones that stand on their own such as
measurements; --repeat <n> runs that many times, each from a fresh start — for a scenario that
fails only sometimes. E2E_SCREENSHOTS=<dir> saves a picture after each scenario. A failure's
picture is kept either way — in that folder, or else in a new one under the temp folder (the
failures option) — and the run says where.
A scenario that has not finished after five minutes (the scenarioTimeoutMs option) fails with
did not finish within <n> s, and the run goes on to quit the window: a step with no deadline of
its own — a protocol command a window that stopped answering never answers — cannot hold the run
until a CI job's time limit ends it with nothing said. This bounds only what yields to the event
loop: a scenario that runs a program with a blocking call (execFileSync) holds the whole process,
so run programs asynchronously with a timeout of their own.
The window under test reads its bundled resources from the copies a build leaves next to the
executable (target/<profile>/…), and the build copies files in but never removes one: a file
deleted from the source, or left there by a build of another branch, is still read by the
scenarios. Removing the copy before the build makes it the source's again — in src-tauri/build.rs,
for a resource directory bundled as assets:
fn main() {
// Watch the source directory so that a file removed from it runs this again.
println!("cargo:rerun-if-changed=../assets");
// OUT_DIR is target/<profile>/build/<crate>-<hash>/out; three levels up is the profile directory.
if let Some(profile) = std::env::var_os("OUT_DIR").as_deref().map(std::path::Path::new).and_then(|out| out.ancestors().nth(3)) {
let _ = std::fs::remove_dir_all(profile.join("assets"));
}
tauri_build::build()
}Checking the installer
import { join } from 'node:path'
import { findInstaller, startsAndStays, withInstalled } from '@iyulab/tauri-kit-dev/installer'
const installer = findInstaller('target/release/bundle/nsis', { version: '1.2.0' })
await withInstalled(installer, async (target) => {
// installed silently for the current user into a temporary folder
if (!(await startsAndStays(join(target, 'my-app.exe')))) throw new Error('the installed app did not stay up')
}, { exe: 'my-app.exe' }) // uninstalled afterwards, and checked to be goneInstalling over the latest published version (pickUpgradeFrom, downloadInstaller) and checking
that WebView2 profile copies are dropped (profileSnapshots, seedProfileSnapshot) use the same
pieces.
An installed copy keeps its data where the person's own copy does (%LOCALAPPDATA%\<identifier>),
and startsAndStays ends it by force. Wrap a check in withAppDataSetAside so whatever the app writes
there — its identity, session or diagnostic records, the web view profile — does not stay behind for
the next real launch:
import { withAppDataSetAside } from '@iyulab/tauri-kit-dev/installer'
await withAppDataSetAside(join(process.env.LOCALAPPDATA, 'com.example.my-app'), async () => {
// the folder is moved aside first, so the check starts with none; put back afterwards
})On a computer without internet, in Windows Sandbox:
npx tauri-kit-dev sandbox <installer> --exe my-app.exe [--extra checks.ps1] [--without-webview2]The script inside records each step and writes a result the command reads back; --extra adds
the app's own steps (Step '<name>' { ... }, with $target the install folder). Windows Sandbox
is an optional Windows feature (Containers-DisposableClientVM).
Checking the build machine
npx tauri-kit-dev machine [--dotnet]On Windows, the Rust toolchain must be MSVC; with --dotnet, a per-user .NET install needs
DOTNET_ROOT for a bundled .NET helper process.
Third-party notices
npx tauri-kit-dev notices --config notices.config.js [--strict] [--check]Writes the notices an installer ships next to the app: every package the app's package managers say it ships — the web UI's npm packages, the shell's crates, a bundled .NET helper's NuGet packages — with the license texts each carries, or the text pinned for one that publishes without it. A self-contained or ahead-of-time .NET helper also lists the runtime packs of the frameworks it references, and a package's notices for the third-party code it bundles come after its license. The config names where each is described, relative to its own folder:
// notices.config.js
export default {
out: 'notices/out/THIRD-PARTY-NOTICES.txt',
title: 'My App — third-party notices',
npm: { lock: 'package-lock.json', installedAt: '.' },
cargo: { cwd: 'src-tauri', target: 'x86_64-pc-windows-msvc' }, // the targets the app is built for
nuget: {
project: 'helper/Helper.csproj', // restored first, needs dotnet
properties: { RuntimeIdentifier: 'win-x64', SelfContained: true }, // as `dotnet publish` is given them
},
pinned: { pins: 'notices/pins.json', dir: 'notices/texts' },
// render: (packages) => '…', // a document of your own instead of the default one
}The .NET project is restored with nuget.properties as MSBuild properties — the ones the helper is
published with. A helper published with -r <rid> --self-contained on the command line ships the
runtime packs of its frameworks, and only a restore for that runtime identifier, self-contained,
downloads them: without the properties they are missing from the notices. Properties that say
self-contained (SelfContained or PublishAot) with a restore that downloaded no runtime pack fail,
rather than leave the runtime out without a word.
A pin that applies to no shipped package, or a pinned text that is missing or not the one pinned,
fails. --strict also fails on a package left without a license text — for a release that goes
out to the public; without it, those are listed. --check writes nothing and fails when the file
is not what would be written. npx tauri-kit-dev notice-pins --pins notices/pins.json --dir
notices/texts downloads the pinned texts and records their SHA-256. npx tauri-kit-dev
notice-pins --config notices.config.js first finds a pin for each package still without a text —
its license file at the commit it was published from (recorded by cargo publish, a nuspec, or an
npm manifest) or else at its release tag, never a branch — adds those, fetches them, and names the
packages it could not find one for. Read what it found before committing it. The pieces are
exported from @iyulab/tauri-kit-dev/notices (shippedNotices, noticesText, suggestPins, the
readers) for a document composed differently.
Checking public text
npx tauri-kit-dev public-text [--config <file>] [--history] [<repo>...]Checks each git repository (default: the current one) — tracked files, and the messages of
commits not yet pushed — for local paths and private hosts. --history also reads every commit
message, every line any commit ever added and tag messages: old versions of files stay readable
once a repository is public.
--config names a module with the caller's own rules. Keep it out of the repository it checks:
a list of the names to keep out of a public repository, committed there, publishes them.
// public-text.config.js
export default {
forbidden: [{ why: 'internal name', re: /\bproject-codename\b/i }],
allowed: ['.gitignore:notes/'], // `path:text` substrings known to be fine
// defaults: false, // drop the built-in local-path and private-host rules
}A line that is meant to carry what a rule finds — a test whose subject is a home folder — says so on that line or the one above it, so the exception sits next to its reason:
// public-text: allow — the test is about paths under a home folder
const home = 'C:\Users\someone\notes'The same check is available as a function:
import { checkRepo, loadConfig } from '@iyulab/tauri-kit-dev/public-text'
const findings = checkRepo('.', { config: await loadConfig('public-text.config.js'), history: true })Versioning
While the version is 0.x, a minor bump may break the API; a patch bump does not.
CHANGELOG.md lists what each release changed.
