Aller au contenu
learn

21. Tests, packaging et distribution avec Tauri

Exemple complet : l16-tauri/frontend/src/api.test.ts, e2e/ et le job de CI tauri-chord-explorer dans .github/workflows/rust-examples.yml.

L’explorateur fonctionne sur la machine de l’auteur. Cette leçon rend cette affirmation vérifiable sur trois systèmes, puis transforme l’application en fichiers que quelqu’un d’autre peut installer.

Niveau Tauri .NET Java
La logique pure cargo test sur theory xUnit JUnit
Le backend par son vrai point d’entrée, sans interface le runtime simulé tauri::test (leçons 17-18) WebApplicationFactory MockMvc de Spring
Le frontend, avec un faux backend Vitest + mockIPC bUnit, Jest Jest
L’application entière WebDriver Playwright, WinAppDriver Selenium
Les installeurs tauri build MSIX, WiX jpackage

Les deux premiers niveaux existent déjà : 15 tests dans theory, 12 dans src-tauri/tests/ qui appellent chaque commande à travers la couche IPC de Tauri, sans fenêtre. Cette leçon ajoute les deux autres.

@tauri-apps/api/mocks remplace la couche IPC dans un DOM simulé : invoke appelle une fonction du test au lieu de Rust. Vitest tourne dans la configuration Vite de la leçon 19, avec jsdom (extrait de vite.config.ts, lignes 26-30) :

test: {
// Un DOM simulé : ni webview ni Rust, c'est mockIPC qui répond à invoke
environment: "jsdom",
include: ["src/**/*.test.ts"],
},

Un test vérifie ce que la page envoie et comment elle lit la réponse (extrait de frontend/src/api.test.ts, lignes 11-22) :

test("spellChord sends the symbol and returns the command's value", async () => {
const calls: [string, unknown][] = [];
mockIPC((cmd, args) => {
calls.push([cmd, args]);
return { symbol: "Am7", qualityName: "minor seventh", notes: ["A", "C", "E", "G"], intervals: ["1", "b3", "5", "b7"] };
});
const chord = await api.spellChord("Am7");
expect(calls).toEqual([["spell_chord", { symbol: "Am7" }]]);
expect(chord.notes).toEqual(["A", "C", "E", "G"]);
});

Le canal de la leçon 18 fonctionne aussi : le mock reçoit l’objet Channel lui-même et peut appeler son onmessage (extrait de frontend/src/api.test.ts, lignes 37-44) :

