diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py
index 30d9b38e..b3e8518c 100644
--- a/.github/scripts/tests/test_command_descriptions.py
+++ b/.github/scripts/tests/test_command_descriptions.py
@@ -90,6 +90,20 @@ class CommandDescriptionsTests(unittest.TestCase):
path = root / lang / "common.json"
path.parent.mkdir()
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
+ # This steady-state fixture assumes generation already finished.
+ # Seed only the three new backup messages in temporary locale
+ # copies; keep recovered command arrays and repo catalogs exact.
+ # Actual missing-key fallback is tested by the JSX seam tests.
+ if lang != "en":
+ temporary = json.loads(path.read_text())
+ for section, key in (
+ ("archives", "emptyMessage"),
+ ("destinations", "backupsKeptMessage"),
+ ("destinations", "localAttachHelpMessage"),
+ ):
+ temporary["backup"][section].setdefault(
+ key, catalog("en")["backup"][section][key])
+ path.write_text(json.dumps(temporary, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx
index 2e455cd5..1a2f5e6c 100644
--- a/AppImage/components/host-backup.tsx
+++ b/AppImage/components/host-backup.tsx
@@ -1300,7 +1300,7 @@ export function HostBackup() {
) : unifiedArchives.length === 0 ? (
- {t("backup.archives.emptyBefore")} {t("backup.manual.run")} {t("backup.archives.emptyAfter")}
+ {t("backup.archives.emptyMessage")}
) : (
@@ -4089,7 +4089,7 @@ function CreateJobDialog({
className="font-mono"
/>
- {t("backup.destinations.localAttachHelpBefore")} /dump {t("backup.destinations.localAttachHelpMiddle")} /var/lib/vz/dump.
+ {t("backup.destinations.localAttachHelpMessage")}
>
) : (
@@ -5709,7 +5709,7 @@ function DestinationsSection({
)}
{backups > 0 && (
- {t("backup.destinations.backupsKeptBefore", { count: backups })} {t("backup.destinations.kept")}.
+ {t("backup.destinations.backupsKeptMessage")}
)}
diff --git a/AppImage/messages/README.md b/AppImage/messages/README.md
index 68e100f7..dd726e3d 100644
--- a/AppImage/messages/README.md
+++ b/AppImage/messages/README.md
@@ -7,4 +7,22 @@ The ProxMenux Monitor dashboard uses a small client-side i18n layer.
To improve a translation, edit the values in your locale's `common.json` — keep placeholders such as `{uptime}`, `{vmid}` or `{count}` unchanged, and don't translate brand or product names (`ProxMenux Monitor`, `Proxmox Backup Server`, `Secure Gateway`, `Tailscale`, etc.). Missing keys fall back to English at runtime, so a partial refresh is always safe to merge.
+## Backup message composition regressions
+
+From the repository root, run:
+
+```sh
+node tests/test_backup_archives_empty.cjs
+node tests/test_backup_destination_messages.cjs
+```
+
+Prerequisites: Node.js and the `typescript` development dependency from
+`AppImage/package.json` must be installed/resolvable (normally in
+`AppImage/node_modules`). These tests compile actual JSX message branches and
+pure i18n lookup functions without importing the component or calling a backend.
+They cover whole English messages, missing-key locale fallback, loading/error
+branches, nonempty lists, the removal notice at zero/one/multiple archives,
+local-attach help, and unchanged shared labels. They do not replace browser or
+real-host acceptance. Keep literal paths in the local-attach help unchanged.
+
To add a new locale, see [§11 → Adding a new locale](../../CONTRIBUTING.md#adding-a-new-locale) in the Contributing Guide.
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index 20a9af22..eac3b591 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -3828,6 +3828,7 @@
"downloadTitle": "Download this backup",
"emptyAfter": "backups yet.",
"emptyBefore": "No",
+ "emptyMessage": "No backup archives found.",
"encrypted": "Encrypted",
"encryptedTitle": "This backup is encrypted",
"filesInBackup": "Files in backup",
@@ -3903,6 +3904,7 @@
"addPbsRepository": "Add PBS repository",
"appendAuthorizedKeyBefore": "Append the generated public key to",
"backupsKeptBefore": "Existing backups are kept in",
+ "backupsKeptMessage": "Existing backup archives are not deleted.",
"borgEncryptionInherited": "Borg encryption is controlled by the repository.",
"builtIn": "Built-in",
"builtInDefault": "Built-in default",
@@ -3923,6 +3925,7 @@
"leavePassphraseBlank": "Leave blank to keep the saved passphrase.",
"leavePasswordBlank": "Leave blank to keep the saved password.",
"localAttachHelpBefore": "Use a mounted local path. For USB drives, mount them first in",
+ "localAttachHelpMessage": "Mount USB drives first. Leave blank to use the PVE storage path + /dump (fallback: /var/lib/vz/dump).",
"localAttachHelpMiddle": "USB drives",
"localManualPickHelp": "Pick a local path for one-shot backup output.",
"localPathHelpAfter": "or another mounted path.",
diff --git a/tests/test_backup_archives_empty.cjs b/tests/test_backup_archives_empty.cjs
new file mode 100644
index 00000000..c78c5797
--- /dev/null
+++ b/tests/test_backup_archives_empty.cjs
@@ -0,0 +1,91 @@
+// Offline JSX seam, following the TypeScript compiler harness in adjacent tests.
+// No component imports, React effects, API calls or host-management execution.
+// Run from the repository root: node tests/test_backup_archives_empty.cjs
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createRequire } = require('node:module');
+const app = path.resolve(__dirname, '../AppImage');
+const ts = createRequire(path.join(app, 'package.json'))('typescript');
+const read = file => fs.readFileSync(path.join(app, file), 'utf8');
+const parse = file => ts.createSourceFile(file, read(file), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+const compile = text => ts.transpileModule(text, {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React },
+}).outputText;
+const printer = ts.createPrinter();
+const provider = parse('lib/i18n/provider.tsx');
+const pure = provider.statements.filter(node => ts.isFunctionDeclaration(node) &&
+ ['getMessage', 'interpolate'].includes(node.name?.text));
+assert.equal(pure.length, 2);
+let lookup;
+function visitLookup(node) {
+ if (ts.isVariableDeclaration(node) && node.name.getText(provider) === 't' &&
+ node.initializer && ts.isCallExpression(node.initializer) &&
+ node.initializer.expression.getText(provider) === 'useCallback') lookup = node.initializer.arguments[0];
+ ts.forEachChild(node, visitLookup);
+}
+visitLookup(provider);
+assert.ok(lookup, 'real provider lookup callback');
+const lookupCode = pure.map(node => printer.printNode(ts.EmitHint.Unspecified, node, provider)).join('\n') +
+ '\nconst t = ' + printer.printNode(ts.EmitHint.Expression, lookup, provider) + ';';
+const catalogs = Object.fromEntries(fs.readdirSync(path.join(app, 'messages')).filter(locale =>
+ fs.existsSync(path.join(app, 'messages', locale, 'common.json'))).map(locale =>
+ [locale, JSON.parse(read(`messages/${locale}/common.json`))]));
+// Compile only trusted, checked-out source; no catalog/input strings enter code.
+const translator = (language, messages = catalogs) =>
+ new Function('MESSAGE_CATALOG', 'language', compile(lookupCode) + '\nreturn t;')(messages, language);
+
+const source = parse('components/host-backup.tsx');
+let branch;
+function visitBranch(node) {
+ if (ts.isConditionalExpression(node) && node.condition.getText(source) === 'archivesErr && remoteArchivesErr') branch = node;
+ ts.forEachChild(node, visitBranch);
+}
+visitBranch(source);
+assert.ok(branch, 'real archives loading/error/empty decision');
+// Keep the actual guards and JSX messages. Only replace the nonempty archive
+// list with a sentinel: rendering its controls is outside this message test.
+const transformed = ts.transform(branch, [context => root => {
+ const visit = node => {
+ if (ts.isConditionalExpression(node) && node.condition.getText(source) === 'unifiedArchives.length === 0') {
+ return ts.factory.updateConditionalExpression(node, node.condition, node.questionToken,
+ node.whenTrue, node.colonToken, ts.factory.createStringLiteral('NONEMPTY_ARCHIVES'));
+ }
+ return ts.visitEachChild(node, visit, context);
+ };
+ return ts.visitNode(root, visit);
+}]);
+const expression = printer.printNode(ts.EmitHint.Expression, transformed.transformed[0], source);
+transformed.dispose();
+const render = new Function('React', 'Loader2', 't', 'archivesErr', 'remoteArchivesErr',
+ 'archivesResp', 'remoteArchivesResp', 'unifiedArchives', compile(`const result = ${expression};`) + '\nreturn result;');
+// Text-only JSX sink: TypeScript performs the real JSX whitespace conversion.
+const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
+const text = (locale = 'en', overrides = {}, messages = catalogs) => {
+ const state = { archivesErr: null, remoteArchivesErr: null, archivesResp: { archives: [] },
+ remoteArchivesResp: { snapshots: [] }, unifiedArchives: [], ...overrides };
+ return render(React, 'spinner', translator(locale, messages), state.archivesErr, state.remoteArchivesErr,
+ state.archivesResp, state.remoteArchivesResp, state.unifiedArchives);
+};
+
+assert.equal(text(), 'No backup archives found.');
+assert.equal(text('it'), 'No backup archives found.', 'Italian catalog uses English fallback');
+// Keep fallback coverage even after all shipped locales gain the new key.
+assert.equal(text('missing', {}, { ...catalogs, missing: {} }), 'No backup archives found.');
+for (const locale of Object.keys(catalogs)) {
+ // Existing catalogs intentionally have no new key; actual provider falls back.
+ if (catalogs[locale].backup?.archives?.emptyMessage === undefined) {
+ assert.equal(text(locale), 'No backup archives found.', `${locale}: missing-key fallback`);
+ }
+}
+for (const count of [1, 3]) assert.equal(text('en', { unifiedArchives: Array(count).fill({}) }), 'NONEMPTY_ARCHIVES');
+assert.equal(text('en', { archivesResp: undefined, remoteArchivesResp: undefined }), translator('en')('backup.common.loading'));
+assert.equal(text('en', { archivesErr: true, remoteArchivesErr: true }), translator('en')('backup.archives.loadFailed'));
+// Preserve existing partial-source semantics; no claim that every source was queried.
+assert.equal(text('en', { archivesErr: true, archivesResp: undefined }), 'No backup archives found.');
+assert.equal(text('en', { remoteArchivesErr: true, remoteArchivesResp: undefined }), 'No backup archives found.');
+assert.equal(translator('en')('backup.manual.run'), 'Run');
+assert.ok(read('components/host-backup.tsx').includes('{t("backup.manual.run")}'), 'manual action remains');
+console.log('PASS: actual archives JSX seam, zero/one/multiple, loading/errors, locale fallback, Run unchanged');
+// Export only test seams for separate, unapplied linguistic proposal checks.
+module.exports = { text, translator, catalogs };
diff --git a/tests/test_backup_destination_messages.cjs b/tests/test_backup_destination_messages.cjs
new file mode 100644
index 00000000..c1cc8e92
--- /dev/null
+++ b/tests/test_backup_destination_messages.cjs
@@ -0,0 +1,56 @@
+// Offline actual JSX/provider seams; no component/backend imports or effects.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createRequire } = require('node:module');
+const { translator, catalogs } = require('./test_backup_archives_empty.cjs');
+const app = path.resolve(__dirname, '../AppImage');
+const ts = createRequire(path.join(app, 'package.json'))('typescript');
+const source = ts.createSourceFile('host-backup.tsx', fs.readFileSync(path.join(app, 'components/host-backup.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+const printer = ts.createPrinter();
+const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') };
+function find(predicate) {
+ const found = [];
+ function visit(node) { if (predicate(node)) found.push(node); ts.forEachChild(node, visit); }
+ visit(source);
+ assert.equal(found.length, 1, 'unique actual message seam');
+ return found[0];
+}
+function render(node, locale, backups = 1, messages = catalogs) {
+ const code = ts.transpileModule(`const result = ${printer.printNode(ts.EmitHint.Expression, node, source)};`, {
+ compilerOptions: { target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.React },
+ }).outputText;
+ // Only trusted checked-out AST enters executable code, never catalog values.
+ return new Function('React', 't', 'backups', code + '\nreturn result;')(React, translator(locale, messages), backups);
+}
+const kept = find(node => ts.isBinaryExpression(node) && node.left.getText(source) === 'backups > 0');
+const keptMessage = 'Existing backup archives are not deleted.';
+for (const count of [1, 3]) assert.equal(render(kept, 'it', count), keptMessage, 'Italian catalog uses English fallback');
+assert.equal(render(kept, 'en', 0), false);
+for (const locale of Object.keys(catalogs)) {
+ if (catalogs[locale].backup?.destinations?.backupsKeptMessage !== undefined && locale !== 'en') continue;
+ for (const count of [1, 3]) assert.equal(render(kept, locale, count), keptMessage, `${locale}: kept whole-message fallback`);
+}
+console.log('PASS: destination removal JSX guard 0/1/N and whole-message locale fallback');
+// Select the actual help paragraph, keeping its literal paths/JSX whitespace.
+const help = find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'p' &&
+ /backup\.destinations\.localAttachHelp(?:Before|Message)/.test(node.getText(source)));
+const helpMessage = 'Mount USB drives first. Leave blank to use the PVE storage path + /dump (fallback: /var/lib/vz/dump).';
+assert.equal(render(help, 'it'), helpMessage, 'Italian catalog uses English fallback');
+for (const locale of Object.keys(catalogs)) {
+ if (catalogs[locale].backup?.destinations?.localAttachHelpMessage !== undefined && locale !== 'en') continue;
+ assert.equal(render(help, locale), helpMessage, `${locale}: local attach help fallback`);
+}
+// Missing-key fallback must remain covered after shipped catalogs are complete.
+const missing = { ...catalogs, missing: {} };
+assert.equal(render(kept, 'missing', 1, missing), keptMessage);
+assert.equal(render(help, 'missing', 1, missing), helpMessage);
+// Synthetic locale proves complete messages are translatable, not hardcoded.
+const synthetic = { ...catalogs, fixture: { backup: { destinations: {
+ backupsKeptMessage: 'Fixture kept.', localAttachHelpMessage: 'Fixture help.',
+} } } };
+assert.equal(render(kept, 'fixture', 1, synthetic), 'Fixture kept.');
+assert.equal(render(help, 'fixture', 1, synthetic), 'Fixture help.');
+assert.equal(catalogs.en.backup.destinations.kept, 'kept');
+assert.equal(catalogs.en.backup.destinations.localAttachHelpMiddle, 'USB drives');
+console.log('PASS: local attach JSX whole-message fallback and literal paths');