Skip to content

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

67 Commits

Folders and files

Repository files navigation

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-dev

Requires 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.

cdp.on(method, listener) hears the events the window sends — once their domain is enabled (await cdp.send('Log.enable')) — and answers a function that stops listening.

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 in setup with 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_arguments is in tauri-kit's tauri-kit-webview from 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_ARGUMENTS repeats them: --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection, and --autoplay-policy=no-user-gesture-required while 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 and debugPortFromEnv: false.

Another driver on the same window

The debugging port takes more than one client, so a window App.launch started — the built app, not a dev server — can also be driven by another CDP client. app.endpoint is where its port answers; Playwright, for one, takes hold of it with connectOverCDP:

import { chromium } from 'playwright-core'
import { App } from '@iyulab/tauri-kit-dev/app'

const app = await App.launch({ exe: 'src-tauri/target/debug/notes.exe', port: 9222 })
const browser = await chromium.connectOverCDP(app.endpoint)
const page = browser.contexts()[0].pages()[0]
await page.getByRole('button', { name: 'New note' }).click()
await browser.close() // lets go of the window; it does not end the app
await app.quit()

App's own helpers keep working beside the other driver, and quit is still what ends the app and waits for its web view to go.

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.

What the page said

App collects what the page says to its console, and what the browser logs about it — a request refused, a content security policy violation — in app.consoleMessages ({ level, text, source? }, in order, with what was said before the window was connected to). app.consoleProblems() is the part at warning or error. A failure report adds its last few after what the window showed. Some faults show nowhere else: a Tauri window whose calls to the app cannot reach http://ipc.localhost — refused by its content security policy, which needs connect-src ipc: http://ipc.localhost, or by a request filter — falls back to another channel and goes on working, with one warning for each call. A scenario can hold the page to having said none:

'says nothing wrong at start': async (app) => {
  assert.deepEqual(app.consoleProblems(), [])
},

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()
}

While developing: tauri dev

import { App, readDiscovery } from '@iyulab/tauri-kit-dev/app'

const app = await App.launchDev({
  cwd: '.', // where `npx tauri dev` runs (the default command; `command` names another)
  port: 9224,
  ready: `!!document.querySelector('my-app')`,
  leftovers: ['my-app.exe', 'My.Helper.exe'], // what a run given up on may have left running
})
const helper = await readDiscovery(join(dataDir, 'helper.json')) // where a helper the build started listens
// …drive app.cdp as with a built exe…
await app.quit() // ends the dev server, the build and the app, not only the shell

launchDev runs a development command in place of a built executable and waits for its window the same way — longer by default (10 minutes), since the first build takes minutes. leftovers, here or in launch, names the executables ended before the app starts (endLeftovers, Windows): only those names, and one not running is passed over. readDiscovery reads a helper's discovery file — { port, … }, as tauri-kit's DiscoveryFile writes it — as soon as it is there and whole.

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 gone

An NSIS installer records the folder it installed into (the default value of HKCU\Software\<maker>\<product>) and its uninstaller leaves it there, so the next plain install of the product would go into the removed temporary folder. withInstalled puts that value back as it was before the install — the value it held, no value, or no key — and warns when the value it puts back names a folder that does not exist (left by another tool).

Installing over the latest published version (pickUpgradeFrom, downloadInstaller) and checking that WebView2 profile copies are dropped (profileSnapshots, seedProfileSnapshot) use the same pieces.

An app that updates itself with tauri-kit-update can have that path walked without a person: walkUpdate installs the previous published release, starts it, waits until it has found the newest release and downloaded its installer, has it install (by default by closing its window, for an app that installs on close), and checks the installed version, that the app was not started again, and that the next launch lets go of the installer it kept. A release build looks for updates where it was built to, so the newest release must already be published.

import { join } from 'node:path'
import { walkUpdate, withAppDataSetAside } from '@iyulab/tauri-kit-dev/installer'

const data = join(process.env.LOCALAPPDATA, 'com.example.my-app')
const { from, to, steps } = await withAppDataSetAside(data, () =>
  walkUpdate({
    repo: 'example/my-app-releases',
    exe: 'my-app.exe',
    product: 'My App', // its uninstall entry; the walk refuses to run while it is installed
    stateFile: join(data, 'update.json'),
    keptInstaller: (version) => join(data, 'updates', `My App_${version}_x64-setup.exe`),
    after: async (target) => { /* the app's own checks on the updated copy */ },
  }),
)

install starts the update another way (an app that installs when asked), and restarts: true expects the installer to start the app again.

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 })

Pin drift

npx tauri-kit-dev pin-drift --props Directory.Packages.props --project helper/Helper.csproj --publisher my-org
npx tauri-kit-dev pin-drift --npm package.json --cargo src-tauri/Cargo.toml --repository '^(git\+)?https://github\.com/my-org/'

Some dependencies are meant to be consumed at their current line — packages from the app's own publisher, released often alongside it. Falling behind them is silent: restore, build and tests stay green on an old pin. pin-drift compares each such pin with the newest stable version and fails when the major version differs or the pin is more than --max-minor-gap (default 5) minor versions behind, unless a waiver in --waivers — [{ package, until: "YYYY-MM-DD", reason }] — has not expired. Smaller gaps are reported and pass.

Which packages count is read from the registries, never from a list of names: NuGet packages whose nuspec lists --publisher among its authors (from the restored graph of --project, transitive ones included), and the npm packages (--npm, any dependencies table) and crates (--cargo, any dependencies table, a renamed one under the crate it names) whose repository on npm or crates.io matches --repository. The sources can be given together or apart.

What a release did once people ran it

npx tauri-kit-dev telemetry --app my-app-insights --resource-group my-app [--days 7] [--version 0.3.0]

For each version, from the Application Insights resource tauri-kit-diagnostics sends to: how often it was started (SessionStart), on how many installations, how many sessions it left unfinished (SessionUnfinished — it ended without saying so) and how many failures it reported, over the last --days. --version exits 1 when that version has not been started anywhere — the question a release's verify step asks. It reads as the person running it: the Azure CLI, signed in, gives the resource's id and a token, and the query goes to the Application Insights API — nothing here holds a key.

Keeping the app's version in step

npx tauri-kit-dev versions --config versions.config.js [--bump <x.y.z>]

An app writes its version in several files, and a release that moves one and forgets another ships an installer whose About box, update feed and crash reports disagree about what it is. versions reads each file's own version — not a dependency's — and fails when they differ; --bump writes the new one into every file, each in place, the rest of its text as it was. Nothing is written when one of the files holds no version to find.

// versions.config.js — paths relative to this file
export default {
  files: [
    'src-tauri/Cargo.toml', // `version` in [package], or in [workspace.package]
    { file: 'src-tauri/Cargo.lock', package: 'my-app' }, // the entry of the app's own crate
    'src-tauri/tauri.conf.json', // the top-level `version`
    'package.json',
    'package-lock.json', // the top-level `version` and the root package's
    'helper/Helper.csproj', // the first <Version>
  ],
}

The same as functions: checkVersions(files, resolvePath) and bumpVersions(files, version, resolvePath) from @iyulab/tauri-kit-dev/versions.

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.

License

MIT

About

No description, website, or topics provided.

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages