i18n: missing translations are reported, never a failure (#2729)

A message a catalog lacks, or translates as an empty string, shows in
English and fails nothing:

- build.rs no longer requires every formats catalog to carry the same
  messages, and tr_format! gets a last arm that formats the English
  source, so a message no catalog has builds.
- Coverage tests (every language translates X) and tests of one
  language's exact wording are gone; the language-switching, layout,
  date, placeholder and user-value tests read the catalog's own value
  instead of a hard-coded translation.
- i18n::tests::translation_gaps_report lists, per language, the plain
  and format messages it lacks and its untranslated What's New lines.
  It always passes; cargo xtask ci
  runs it from the test step's binary after the tests.
- catalogs_load checks that every catalog file is a JSON object of
  strings with each key once; it.json named "Dimensions" twice.
This commit is contained in:
aiongg
2026-10-23 16:42:28 -04:00
committed by GitHub
parent d71b83b2bc
commit b264557916
18 changed files with 495 additions and 950 deletions
-4
View File
@@ -456,10 +456,6 @@ mod tests {
set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
}
set_language(Locale::ZhHant);
assert_eq!(label_text("File"), "檔案");
assert_eq!(label_text("Settings…"), "設定…");
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
set_language(Locale::En);
}
+8 -16
View File
@@ -7,9 +7,9 @@
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
//! adding its file (and its `language_table!` entry); this script picks the file up by name.
//!
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
//! shows English.
//! A message a language lacks (or translates as an empty string) shows the English source: that
//! language has no arm for it, and a message no catalog has formats the English source through the
//! macro's last arm. `i18n::tests::translation_gaps_report` lists the gaps per language.
//!
//! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`,
//! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from
@@ -61,25 +61,14 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
println!("cargo:rerun-if-changed=locales/{name}");
let variant = variant_for(code)?;
let messages: BTreeMap<String, String> =
let mut messages: BTreeMap<String, String> =
serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?;
messages.retain(|_, translated| !translated.is_empty());
catalogs.push((code.to_string(), variant, messages));
}
catalogs.sort_by(|a, b| a.0.cmp(&b.0));
// Every catalog translates the same messages.
let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect();
let mut problems = Vec::new();
for (code, _, messages) in &catalogs {
for key in &all {
if !messages.contains_key(*key) {
problems.push(format!("locales/{code}-formats.json is missing {key:?}"));
}
}
}
if !problems.is_empty() {
return Err(problems.join("\n").into());
}
let mut source = String::from(concat!(
"/// Translate a format string, checked by `format!` in every language.\n",
@@ -98,6 +87,9 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n"));
}
source.push_str(concat!(
" ($english:literal $(, $($args:tt)*)?) => {\n",
" format!($english $(, $($args)*)?)\n",
" };\n",
"}\n",
"\n",
"// `macro_rules!` is textual: the module re-export is what lets call sites say\n",
-1
View File
@@ -2344,7 +2344,6 @@
"Loupe Info 1": "Info lente 1",
"Loupe Info 2": "Info lente 2",
"Use Defaults": "Usa predefiniti",
"Dimensions": "Dimensioni",
"Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2."
}
+1 -4
View File
@@ -2495,7 +2495,7 @@ mod tests {
assert!(!row.cancellable, "{row:?}");
assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked");
let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default();
assert_eq!(current, "Wird zu Apple Fotos hinzugefügt…");
assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…"));
// the row says so on the next frame (issue #345)
h.step();
let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row");
@@ -4570,9 +4570,6 @@ mod tests {
assert_eq!(crate::i18n::language(), locale);
for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] {
let expected = locale.tr(key);
if locale != Locale::En {
assert_ne!(expected, key, "this locale must translate the tested message");
}
assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true);
assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode));
assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected);
+365 -788
View File
File diff suppressed because it is too large Load Diff
+2 -10
View File
@@ -684,25 +684,17 @@ mod tests {
}
#[test]
fn skipped_images_are_one_line_in_the_ui_language() {
fn skipped_images_are_one_line() {
let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [
{"image": 7, "reason": "missingFile"},
{"image": 8, "reason": "missingRoot"},
{"image": 9, "reason": "masterSkipped"},
]}});
let details = |locale| {
crate::i18n::set_language(locale);
let dialog = result_dialog(&value, "done");
crate::i18n::set_language(crate::i18n::Locale::En);
let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") };
details
};
let en = details(crate::i18n::Locale::En);
let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") };
assert_eq!(en.len(), 4, "{en:?}");
assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete.");
assert_eq!(&en[2..], ["w1", "w2"]);
let es = details(crate::i18n::Locale::Es);
assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos.");
}
#[test]
-12
View File
@@ -1533,18 +1533,6 @@ mod tests {
}
}
#[test]
fn library_context_menu_labels_have_french_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (label, expected) in
[("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")]
{
assert_eq!(crate::i18n::tr(label), expected);
}
crate::i18n::set_language(saved);
}
#[test]
fn square_grid_groups_and_auto_levels() {
let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true);
+29 -41
View File
@@ -520,6 +520,35 @@ fn in_tree(tree: &[KeywordNode], path: &str) -> bool {
tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path)))
}
/// The labels the Keyword List and its dialogs pass to `tr` through variables, out of reach of a
/// literal search: the translation gaps report (`i18n`) checks them.
#[cfg(test)]
pub(crate) const VARIABLE_LABELS: &[&str] = &[
"Include on Export",
"Export Containing Keywords",
"Export Synonyms",
"Person",
"Merge Keywords",
"No keywords yet",
"No keywords match",
"Add to Selected Photos",
"Remove from Selected Photos",
"Keywords",
"& Containing",
"Will Export",
"No keywords are exported",
"Add to All Selected Photos",
"Set name",
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
#[cfg(test)]
mod tests {
use lightcraft_catalog::{Op, Photo, Source};
@@ -589,45 +618,4 @@ mod tests {
let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]);
assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some);
}
/// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of
/// a literal search) are translated in every language.
#[test]
fn labels_passed_through_variables_are_translated() {
use crate::i18n::Locale;
let labels = [
"Include on Export",
"Export Containing Keywords",
"Export Synonyms",
"Person",
"Merge Keywords",
"No keywords yet",
"No keywords match",
"Add to Selected Photos",
"Remove from Selected Photos",
"Keywords",
"& Containing",
"Will Export",
"No keywords are exported",
"Add to All Selected Photos",
"Set name",
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] {
let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap();
let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap();
for label in labels {
assert!(catalog.contains_key(label), "{code}: “{label}”");
}
}
assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above");
}
}
-19
View File
@@ -897,25 +897,6 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
mod tests {
use super::*;
#[test]
fn masking_context_actions_use_catalog_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (english, translated) in [
("Duplicate Mask", "Dupliquer le masque"),
("Invert Mask", "Inverser le masque"),
("Hide Mask", "Masquer le masque"),
("Move Up", "Monter"),
("Brush", "Pinceau"),
("Luminance Range", "Plage de luminance"),
] {
assert_eq!(menu_label(english), translated, "{english}");
}
// An arbitrary user name has no catalog entry and is left unchanged.
assert_eq!(menu_label("My custom mask"), "My custom mask");
crate::i18n::set_language(saved);
}
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
/// room by dropping a column rather than drawing it past its tile.
#[test]
+1 -9
View File
@@ -343,20 +343,12 @@ mod import_review {
}
}
/// Every shipped locale paints translated destination choices and initializes a translated new name.
/// Every shipped locale paints its own destination choices and names a new album in its language.
#[test]
fn import_album_picker_localizes_choices_in_every_shipped_language() {
use crate::i18n::Locale;
let Review { mut h, .. } = review();
for locale in Locale::ALL {
for source in
["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"]
{
assert!(!locale.tr(source).is_empty());
if *locale != Locale::En {
assert_ne!(locale.tr(source), source, "{locale:?}: {source}");
}
}
let r = h.request("ui.set", json!({"language": locale.code()}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE));
+3 -2
View File
@@ -46,8 +46,9 @@ ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze
## Ověření a revize
Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál,
uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas
a české glyfy. Překladové šablony kontroluje také Rust při sestavení.
uložení jazyka, placeholdery, datum, čas a české glyfy. Překladové šablony kontroluje také
Rust při sestavení. Chybějící překlady se zobrazují anglicky a vypisuje je
`i18n::tests::translation_gaps_report`; žádný test kvůli nim neselže.
Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack,
„bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení.
+3 -3
View File
@@ -29,8 +29,8 @@ cargo test -p lightcraft-ui-egui i18n::tests
LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000
```
Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal,
Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute
und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
Die Tests prüfen Platzhalter, Sprachwechsel per Menü und Steuerkanal, Speicherung,
Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute und ß. Fehlende
Übersetzungen erscheinen auf Englisch; `i18n::tests::translation_gaps_report` listet sie auf. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`;
gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md).
+3 -2
View File
@@ -52,8 +52,9 @@ englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähde
## Testaus
`i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta,
kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja
suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa.
kielen tallennuksen, paikkamerkit, päivämäärän, ajan ja suomen kirjaimet. Rust tarkistaa
muotoilumallit käännösvaiheessa. Puuttuvat käännökset näkyvät englanniksi, ja
`i18n::tests::translation_gaps_report` luettelee ne; mikään testi ei epäonnistu niiden takia.
Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading),
”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing),
+4 -4
View File
@@ -19,7 +19,7 @@ not translated. Descriptions for SAM 3 go to the model unchanged.
## Georgian script
Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the
interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts
interface never uses Mtavruli. Commands are verbal nouns. Counts
are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with
`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`.
@@ -73,7 +73,7 @@ cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000
```
The tests check catalog coverage, placeholders, switching language by menu and control channel,
saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case
keeps Georgian lowercase. More notes are in
The tests check placeholders, switching language by menu and control channel, saving, the `ka`
locale tags, dates and that sentence case keeps Georgian lowercase. Messages Georgian lacks show in
English; `i18n::tests::translation_gaps_report` lists them. More notes are in
[localization.md](localization.md).
+3 -2
View File
@@ -66,8 +66,9 @@ LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000
LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800
```
Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album,
đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu.
Các test tiếng Việt kiểm tra đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng và glyph
dấu. Chuỗi còn thiếu hiện bằng tiếng Anh và được `i18n::tests::translation_gaps_report` liệt kê;
không test nào thất bại vì thiếu bản dịch.
## Nguồn dịch (clean-room)
+2 -1
View File
@@ -34,5 +34,6 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由
两种目录的占位符是否一致、字形是否齐全,由
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。
缺少的译文显示为英文,由 `i18n::tests::translation_gaps_report` 列出,不会让测试失败。
+16 -12
View File
@@ -12,11 +12,10 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
`locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the
English source strings and must stay byte-for-byte identical. The plain catalog may grow at its
own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests
-- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The
formats catalog must carry every message (the build fails otherwise; a message copied in English,
value equal to its key, is an untranslated placeholder until someone translates it), including the date
English source strings and must stay byte-for-byte identical. Both catalogs may grow at their
own pace: a message a catalog lacks, or translates as an empty string, shows in English, and
nothing fails. The translation gaps report lists what each language lacks (see
[Testing and looking at it](#testing-and-looking-at-it)). The formats catalog also holds the date
patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
capture times use in every language but English; weekday names go in the plain catalog.
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
@@ -81,18 +80,23 @@ tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album
`"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`).
- `format!` rejects an unused argument, so a value a language does not need — the English plural
suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing).
- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a
`tr_format!` call whose message is in no catalog does not compile: add it to every
`*-formats.json` file.
- A language whose formats catalog lacks a message shows the English source there, and a
`tr_format!` call whose message is in no catalog formats the English source (the macro's last
arm). A new message builds with no catalog entry.
## Testing and looking at it
```sh
cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands
cargo test -p lightcraft-ui-egui i18n::tests # catalogs load, placeholders, switching
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage
cargo test -p lightcraft-ui-egui --lib -- --exact i18n::tests::translation_gaps_report --nocapture
```
The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then).
The tests fail on a broken catalog (malformed JSON, a repeated key, placeholders that differ from
the source), never on a missing translation. The last command is the translation gaps report: per
language, the count and list of plain and format messages it lacks and the number of untranslated
What's New lines. It always passes, and `cargo xtask ci` prints it after its test step. The glyph
tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then).
To look at a language, render it headless:
```sh
@@ -114,5 +118,5 @@ when a region has no dedicated entry.
Help ▸ What's New shows `docs/whats-new.md`, and each of its lines (a heading, or one wrapped line of a
bullet, without the `- ` or `### `) is looked up like any message, so a catalog can translate the notes
line by line (German does). A line that is re-wrapped or edited no longer matches its translation and
shows in English until the catalog is updated; untranslated lines are reported by
`display_label_gaps_are_reported`, never failed.
shows in English until the catalog is updated; the translation gaps report counts the untranslated
lines.
+36 -1
View File
@@ -37,7 +37,8 @@ commands:
ci [--hardware] [--summary-md]
fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm
(stops at first failure). Tests run under cargo test (cargo nextest with
LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc. Writes
LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc; after
them it prints the translation gaps report (never a failure). Writes
target/ci/report.json (machine, GPU adapters, data,
steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on
every backend with a hardware adapter; --summary-md prints the Markdown summary
@@ -238,6 +239,39 @@ fn doctests() -> (Command, String) {
(c, "cargo test --workspace --doc".to_owned())
}
/// The test that prints the messages each language lacks (shown in English); it always passes.
const TRANSLATION_GAPS_TEST: &str = "i18n::tests::translation_gaps_report";
/// Print the translation gaps report into the log: [`TRANSLATION_GAPS_TEST`] of lightcraft-ui-egui,
/// run from the test binary the test step built (found with `--no-run` and the step's own
/// arguments, so nothing rebuilds). A report that can't run is a note, never a failed step.
fn translation_gaps() {
eprintln!("\n=== ci: translation gaps ===");
let mut c = cargo();
c.args(["test", "--no-run", "--message-format=json"]).args(with_test_targets(&["--workspace"]));
let binary = c.output().map_err(|e| format!("cargo test --no-run: {e}")).and_then(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|m| {
m["reason"] == "compiler-artifact"
&& m["target"]["name"] == "lightcraft_ui_egui"
&& m["profile"]["test"] == true
&& m["target"]["kind"].as_array().is_some_and(|kinds| kinds.iter().any(|kind| kind == "lib"))
})
.and_then(|m| m["executable"].as_str().map(PathBuf::from))
.ok_or_else(|| format!("no lightcraft-ui-egui test binary in the build (cargo exited with {})", out.status))
});
let ran = binary.and_then(|binary| {
let mut c = Command::new(&binary);
c.args(["--exact", TRANSLATION_GAPS_TEST, "--nocapture", "--quiet"]);
run(c, &format!("{} --exact {TRANSLATION_GAPS_TEST} --nocapture --quiet", binary.display()))
});
if let Err(e) = ran {
eprintln!("note: the translation gaps report did not run: {e}");
}
}
pub fn run(mut cmd: Command, what: &str) -> Result<(), String> {
eprintln!("$ {what}");
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
@@ -431,6 +465,7 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
if *name == "test" {
// right after the workspace tests, before parity and wasm build other feature sets
probe();
translation_gaps();
}
if let Err(e) = r {
failure = Some((*name, e));