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');