Plugins
Tester un plugin
Escouade donne aux auteurs de plugins une commande, escouade-plugin, qui fait tourner un plugin sans l’app : le banc de conformité le passe au crible de ce qu’Escouade attend de lui, et le faux Escouade lui envoie des faits et des demandes à la main. Le banc est celui que joue Escouade sur ses propres plugins.
La commande
escouade-plugin est livrée avec Escouade, à côté de son programme : %LOCALAPPDATA%\Escouade\escouade-plugin.exe sous Windows, Escouade.app/Contents/MacOS/escouade-plugin sous macOS, /usr/bin/escouade-plugin avec le paquet .deb. Pour un plugin run.js ou run.wasm, elle n’a besoin ni de Node ni de Rust : elle compile son module comme le store et le joue en WASM, dans l’hôte de l’app. Pour un plugin lancé par une commande, il lui faut ce qu’il faut à ce programme pour démarrer.
escouade-plugin validate mon-plugin # le manifeste, champ par champ
escouade-plugin test mon-plugin # le banc de conformité
escouade-plugin dev mon-plugin # le faux Escouade
--lang fr ou --lang en choisit la langue de ses messages ; sans lui, c’est celle du système.
--escouade <version> joue le plugin comme cette version d’Escouade, avec validate, test et dev : la plage escouade du manifeste se contrôle contre elle, et c’est elle que le plugin voit dans initialize. Sans lui, c’est la version de la commande. Il sert à écrire pour une version à venir :
escouade-plugin test mon-plugin --escouade 2.2.0
Le banc de conformité
escouade-plugin test <dossier> lit le manifeste, lance le programme de run comme Escouade le lance (dans son dossier, avec ses trois variables, avec tout ce qu’il démarre), et joue jusqu’à onze scénarios. Chacun dit ce qui s’est passé ; un scénario qui échoue dit aussi ce qui manque.
| Scénario | Ce que le banc fait | Ce qu’il attend |
|---|---|---|
| Manifeste | Lit escouade-plugin.json. |
Un manifeste valide, pour cette version d’Escouade. |
| Initialisation | Envoie initialize. |
{"protocolVersion": 1, "capabilities": {…}} en moins de 10 s. |
| Version refusée | Relance le plugin et lui offre la version 999 du protocole. | Sa propre version, ou une erreur : jamais la version offerte. |
| Délais | Mesure chaque appel du banc, et appelle bench/unknown, qu’aucun plugin ne sert. |
Chaque réponse dans le délai de sa méthode ; l’erreur -32601 tout de suite à une méthode inconnue. |
| Un fait reçu | Envoie un fait de permissions.events (la notification events/fact), puis un appel. |
Pas de réponse à la notification, pas de plantage, et la réponse à l’appel suivant. |
| Une décision | Envoie permissions/decide pour une commande npm test. |
{"decision": "allow" | "deny" | "ask"}, avec un message pour un refus. |
| Un secret | Sert secrets/get pendant tout le banc, avec une valeur de test. |
Seulement les clés de permissions.secrets, et jamais leur valeur écrite sur la sortie d’erreur ou dans un log. |
| Plantage | Tue le plugin et tout ce qu’il a lancé au milieu d’un appel, puis le relance. | Un nouveau process qui s’initialise comme le premier. |
| Fermeture | Envoie shutdown. |
Une réponse en moins d’une seconde, puis la fin du process. |
| Source de tickets | Seulement si contributes.ticketSources est déclaré : account/me et tickets/containers. |
Une réponse dans le délai, ou une erreur que le contrat nomme : -(32000 + statut HTTP) (-32401 : jeton refusé), -32010 (un refus durable), -32601 (un appel qu’il ne sert pas). |
| Hébergeur | Seulement si contributes.forges est déclaré : account/me et forge/pullRequestsOfBranch. |
La même. |
Un scénario qui ne concerne pas le plugin n’est pas joué et le dit : pas de fait sans permissions.events, pas de décision sans permissions.decide, pas de source ni d’hébergeur sans contributes.ticketSources ou contributes.forges, et rien d’un programme pour un plugin déclaratif, dont seul le manifeste est lu.
Banc de conformité : dev.exemple.slow-rules 1.0.0
✓ Manifeste valide, avec un programme
✓ Initialisation répond en 61 ms, protocole 1
✓ Version refusée offerte la version 999, il répond la sienne (1) : Escouade l'arrête
✗ Délais permissions/decide : répondu en 812 ms, au-delà de son délai de 500 ms
Ce qui manque : répondre dans le délai de chaque méthode (500 ms pour une décision, 5 s en général) ; passé ce délai, Escouade n'attend plus et sa réponse par défaut s'applique.
– Un fait reçu il ne demande aucun fait (`permissions.events`)
✓ Une décision « allow » en 812 ms
– Un secret il ne demande aucun secret (`permissions.secrets`)
✓ Plantage tué au milieu d'un appel, il redémarre : répond en 58 ms, protocole 1
✓ Fermeture répond en 2 ms, puis se termine
6 réussis, 1 échoués, 2 sans objet
Le code de sortie est 1 si un scénario échoue, 0 sinon : la commande sert telle quelle en CI.
--jsondonne le rapport en JSON : chaque scénario, son verdict (pass,fail,skip), ce qui s’est passé, ce qui manque, la durée de chaque appel, et ce que le plugin a fait de lui-même (sa sortie d’erreur, ses journaux, les secrets demandés ; les 200 derniers). C’est ce que lit la revue « Vérifié par Escouade », qui passe le banc dans un conteneur sans réseau : une connexion ou une écriture refusée s’y voit.--measure [n]mesure ensuitenallers-retours (1 000 par défaut) et donne leur médiane et leur 99e centile, en microsecondes.
Les faits et les décisions du banc
Le banc envoie les faits et les décisions dans la forme que leur donnera Escouade : le projet, le ticket et l’agent par leurs ids et leurs noms, jamais la conversation.
{"jsonrpc":"2.0","method":"events/fact","params":{"name":"ticket.column","version":1,"at":"2026-10-29T10:00:00Z","project":{"id":"bench-project","name":"Banc","private":false},"ticket":{"id":"bench-ticket","key":"BEN-1","title":"Un ticket du banc","from":"doing","column":"test"},"agent":{"id":"bench-agent","name":"Agent du banc"}}}
{"jsonrpc":"2.0","id":3,"method":"permissions/decide","params":{"request":{"id":"bench-request","kind":"command","tool":"Bash","input":{"command":"npm test"}},"project":{"id":"bench-project","name":"Banc","private":false},"ticket":{"id":"bench-ticket","key":"BEN-1","title":"Un ticket du banc"},"agent":{"id":"bench-agent","name":"Agent du banc"}}}
Un fait est une notification : il n’a pas d’id, et le plugin n’y répond pas. Les faits qu’un plugin reçoit dans Escouade, et leurs champs, arrivent avec les événements, dans une prochaine version ; ignore les champs que tu ne connais pas.
Le faux Escouade
escouade-plugin dev <dossier> lance le plugin, l’initialise, puis envoie ce que tu tapes, ligne par ligne, et montre ce qui revient : ses réponses avec leur durée, ses demandes de secrets, ses log, sa sortie d’erreur.
| Ligne | Ce qui part |
|---|---|
fact <nom> [json] |
Le fait <nom> (events/fact) ; le JSON remplace ses champs, par exemple fact ticket.column {"project":{"id":"p","name":"Privé","private":true}}. |
decide [json] |
Une demande d’autorisation (permissions/decide), et sa réponse ; une réponse au-delà de 500 ms est signalée. |
call <méthode> [json] |
Un appel, et sa réponse. |
notify <méthode> [json] |
Une notification. |
restart |
Tue le plugin et le relance. |
quit |
shutdown, puis la fin. |
--secret <clé>=<valeur> donne la valeur d’un secret (répétable) ; une clé du plugin sans valeur reçoit null, comme dans Escouade quand rien n’est gardé, et une clé hors de ses permissions.secrets est refusée par -32001.
$ escouade-plugin dev slack --secret webhook=https://example.invalid
slack/slack.cmd (pid 30112)
→ initialize
← {"capabilities":{},"protocolVersion":1} (47 ms)
> fact ticket.column
→ events/fact {"agent":{…},"name":"ticket.column","project":{…},"ticket":{…},"version":1}
→ secrets/get "webhook"
→ log [info] no Slack webhook kept: nothing sent
> call nope {"a":1}
← error -32601 unknown method nope (0 ms)
> quit
→ shutdown
← null (0 ms)
Un plugin en Rust
Trois niveaux, du plus rapide au plus vrai :
cargo test # le plugin, en natif, sur ta machine
cargo check --target wasm32-wasip1 # il compile en module (rustup target add wasm32-wasip1)
escouade-plugin test mon-plugin # le banc, sur le module
cargo testtourne en natif, avec tokio et reqwest : la même source que le module, sans son hôte.serve_ondu kit joue le plugin sur des lignes en mémoire, les mêmes qu’envoie Escouade. Il lui faut un compilateur C, pourring. Les tests du modèle :sdk/create-escouade-plugin/templates/rust/src/tests.rs(extrait)
/// What the plugin writes for `messages`, one message a line.
fn play(messages: &[Value]) -> Vec<Value> {
let input: String = messages.iter().map(|m| format!("{m}\n")).collect();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let (output, written) = tokio::io::duplex(64 * 1024);
serve_on(Arc::new(Template), input.as_bytes(), output)
.await
.unwrap();
let mut lines = BufReader::new(written).lines();
let mut read = Vec::new();
while let Some(line) = lines.next_line().await.unwrap() {
read.push(serde_json::from_str(&line).unwrap());
}
read
})
}
wasm32-wasip1est la cible du module.cargo check --target wasm32-wasip1dit vite ce qui n’y compile pas :tokio,std::process, une dépendance qui suppose un système. Ce n’est pas le module publié : celui-là sort deescouade-plugin build.- Le banc et le faux Escouade compilent le module comme le store, puis le jouent dans l’hôte de l’app. Une erreur du store se voit donc ici : pas de
Cargo.lock, un crate hors de la liste. La chaîne d’outils, rustc 1.98.0 etwasm32-wasip1, est téléchargée au premier besoin dans~/.escouade/tools(650 Mo, une minute) et vérifiée à chaque usage, sans rustup.ESCOUADE_RUST_TOOLCHAINen nomme une autre.
--wasm <module> joue un module donné au lieu de compiler ; --offline refuse tout appel réseau, comme la revue ; --account <adresse> donne une adresse à {account}. Le banc sert fs/* et proc/* autour d’un agent à lui, et secrets/get. Il ne sert pas settings/get, qui répond -32601 : un plugin doit y survivre.
En CI
Le banc tient dans une étape : installe Escouade (ou seulement escouade-plugin, à côté de l’app), puis
escouade-plugin validate .
escouade-plugin test .
Les tests d’Escouade jouent ce banc sur son faux plugin et sur ses propres plugins (plugins/ de son dépôt), avec --first-party : leurs identifiants dev.escouade.* sont réservés, le banc les refuse à tout autre plugin. Le SDK en TypeScript (@escouade-dev/plugin) installe la même commande dans les node_modules d’un plugin, et sa CI joue ce banc sur les gabarits de npm create escouade-plugin.