test("findVoicings passes each channel message to the callback", async () => {
mockIPC((cmd, args) => {
if (cmd !== "find_voicings") return;
const { onEvent, maxSpan } = args as { onEvent: Channel<SearchEvent>; maxSpan: number };
onEvent.onmessage({ event: "voicings", data: { frets: ["x32010"] } });
onEvent.onmessage({ event: "finished", data: { found: 1, elapsedMs: 3, cancelled: false } });
return maxSpan;
});
RUN v5.0.1 C:/Users/spare/source/repos/learn/code/rust-for-csharp-java/l16-tauri/frontend
✓ src/api.test.ts > spellChord sends the symbol and returns the command's value 2ms
✓ src/api.test.ts > a rejected command is a CommandError, a refused call is a string 0ms
✓ src/api.test.ts > findVoicings passes each channel message to the callback 1ms
Test Files 1 passed (1)
Tests 3 passed (3)
Start at 09:52:30
Duration 869ms (environment 94%, transform 4%, import 2%)

Deux limites, rencontrées toutes les deux en écrivant cette leçon :

  • Vitest ne vérifie pas les types. Tant que elapsedMs était encore un bigint (leçon 19), le troisième test passait, et tsc rejetait le même fichier : frontend/src/api.test.ts(42,62): error TS2322: Type 'number' is not assignable to type 'bigint'. Le npm run web:build du cours lance tsc avant Vite, et la CI le lance avant npm test.
  • Le mock répond à tout ce qu’on lui demande. Renommez la commande Rust et ces tests passent toujours. Ils testent la logique du frontend, pas le contrat avec Rust : c’est le travail du niveau suivant.

WebDriver est le protocole du W3C derrière Selenium : un serveur qui pilote un navigateur, ici la webview de l’application. La documentation de Tauri décrit deux voies :

  • tauri-driver, un proxy vers le driver de la plateforme : Microsoft Edge Driver sous Windows, WebKitWebDriver sous Linux. macOS n’a pas de driver pour WKWebView, donc pas de macOS.
  • tauri-plugin-wdio-webdriver (1.4.0), un serveur WebDriver à l’intérieur de l’application, maintenu par le projet WebdriverIO. Pas de driver externe, et il fonctionne sous macOS.

Le cours prend la seconde voie, derrière une feature Cargo, pour que l’application que vous livrez n’ait pas de port WebDriver (extrait de src-tauri/Cargo.toml, lignes 27-32) :

# Leçon 21 : un serveur WebDriver dans l'application, compilé seulement pour les tests de bout en bout
tauri-plugin-wdio-webdriver = { version = "1.4.0", optional = true }
[features]
# `tauri build --features webdriver` construit le binaire que pilote WebdriverIO
webdriver = ["dep:tauri-plugin-wdio-webdriver"]

(extrait de src-tauri/src/lib.rs, lignes 49-51) :

// Leçon 21 : absent des builds normaux, l'application livrée n'ouvre donc aucun port WebDriver
#[cfg(feature = "webdriver")]
let builder = builder.plugin(tauri_plugin_wdio_webdriver::init());

npm run build:e2e lance tauri build --no-bundle --features webdriver avec le frontend Vite. La configuration WebdriverIO démarre ce binaire et attend son serveur sur le port 4445 (extrait de e2e/wdio.conf.ts, lignes 34-56) :

export const config: WebdriverIO.Config = {
runner: "local",
specs: ["./*.e2e.ts"],
maxInstances: 1,
hostname: "127.0.0.1",
port,
capabilities: [{}],
framework: "mocha",
reporters: ["spec"],
logLevel: "warn",
waitforTimeout: 10000,
mochaOpts: { timeout: 60000 },
async onPrepare() {
const started = Date.now();
app = spawn(binary, [], { env: { ...process.env, TAURI_WEBDRIVER_PORT: String(port) }, stdio: "inherit" });
await serverReady();
console.log(`WebDriver server ready after ${Date.now() - started} ms`);
},
onComplete() {
app?.kill();
},
};

Le test saisit, clique et lit, et tout ce qui se trouve entre les deux est réel : la page, l’IPC, les capabilities, la commande Rust (extrait de e2e/chords.e2e.ts, lignes 5-25) :

describe("chord explorer", () => {
// Le serveur WebDriver peut répondre avant que le script de la page ait tourné : attendre la
// liste des qualités, que la page remplit depuis Rust une fois ses gestionnaires en place
before(async () => {
await expect($("#qualities")).toHaveText(expect.stringContaining("Qualities:"));
});
it("spells the chord typed in the form", async () => {
await $("#chord-input").setValue("Am7");
await $("#chord-form button[type=submit]").click();
await expect($("#chord-output")).toHaveText("Am7 (minor seventh): A C E G — 1 b3 5 b7");
});
it("shows a Rust error when the quality is unknown", async () => {
await $("#chord-input").setValue("Cm9");
await $("#chord-form button[type=submit]").click();
await expect($("#chord-error")).toHaveText("unknownQuality: unknown chord quality `m9` in `Cm9`");
});
});

Sur le runner windows-latest (npm run build:e2e, puis npm run test:e2e) :

> wdio run e2e/wdio.conf.ts
WebDriver server ready after 287 ms
[msedge 152.0.0.0 windows #0-0] Running: msedge (v152.0.0.0) on windows
[msedge 152.0.0.0 windows #0-0] » e2e\chords.e2e.ts
[msedge 152.0.0.0 windows #0-0] chord explorer
[msedge 152.0.0.0 windows #0-0] ✓ spells the chord typed in the form
[msedge 152.0.0.0 windows #0-0] ✓ shows a Rust error when the quality is unknown
[msedge 152.0.0.0 windows #0-0]
[msedge 152.0.0.0 windows #0-0] 2 passing (973ms)
Spec Files: 1 passed, 1 total (100% completed) in 00:00:05

Le serveur se présente comme msedge, avec la version de WebView2 : 152 sur le runner, 153 sur la machine de l’auteur (leçon 16). Pour vérifier que le test peut échouer, les notes attendues ont été changées en A C E Gb lors d’une exécution locale :

Expected: "Am7 (minor seventh): A C E Gb — 1 b3 5 b7"
Received: "Am7 (minor seventh): A C E G — 1 b3 5 b7"

Quatre choses n’ont pas fonctionné du premier coup :

  • setValue suivi de browser.keys("Enter") n’a pas soumis le formulaire : la sortie montrait toujours l’accord par défaut, Bbm7 (minor seventh): Bb Db F Ab — 1 b3 5 b7. Cliquer sur le bouton d’envoi fait ce que fait un utilisateur.
  • La page WebDriver de Tauri recommande le @wdio/tauri-service (1.4.0). Avec lui, les deux mêmes tests passaient en 1 min 30 s au lieu d’une seconde, avec un avertissement avant chaque commande : WARN tauri-service:window: Failed to get window states: Error: Tauri core.invoke not available after 5s timeout. Le service attend un second plugin, tauri-plugin-wdio, plus son paquet npm importé par la page, pour son browser.tauri.execute() et ses mocks de commandes. Le cours n’a besoin ni de l’un ni de l’autre, il parle donc directement au serveur embarqué.
  • Sur ubuntu-latest, la première configuration attendait le serveur 20 s puis abandonnait avec Error: no WebDriver server on port 4445. L’application met là-bas environ 25 s à démarrer sous un écran virtuel ; la configuration attend maintenant jusqu’à 60 s, comme le fait le service WebdriverIO en CI, et s’arrête aussitôt si l’application se termine.
  • Sur windows-latest, c’est l’inverse qui s’est produit : le serveur a répondu après 394 ms, avant que le script de la page ait attaché ses gestionnaires. Le clic a soumis un simple formulaire HTML, et le test a lu Received: "". Le hook before attend un texte que la page n’écrit qu’après avoir appelé Rust. La machine de l’auteur n’est jamais tombée dans cette fenêtre de temps ; deux runners de vitesses différentes ont révélé les deux problèmes de timing.

Sur chaque système :

Rien à installer : le serveur embarqué pilote WebView2. Sortie ci-dessus, depuis windows-latest.

tauri build compile le binaire de release, embarque le frontend, et produit les installeurs listés dans bundle.targets ("all" dans la configuration du cours). Sous Windows (npm run build:vite) :

Finished `release` profile [optimized] target(s) in 3m 05s
Built application at: C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\chord-explorer.exe
Info Patching C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\chord-explorer.exe with bundle type information: msi
Info Verifying wix package
Downloading https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip
Info validating hash
Info extracting WIX
Info Target: x64
Running candle for "C:\\Users\\spare\\source\\repos\\learn\\code\\rust-for-csharp-java\\l16-tauri\\target\\release\\wix\\x64\\main.wxs"
Running light to produce C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\bundle\msi\Chord Explorer_0.1.0_x64_en-US.msi
Info Patching C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\chord-explorer.exe with bundle type information: nsis
Info Verifying NSIS package
Downloading https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip
Info validating hash
Info extracting NSIS
Downloading https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll
Info validating hash
Info Target: x64
Running makensis to produce C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\bundle\nsis\Chord Explorer_0.1.0_x64-setup.exe
Finished 2 bundles at:
C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\bundle\msi\Chord Explorer_0.1.0_x64_en-US.msi
C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\bundle\nsis\Chord Explorer_0.1.0_x64-setup.exe

La CLI a téléchargé WiX 3.14 et NSIS 3.11 à la première utilisation, vérifié leurs empreintes, et les a mis en cache : rien à installer au préalable. Elle a aussi patché l’exécutable avant chaque installeur, en y écrivant le type de paquet auquel il appartient (msi, puis nsis).

Fichier Taille
chord-explorer.exe (release, LTO, strippé) 5 120 000 octets (4,9 Mo)
le même avec --features webdriver 6 330 880 octets (6,0 Mo)
bundle/nsis/Chord Explorer_0.1.0_x64-setup.exe 1 570 344 octets (1,5 Mo)
bundle/msi/Chord Explorer_0.1.0_x64_en-US.msi 2 351 104 octets (2,2 Mo)

Aucun des deux installeurs ne contient WebView2. Le webviewInstallMode par défaut est downloadBootstrapper : si WebView2 manque, l’installeur le télécharge, ce dont Windows 11 n’a jamais besoin. Les autres modes embarquent le bootstrapper (environ 1,8 Mo), l’installeur hors ligne (environ 127 Mo) ou un runtime fixe (environ 180 Mo), selon la documentation de Tauri.

MSI est le format qu’attendent les outils de déploiement en entreprise comme Intune ou les stratégies de groupe ; NSIS donne un setup.exe plus petit et personnalisable. Les installeurs n’ont pas été lancés sur la machine de l’auteur (à vérifier).

Pour comparaison, la leçon 16 a mesuré Electron 44.3.0 pour Windows x64 à 385 Mo décompressé, avant la moindre ligne de code applicatif.

Aucun des installeurs ci-dessus n’est signé. Windows le dit :

Fenêtre de terminal
Get-AuthenticodeSignature "target\release\bundle\nsis\Chord Explorer_0.1.0_x64-setup.exe" | Format-List Status, StatusMessage
Status : NotSigned
StatusMessage : The file C:\Users\spare\source\repos\learn\code\rust-for-csharp-java\l16-tauri\target\release\bundle\nsis\Chord Explorer_0.1.0_x64-setup.exe is not digitally signed. […]

Le cours ne signe rien : un certificat coûte de l’argent et appartient à une personne ou à une entreprise. Ce qu’attend chaque système, d’après les guides de distribution de Tauri (à vérifier, rien de tout cela n’a été essayé ici) :

Un installeur non signé téléchargé avec un navigateur déclenche un avertissement SmartScreen. La signature utilise un certificat de signature de code OV ou EV, ou une clé dans Azure Key Vault, configuré dans bundle.windows (certificateThumbprint, digestAlgorithm, timestampUrl, ou une signCommand personnalisée). Depuis 2024, un certificat EV n’accorde plus de réputation immédiate : un fichier nouvellement signé peut encore afficher l’avertissement jusqu’à ce qu’il se construise une réputation. La publication sur le Microsoft Store exige la signature.

Le plugin updater (2.11.0) télécharge une nouvelle version et l’installe. Sa sécurité ne repose pas sur la signature de code ci-dessus mais sur sa propre paire de clés, que la CLI génère :

Your keypair was generated successfully:
Private: C:\Users\spare\AppData\Local\Temp\claude\C--Users-spare-source-repos-learn\515605f1-4f43-4be8-b806-602d45816b1f\scratchpad\updater-key\chord-explorer.key (Keep it secret!)
Public: C:\Users\spare\AppData\Local\Temp\claude\C--Users-spare-source-repos-learn\515605f1-4f43-4be8-b806-602d45816b1f\scratchpad\updater-key\chord-explorer.key.pub
---------------------------
Environment variables used to sign:
- `TAURI_SIGNING_PRIVATE_KEY`: String of your private key
- `TAURI_SIGNING_PRIVATE_KEY_PATH`: Path to your private key file
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: Your private key password (optional if key has no password)
ATTENTION: If you lose your private key OR password, you'll not be able to sign your update package and updates will not work

(npx tauri signer generate --ci -p "" -w <file> : --ci saute les questions et -p "" définit un mot de passe vide, acceptable seulement pour une clé jetable comme celle-ci.)

Le mécanisme, tel qu’il est documenté (à vérifier, l’application du cours n’a pas d’updater) :

  1. La clé publique va dans tauri.conf.json sous plugins.updater.pubkey, avec les URL à consulter dans endpoints, et bundle.createUpdaterArtifacts: true.
  2. Avec la clé privée dans TAURI_SIGNING_PRIVATE_KEY, tauri build écrit aussi un fichier .sig à côté de chaque installeur.
  3. Vous publiez un fichier JSON à l’un des endpoints (le latest.json d’une release GitHub fonctionne) qui donne, par plateforme, la version, l’URL de téléchargement et le contenu du .sig.
  4. L’application appelle check() depuis JavaScript ou Rust, télécharge la mise à jour, vérifie la signature avec la clé publique embarquée, et l’installe ; HTTPS est imposé en production.

Perdez la clé privée, et les applications installées ne pourront plus jamais accepter de mise à jour : elles ne font confiance qu’à cette clé.

Le projet Tauri 2 contient déjà ce dont le mobile a besoin : #[cfg_attr(mobile, tauri::mobile_entry_point)] sur run, et crate-type = ["staticlib", "cdylib", "rlib"] dans src-tauri/Cargo.toml, pour que la même bibliothèque puisse être chargée par une application Android ou iOS. Les commandes, l’état, les événements et les capabilities des leçons 17 à 20 sont le même code. Ce qui change, selon la documentation de Tauri (à vérifier, rien n’a été lancé ici) :

  • tauri android init génère un projet Gradle dans src-tauri/gen/android ; il faut le SDK et le NDK Android, et tauri android dev s’exécute sur un émulateur ou un appareil.
  • tauri ios init génère un projet Xcode ; il faut macOS et Xcode.
  • L’origine de la page devient http://tauri.localhost sous Android (les tests simulés de la leçon 17 la choisissent déjà).
  • Les plugins déclarent les plateformes qu’ils prennent en charge : dialog, fs et store existent sur mobile, avec des différences documentées plugin par plugin ; le système de fichiers mobile est isolé dans un bac à sable.
  • La voie WebDriver de cette leçon concerne le bureau ; les tests de bout en bout sur mobile utilisent Appium ou les outils de la plateforme.
  • Quatre niveaux : cargo test pour la logique, le runtime simulé pour les commandes à travers l’IPC, Vitest avec mockIPC pour le frontend, WebDriver pour l’application entière. Chacun attrape ce que le niveau inférieur ne peut pas attraper.
  • mockIPC répond à tout ce qu’on lui demande, et Vitest ne vérifie pas les types : gardez tsc dans le pipeline et un test de bout en bout pour le contrat.
  • tauri-plugin-wdio-webdriver derrière une feature Cargo fournit un serveur WebDriver sous Windows, Linux et macOS sans le livrer.
  • tauri build produit des installeurs natifs pour chaque système : 1,5 Mo pour NSIS et 2,2 Mo pour MSI sous Windows, sans WebView2.
  • La signature dépend du système et coûte un certificat ou une adhésion ; l’updater a sa propre paire de clés, et perdre la clé privée casse les mises à jour pour de bon.
  1. Écrivez un test Vitest pour api.diatonicChords qui vérifie le nom de la commande et ses arguments. Pourquoi l’argument s’appelle-t-il tonicNote et pas tonic_note ?
Solution
test("diatonicChords sends the tonic and the mode in camelCase", async () => {
let received: unknown;
mockIPC((cmd, args) => {
if (cmd === "diatonic_chords") received = args;
return [];
});
await api.diatonicChords("E", "minor");
expect(received).toEqual({ tonicNote: "E", mode: "minor" });
});

#[tauri::command] convertit le paramètre Rust tonic_note en camelCase dans le JSON qu’il attend (leçon 17). Le test fige ce comportement : si api.ts envoyait tonic_note, ce test échouerait, et la vraie commande se rejetterait pour un argument manquant.

  1. Ajoutez un test de bout en bout qui épelle Am7, clique sur +1, et vérifie le résultat. Avant d’écrire le texte attendu, décidez : A#m7 ou Bbm7 ? Pourquoi cela vaut-il un test de bout en bout alors que theory a déjà des tests unitaires ?
Solution
it("transposes up a semitone", async () => {
await $("#chord-input").setValue("Am7");
await $("#chord-form button[type=submit]").click();
await $("#up").click();
await expect($("#chord-output")).toHaveText("Bbm7 (minor seventh): Bb Db F Ab — 1 b3 5 b7");
});

theory épelle la fondamentale transposée avec l’orthographe courante : son test unitaire transposes_with_common_spellings affirme que Am7 monté d’un demi-ton donne Bbm7, et la ligne de sortie est celle que l’application a affichée pour Bbm7 lors de la première exécution WebDriver en échec, plus haut. Deviner A#m7 aurait fait vérifier au test la croyance de l’auteur au lieu du comportement du programme.

Le test unitaire prouve la transposition ; ce test prouve le câblage : le bouton +1 envoie semitones: 1 (et non "1", rejeté comme l’a montré la leçon 17), la page lit l’accord dans le champ, et le résultat remplace la sortie. Rien de cela ne s’exécute dans cargo test ni avec mockIPC.

  1. Un collègue propose de livrer le build webdriver, « pour que le support puisse piloter l’application d’un client ». Qu’est-ce que ce build ouvre, et que répondriez-vous ?
Solution

Le serveur embarqué écoute sur un port TCP (4445 par défaut, ou TAURI_WEBDRIVER_PORT) et accepte des commandes WebDriver : trouver des éléments, cliquer, saisir, exécuter des scripts dans la page. Sur le bureau, le serveur n’écoute que sur 127.0.0.1 ; sous Android, il écoute sur toutes les interfaces, pour tester en Wi-Fi (src/server/mod.rs, lignes 71-76). N’importe quel processus de n’importe quel utilisateur de la machine peut donc piloter l’application en se faisant passer pour la page : la session de l’utilisateur, ses fichiers via l’export, tout ce que les capabilities accordent.

Réponse : non. Le support a besoin de logs, d’un export de diagnostic ou d’une session à distance que l’utilisateur lance lui-même. La feature existe justement pour que le binaire livré ne contienne pas ce serveur ; la CI construit les deux et ne livre que celui qui ne l’a pas.