diff --git a/tests/backup_i18n_seam.cjs b/tests/backup_i18n_seam.cjs new file mode 100644 index 00000000..3404efe2 --- /dev/null +++ b/tests/backup_i18n_seam.cjs @@ -0,0 +1,40 @@ +// Test-only provider seam shared by independent backup message programs. +// Compile only checked-out provider source; catalog values are never executable code. +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 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 = ts.createSourceFile('provider.tsx', read('lib/i18n/provider.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.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`))])); +const registered = provider.statements.flatMap(statement => ts.isVariableStatement(statement) ? + statement.declarationList.declarations.filter(declaration => declaration.name.getText(provider) === 'MESSAGE_CATALOG') : []); +assert.equal(registered.length, 1, 'real provider catalog declaration'); +assert.deepEqual(registered[0].initializer.properties.map(property => property.name.getText(provider)).sort(), + Object.keys(catalogs).sort(), 'test each runtime-registered shipped locale'); +assert.ok(catalogs.en, 'English fallback catalog exists'); +const translator = (language, messages = catalogs) => + new Function('MESSAGE_CATALOG', 'language', compile(lookupCode) + '\nreturn t;')(messages, language); +module.exports = { app, ts, read, compile, printer, catalogs, translator }; diff --git a/tests/test_audit_report.py b/tests/test_audit_report.py index 5c12e438..c6bd5cf6 100644 --- a/tests/test_audit_report.py +++ b/tests/test_audit_report.py @@ -395,8 +395,38 @@ class StoreTests(unittest.TestCase): critical = self.finding() critical.update(classification='critical', raw_classification='critical') changed = self.recorded(critical) - self.assertEqual(len(engine.compare_runs(before, changed)['new']), 1) - self.assertEqual(len(engine.compare_runs(changed, before)['new']), 1) + worse = engine.compare_runs(before, changed) + self.assertEqual(worse['new'], []) + self.assertEqual(worse['resolved'], []) + self.assertEqual(len(worse['worse']), 1) + self.assertEqual(worse['worse'][0]['classification'], 'critical') + self.assertEqual(worse['worse'][0]['previous_classification'], 'warning') + self.assertEqual(worse['worse'][0]['previous_affected'], 1) + self.assertEqual(worse['worse'][0]['affected_count'], 1) + self.assertEqual(worse['better'], []) + better = engine.compare_runs(changed, before) + self.assertEqual(better['new'], []) + self.assertEqual(better['resolved'], []) + self.assertEqual(len(better['better']), 1) + self.assertEqual(better['better'][0]['classification'], 'warning') + self.assertEqual(better['better'][0]['previous_classification'], 'critical') + self.assertEqual(better['better'][0]['previous_affected'], 1) + self.assertEqual(better['better'][0]['affected_count'], 1) + self.assertEqual(better['worse'], []) + + def test_new_failure_and_verified_resolution_stay_distinct(self): + conformant = self.recorded(self.finding(state='pass')) + warning = self.recorded(self.finding()) + new = engine.compare_runs(conformant, warning) + self.assertEqual(len(new['new']), 1) + self.assertEqual(new['new'][0]['classification'], 'warning') + self.assertEqual(new['resolved'], []) + self.assertEqual(new['unverified'], []) + resolved = engine.compare_runs(warning, conformant) + self.assertEqual(len(resolved['resolved']), 1) + self.assertEqual(resolved['resolved'][0]['classification'], 'conformant') + self.assertEqual(resolved['new'], []) + self.assertEqual(resolved['unverified'], []) def test_malformed_result_does_not_abort_remaining_checks(self): for bad in (False, [], {'affected': [None]}, {'affected': 'invalid'}): diff --git a/tests/test_backup_archives_empty.cjs b/tests/test_backup_archives_empty.cjs index c78c5797..66b126ea 100644 --- a/tests/test_backup_archives_empty.cjs +++ b/tests/test_backup_archives_empty.cjs @@ -2,38 +2,8 @@ // 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 { ts, read, compile, printer, catalogs, translator } = require('./backup_i18n_seam.cjs'); 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; @@ -68,16 +38,22 @@ const text = (locale = 'en', overrides = {}, messages = catalogs) => { 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`); - } +const emptyMessage = 'No backup archives found.'; +assert.equal(catalogs.en.backup.archives.emptyMessage, emptyMessage); +assert.equal(text(), emptyMessage); +// Missing-key fallback is unconditional: it must survive later catalog generation. +assert.equal(text('missing', {}, { ...catalogs, missing: {} }), emptyMessage); +const synthetic = { ...catalogs, fixture: { backup: { archives: { emptyMessage: 'Fixture: no archives.' } } } }; +assert.equal(text('fixture', {}, synthetic), 'Fixture: no archives.'); +const locales = Object.keys(catalogs); +assert.ok(locales.length > 1, 'shipped multilingual catalogs discovered'); +for (const locale of locales) { + const shipped = catalogs[locale].backup?.archives?.emptyMessage; + assert.equal(text(locale), shipped ?? emptyMessage, `${locale}: actual shipped locale or English fallback`); } +assert.ok(locales.some(locale => locale !== 'en' && + typeof catalogs[locale].backup?.archives?.emptyMessage === 'string' && + catalogs[locale].backup.archives.emptyMessage !== emptyMessage), 'localized shipped archive message exercised'); 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')); @@ -86,6 +62,4 @@ assert.equal(text('en', { archivesErr: true, archivesResp: undefined }), 'No bac 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 }; +console.log('PASS: actual archives JSX seam, zero/one/multiple, loading/errors, shipped locales and synthetic fallback/translation, Run unchanged'); diff --git a/tests/test_backup_destination_messages.cjs b/tests/test_backup_destination_messages.cjs index c1cc8e92..f2eb8679 100644 --- a/tests/test_backup_destination_messages.cjs +++ b/tests/test_backup_destination_messages.cjs @@ -1,13 +1,8 @@ // 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 { ts, read, printer, translator, catalogs } = require('./backup_i18n_seam.cjs'); +const source = ts.createSourceFile('host-backup.tsx', read('components/host-backup.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const React = { createElement: (tag, props, ...children) => children.flat(Infinity).filter(x => x != null && x !== false).join('') }; function find(predicate) { const found = []; @@ -25,32 +20,45 @@ function render(node, locale, backups = 1, messages = catalogs) { } 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(catalogs.en.backup.destinations.backupsKeptMessage, keptMessage); +assert.equal(render(kept, 'en', 1), keptMessage); +assert.equal(render(kept, 'en', 3), keptMessage); 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'); +// A missing locale key must use English regardless of shipped catalog completeness. +const missing = { ...catalogs, missing: {} }; +for (const count of [1, 3]) assert.equal(render(kept, 'missing', count, missing), keptMessage); +console.log('PASS: destination removal JSX guard 0/1/N and missing-key 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(catalogs.en.backup.destinations.localAttachHelpMessage, helpMessage); +assert.equal(render(help, 'en'), helpMessage); assert.equal(render(help, 'missing', 1, missing), helpMessage); -// Synthetic locale proves complete messages are translatable, not hardcoded. +// Synthetic whole messages prove both JSX consumers use the provider rather than hardcoded English. const synthetic = { ...catalogs, fixture: { backup: { destinations: { backupsKeptMessage: 'Fixture kept.', localAttachHelpMessage: 'Fixture help.', } } } }; assert.equal(render(kept, 'fixture', 1, synthetic), 'Fixture kept.'); +assert.equal(render(kept, 'fixture', 3, synthetic), 'Fixture kept.'); assert.equal(render(help, 'fixture', 1, synthetic), 'Fixture help.'); +const locales = Object.keys(catalogs); +assert.ok(locales.length > 1, 'shipped multilingual catalogs discovered'); +for (const locale of locales) { + const destinations = catalogs[locale].backup?.destinations; + for (const count of [1, 3]) { + assert.equal(render(kept, locale, count), destinations?.backupsKeptMessage ?? keptMessage, + `${locale}: actual kept message or English fallback`); + } + assert.equal(render(help, locale), destinations?.localAttachHelpMessage ?? helpMessage, + `${locale}: actual local attach help or English fallback`); +} +for (const [key, english] of [['backupsKeptMessage', keptMessage], ['localAttachHelpMessage', helpMessage]]) { + assert.ok(locales.some(locale => locale !== 'en' && + typeof catalogs[locale].backup?.destinations?.[key] === 'string' && + catalogs[locale].backup.destinations[key] !== english), + `${key}: localized shipped message exercised`); +} 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'); +console.log('PASS: destination whole-message JSX, shipped locales, synthetic translation/fallback and literal paths');