Merge remote-tracking branch 'origin/develop' into HEAD

This commit is contained in:
MacRimi
2026-10-18 13:19:12 +02:00
146 changed files with 10388 additions and 628 deletions
@@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
# provider, then restore the exact source spelling afterwards. Keep longer
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
PROTECTED_TECHNICAL_TERMS = (
# Product names, kept whole. Left to a translator, "Proxmox Backup Server"
# became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox",
# and when a provider rightly handed it back unchanged the string was
# retried and then dropped as untranslated.
"Proxmox Backup Server",
"Proxmox Datacenter Manager",
"Proxmox Mail Gateway",
"Proxmox VE",
"ProxMenux Monitor",
"ProxMenux Optimizations",
"Secure Gateway",
"Tailscale",
"google/gasket-driver",
"feranick/gasket-driver",
"libedgetpu1-std",
+77 -6
View File
@@ -73,6 +73,24 @@ TECHNICAL_TERMS = (
"DKMS",
"SSH",
"API",
"Flask",
"SQLite",
"Prometheus",
"Grafana",
"Uptime Kuma",
"PWA",
"Fail2Ban",
"Tailscale",
"JavaScript",
"TypeScript",
"Next.js",
"Coral",
"rclone",
"journald",
"gasket",
"apex",
"Alpine",
"OpenVINO",
)
PROTECTED_PATTERNS = (
@@ -88,9 +106,13 @@ PROTECTED_PATTERNS = (
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
# Identifiers and encoded delimiters remain literal even outside <code>.
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
)
LITERAL_TAG_RE = re.compile(
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL,
)
@@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
BARE_SENTINELS = False
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
NON_TRANSLATABLE_KEYS = {
"accent",
"categoryslug",
"command",
"code",
"href",
"icon",
"iconbg",
"id",
"menucmd",
"path",
"route",
"slug",
"src",
"url",
"color",
"tone",
"variant",
}
SOURCE_STATE_VERSION = 1
@@ -186,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
if not source.strip() or not re.search(r"[A-Za-z]", source):
return True
last = str(path[-1]).lower() if path else ""
# These fields describe the surrounding snippet; they are prose, even
# though their names end in "Code".
if last in {"aftercode", "beforecode"}:
return False
# This UI breadcrumb is prose, rather than a filesystem path.
if last == "uipath" and "<strong>" in source:
return False
if last == "flow" and source in {"loader", "dedicated"}:
return True
# These exact values drive rendering/navigation; similarly named visible
# labels remain translatable.
if last == "branch" and source in {"main", "develop", "feature/*"}:
return True
if last == "kind" and source in {"internal", "external"}:
return True
if last == "key" and source in {"linux", "nas", "windows"}:
return True
if last in NON_TRANSLATABLE_KEYS or any(
last.endswith(suffix)
for suffix in ("url", "href", "path", "command", "code", "icon")
):
return True
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source):
return True
# Standalone literal blocks contain examples, not prose. Separators between
# several blocks do not make their command/configuration contents translatable.
without_literals = LITERAL_TAG_RE.sub("", source)
if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)):
return True
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
return True
@@ -202,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str:
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
def literal_values_match(source: Any, target: Any) -> bool:
"""Literal commands and rendering metadata must follow the source too."""
return all(
not should_copy(leaf.source, leaf.path)
or get_at_path(target, leaf.path) == leaf.source
for leaf in iter_leaves(source)
)
def source_fingerprints(source: Any) -> dict[str, str]:
return {
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
@@ -240,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
def replace(match: re.Match[str]) -> str:
original = match.group(1)
if original.lower() in {"code", "kbd", "pre"}:
if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
return match.group(0)
key = original.lower()
internal = names.get(key)
@@ -329,11 +389,14 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
def restore_text(text: str, mapping: dict[str, str]) -> str:
missing = [token for token in mapping if token not in text]
# A local model may append a digit to a bare sentinel. A substring match
# would silently restore the prefix and leak the extra digit into prose.
patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping}
missing = [token for token, pattern in patterns.items() if not pattern.search(text)]
if missing:
raise ValueError(f"translation provider changed protected token {missing[0]}")
for token, original in mapping.items():
text = text.replace(token, original)
text = patterns[token].sub(lambda match: original, text)
return text
@@ -347,6 +410,11 @@ def validate_contract(source: str, target: str) -> None:
raise ValueError("literal code changed")
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
raise ValueError("placeholder contract changed")
# Bare numeric protection markers once leaked a trailing zero just
# outside an otherwise intact code span. Literal/ICU checks cannot see it.
residue = re.compile(r'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(0+)(?=\s|[;:/<().,-]|$)')
if len(residue.findall(target)) > len(residue.findall(source)):
raise ValueError("unexpected numeric residue next to literal markup")
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
@@ -822,7 +890,10 @@ def main() -> int:
leaves = pending_leaves(source, target, queued_tokens)
total_strings += len(iter_leaves(source))
missing_strings += len(leaves)
schema_changed = not schema_matches(source, target)
schema_changed = (
not schema_matches(source, target)
or not literal_values_match(source, target)
)
if leaves or schema_changed:
pending.append(
(
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Check active terminal translations offline, without generating any text.
Validates formatting fields and literal commands/identifiers. Missing values
can retain English fallback; --require-complete makes them errors. This does
not establish the accuracy or fluency of the translated prose.
"""
from __future__ import annotations
import argparse
from pathlib import Path
import re
from build_translation_cache import (
DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts,
extract_python_texts, extract_translate_texts,
)
from build_web_docs_i18n import read_json, validate_contract
def active_sources(root: Path) -> list[str]:
# Match build-translation-cache.yml, including launchers outside scripts/.
extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')]
texts = extract_translate_texts(root / 'scripts', extras)
texts += extract_translate_texts(root / 'oci/remote')
texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote'])
texts += extract_catalog_texts([root / 'oci/catalog'])
return sorted({text for text in texts if '$' not in text and '`' not in text})
def validate_message(source: str, target: str, *, title_separator: bool = False) -> None:
validate_contract(source, target)
for match in STRUCTURAL_LITERAL_RE.finditer(source):
literal = match.group()
# A page title may replace its decorative separator with a dash.
# In command examples, a pipe remains a shell operator.
if title_separator and literal == '|':
continue
# DNS hostnames are case-insensitive. Paths, field names and command
# names are not, so do not case-fold those to hide a broken example.
is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal)
if literal not in target and not (is_hostname and literal.casefold() in target.casefold()):
raise ValueError(f'literal command or identifier changed: {literal}')
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=Path('.'))
parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES))
parser.add_argument('--require-complete', action='store_true')
args = parser.parse_args()
root = args.root.resolve()
texts = active_sources(root)
errors = 0
for language in args.languages.split(','):
if language not in DEFAULT_LANGUAGES:
parser.error(f'Unsupported locale: {language}')
catalog = read_json(root / 'lang' / f'{language}.json') or {}
missing = 0
for source in texts:
target = catalog.get(source)
if not isinstance(target, str) or not target.strip():
missing += 1
continue
try:
validate_message(source, target)
except ValueError as error:
print(f'[{language}] {source!r}: {error}')
errors += 1
print(f'[{language}] active={len(texts)} missing={missing}')
if args.require_complete and missing:
errors += 1
return 1 if errors else 0
if __name__ == '__main__':
raise SystemExit(main())
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Check documentation translation structure and literal contracts offline.
Missing locales/leaves are allowed by default because the website falls back
to English. Use --require-complete when validating a catalog completion PR.
This checks data integrity, not translation fluency or linguistic accuracy.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from build_web_docs_i18n import (
DEFAULT_LANGUAGES,
get_at_path,
iter_leaves,
literal_values_match,
read_json,
schema_matches,
source_files,
)
from check_terminal_catalogs import validate_message
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
parser.add_argument("--require-complete", action="store_true")
args = parser.parse_args()
root = args.messages_dir.resolve()
files = source_files(root / "en", ".")
errors = 0
for language in args.languages.split(","):
language = language.strip()
if language not in DEFAULT_LANGUAGES:
parser.error(f"Unsupported locale: {language}")
missing = checked = present_files = 0
for file in files:
relative = file.relative_to(root / "en")
source = read_json(file)
target = read_json(root / language / relative)
if target is not None:
present_files += 1
if args.require_complete and not schema_matches(source, target):
print(f"[{language}] {relative}: source/target schema differs")
errors += 1
for leaf in iter_leaves(source):
localized = get_at_path(target, leaf.path)
if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()):
missing += 1
continue
try:
validate_message(
leaf.source, localized,
title_separator=bool(
leaf.path and leaf.path[0] == "meta"
and leaf.path[-1] in ("title", "ogTitle", "twitterTitle")
),
)
except ValueError as exc:
print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}")
errors += 1
checked += 1
if args.require_complete and not literal_values_match(source, target):
print(f"[{language}] {relative}: literal commands or rendering metadata differ")
errors += 1
print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}")
if args.require_complete and missing:
errors += 1
return 1 if errors else 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -13,6 +13,7 @@ import sys
import tempfile
PYTHON_FILES = (
"AppImage/scripts/tests/test_native_app_discovery.py",
"tests/test_audit_presentation.py",
"tests/test_audit_safety_wording.py",
"tests/test_audit_policy.py",
@@ -24,6 +25,7 @@ PYTHON_FILES = (
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
)
NODE_FILES = (
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
"tests/lxc_updates/test_docker_delegated_ui.cjs",
"tests/test_audit_diagnostic_document.cjs",
"tests/test_audit_policy.cjs",
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
target = {"title": "Actualizaciones", "url": "https://example.com"}
self.assertEqual(MODULE.pending_leaves(source, target), [])
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
for key, value in (("tone", "amber"), ("variant", "source"),
("accent", "border-blue-300 bg-blue-50"),
("iconBg", "bg-blue-100 text-blue-700"),
("color", "green")):
with self.subTest(key=key):
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
self.assertFalse(MODULE.should_copy("English", ("lang",)))
for value in ("loader", "dedicated"):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
self.assertFalse(MODULE.should_copy(value, ("label",)))
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
for source in ("<code>apt update</code>",
"<code>net0</code> / <code>net1</code>.",
"/api/task-log/&lt;upid&gt;", "/api/vms/<vmid>/config"):
with self.subTest(source=source):
self.assertTrue(MODULE.should_copy(source, ("cell",)))
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at &lt;host&gt;."
protected, mapping = MODULE.protect_text(source)
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "&lt;", "&gt;"):
self.assertIn(literal, mapping.values())
self.assertNotIn(literal, protected)
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_custom_code_spans_keep_commands_literal(self):
for tag in ("codeNw", "codeXs"):
source = f"Run <{tag}>pct config 100</{tag}>."
with self.subTest(tag=tag):
protected, mapping = MODULE.protect_rich_tags(source)
self.assertEqual(protected, source)
self.assertEqual(mapping, {})
with self.assertRaisesRegex(ValueError, "literal code changed"):
MODULE.validate_contract(source, source.replace("config", "configuration"))
with self.assertRaisesRegex(ValueError, "numeric residue"):
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
values = {
"categorySlug": ["system", "network"],
"menuCmd": ["menu"],
"branch": ["main", "develop", "feature/*"],
"kind": ["internal", "external"],
"key": ["nas", "linux", "windows"],
}
for key, items in values.items():
for value in items:
with self.subTest(key=key, value=value):
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
self.assertFalse(MODULE.should_copy(value, ("label",)))
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
def test_bare_sentinel_with_added_digit_is_rejected(self):
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
token = next(iter(mapping))
with self.assertRaisesRegex(ValueError, "changed protected token"):
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
def test_numeric_residue_outside_intact_literal_is_rejected(self):
examples = [
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'numeric residue'):
MODULE.validate_contract(source, target)
MODULE.validate_contract('The result is <code>echo</code>0.',
'Výsledok je <code>echo</code>0.')
def test_product_and_kernel_module_names_are_protected(self):
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
protected, mapping = MODULE.protect_text(source)
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
self.assertIn(name, mapping.values())
self.assertNotIn(name, protected)
self.assertEqual(MODULE.restore_text(protected, mapping), source)
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "messages/en/common.json"
target = root / "messages/de/common.json"
source.parent.mkdir(parents=True)
target.parent.mkdir(parents=True)
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(json.loads(target.read_text()), {
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
})
provider.assert_not_called()
before = target.stat().st_mtime_ns
self.assertEqual(self.run_generator(root, provider), 0)
self.assertEqual(target.stat().st_mtime_ns, before)
provider.assert_not_called()
def test_changed_source_text_is_pending_even_with_existing_translation(self):
source = {"title": "Updated installation guidance"}
target = {"title": "Vorherige Installationsanleitung"}
@@ -0,0 +1,30 @@
"""Keep machine generation explicit without losing automatic validation."""
from pathlib import Path
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[3]
class DocumentationReviewWorkflowTests(unittest.TestCase):
def workflow(self, name):
return yaml.load(
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
Loader=yaml.BaseLoader,
)
def test_generation_requires_explicit_dispatch(self):
triggers = self.workflow('build-web-docs-i18n.yml')['on']
self.assertEqual(set(triggers), {'workflow_dispatch'})
def test_website_changes_keep_automatic_offline_validation(self):
triggers = self.workflow('test-i18n.yml')['on']
for event in ('pull_request', 'push'):
with self.subTest(event=event):
self.assertIn('web/**', triggers[event]['paths'])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,30 @@
"""Every shipped terminal language must be selectable during installation."""
from pathlib import Path
import re
import sys
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
from build_translation_cache import DEFAULT_LANGUAGES
class InstallerLanguageTests(unittest.TestCase):
def test_both_installers_offer_every_shipped_language(self):
expected = {'en', *DEFAULT_LANGUAGES}
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
with self.subTest(installer=name):
source = (ROOT / name).read_text(encoding='utf-8')
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
self.assertIsNotNone(function, 'Missing installer language selector')
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
self.assertEqual(set(offered), expected)
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
# English is the source text in the scripts, not a JSON catalog.
for language in set(offered) - {'en'}:
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
if __name__ == '__main__':
unittest.main()
@@ -5,6 +5,7 @@ import json
from pathlib import Path
import re
import runpy
import subprocess
import sys
import tempfile
from types import ModuleType, SimpleNamespace
@@ -52,7 +53,8 @@ class InventoryMessages(unittest.TestCase):
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
'translate': translate, 're': re}
'translate': translate, 're': re, 'subprocess': subprocess,
'_selected_configuration': remover.guest_config}
summary = extracted(MENU, '_removal_summary', scope)
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
@@ -56,7 +56,8 @@ class RemovalWordings(unittest.TestCase):
state = ModuleType('oci_installation_state')
state.parse_config = lambda raw: {}
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': translated or (lambda value: value), 're': re}
'translate': translated or (lambda value: value), 're': re,
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
@@ -87,7 +88,8 @@ class RemovalWordings(unittest.TestCase):
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
'oci_installation_state': state}):
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
'translate': lambda value: value, 're': re}
'translate': lambda value: value, 're': re,
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
self.assertIn(WHOLE, preview)
@@ -42,13 +42,13 @@ class SelectionSetupWording(TestCase):
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=lambda project, **options: {},
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
translate=lambda s: s)
fn(Path('/fixture'), ui)
ui.message.assert_called_once_with(EMPTY, 'OCI management')
ui.choose.assert_not_called()
def _menu(self, inventories, selections):
def _menu(self, inventories, selections, unread=True):
rows = iter(inventories)
ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections))
refresh = Mock(return_value={})
@@ -57,7 +57,8 @@ class SelectionSetupWording(TestCase):
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows),
_clean_orphans=lambda project: None,
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
carry_records=refresh, manage_instance=manage, menu_size=lambda: 10,
carry_records=refresh, _records_unread=lambda project: unread,
manage_instance=manage, menu_size=lambda: 10,
STATUS_LABELS={}, translate=lambda s: s)
fn(Path('/fixture'), ui)
return ui, refresh, manage
@@ -67,7 +68,14 @@ class SelectionSetupWording(TestCase):
ui, refresh, manage = self._menu([[row], [row]], ['101', None])
self.assertEqual(ui.choose.call_count, 2)
manage.assert_called_once()
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True)
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True,
heading='Manage installed OCI applications')
def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
_, refresh, _ = self._menu([[row]], [None], unread=False)
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False,
heading='Manage installed OCI applications')
def test_removing_the_last_application_returns_without_a_message(self):
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
@@ -3,6 +3,7 @@ import ast
from contextlib import nullcontext
import copy
import json
import os
from pathlib import Path
import re
import runpy
@@ -129,7 +130,9 @@ class UpdateWording(unittest.TestCase):
reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'os': os, 'tempfile': tempfile, 'Path': Path,
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
'images': SimpleNamespace(offer_removal=lambda *args: None),
'carry_records': lambda project, **options: {},
'_work_backup_ready': lambda project, ui, vmid: True,
@@ -0,0 +1,82 @@
"""Offline regressions for copyable commands and runtime format fields."""
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
ROOT = SCRIPTS.parents[1]
sys.path.insert(0, str(SCRIPTS))
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC and SPEC.loader
SPEC.loader.exec_module(MODULE)
class TerminalCatalogsTests(unittest.TestCase):
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
fixtures = {
'menu': 'translate "Main launcher"',
'install_proxmenux.sh': 'translate "Stable installer"',
'install_proxmenux_beta.sh': 'translate "Beta installer"',
'scripts/check.sh': 'translate "Host shell script"',
'scripts/check.py': 'translate("Host Python script")',
'oci/src/check.py': 'N_("OCI Python script")',
'oci/remote/check.py': 'translate("Remote Python script")',
'oci/remote/check.sh': 'translate "Remote shell script"',
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
}
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for name, content in fixtures.items():
file = root / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(content, encoding='utf-8')
self.assertEqual(set(MODULE.active_sources(root)), {
'Main launcher', 'Stable installer', 'Beta installer',
'Host shell script', 'Host Python script', 'OCI Python script',
'Remote Python script', 'Remote shell script', 'Application tagline',
})
def test_runtime_format_field_cannot_be_translated(self):
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
with self.assertRaisesRegex(ValueError, 'placeholder'):
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
def test_copyable_command_and_environment_variable_are_literal(self):
examples = [
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
]
for source, target in examples:
with self.subTest(source=source):
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message(source, target)
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
'Ejemplo: pct config 110 | grep unprivileged')
def test_dns_capitalization_does_not_relax_file_path_case(self):
MODULE.validate_message('Cannot reach download.proxmox.com.',
'Download.proxmox.com ist nicht erreichbar.')
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
def test_shipped_active_catalogs_keep_literal_contracts(self):
result = subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(ROOT), '--require-complete'],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for locale in MODULE.DEFAULT_LANGUAGES:
self.assertIn(f'[{locale}] active=', result.stdout)
self.assertNotIn('missing=1', result.stdout)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,94 @@
"""Validate complete catalogs without rejecting intentional English fallback."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
class WebDocsCatalogTests(unittest.TestCase):
def run_check(self, root, *options):
return subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
"--languages", "de", *options],
capture_output=True, text=True,
)
def write_catalog(self, root, locale, content):
file = root / locale / "common.json"
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(json.dumps(content), encoding="utf-8")
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
partial = self.run_check(root)
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
self.assertIn("missing=1", partial.stdout)
complete = self.run_check(root, "--require-complete")
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
self.assertIn("schema differs", complete.stdout)
def test_translated_code_is_rejected_in_both_modes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
for options in ((), ("--require-complete",)):
with self.subTest(options=options):
result = self.run_check(root, *options)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_bare_commands_and_operators_are_not_prose(self):
examples = [
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
]
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for source, target in examples:
with self.subTest(source=source):
self.write_catalog(root, "en", {"body": source})
self.write_catalog(root, "de", {"body": target})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("literal command or identifier", result.stdout)
def test_title_separator_can_change_without_relaxing_body_pipes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
result = self.run_check(root, "--require-complete")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
result = self.run_check(root)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
def test_shipped_web_catalogs_keep_literal_contracts(self):
messages = SCRIPT.parents[2] / "web" / "messages"
result = subprocess.run(
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
invalid = self.run_check(root, "--require-complete")
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
self.assertIn("rendering metadata differ", invalid.stdout)
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
valid = self.run_check(root, "--require-complete")
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
@@ -0,0 +1,41 @@
"""Exercise the static-export language repair on real HTML files."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from test_command_descriptions import require_node
ROOT = Path(__file__).resolve().parents[3]
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
class WebExportLanguageTests(unittest.TestCase):
def test_locales_nested_pages_preserved_content_and_idempotence(self):
node = require_node()
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
for locale in locales:
file = root / "out" / locale / "docs/monitor/index.html"
file.parent.mkdir(parents=True)
file.write_text(html)
for relative in ["out/index.html", "out/assets/index.html"]:
file = root / relative
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(html)
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("8 localized pages", result.stdout)
for locale in locales:
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
for relative in ["out/index.html", "out/assets/index.html"]:
self.assertEqual((root / relative).read_text(), html)
tracked = root / "out/de/docs/monitor/index.html"
before = tracked.stat().st_mtime_ns
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual(tracked.stat().st_mtime_ns, before)
+3 -8
View File
@@ -1,14 +1,9 @@
name: Build web documentation translations
on:
push:
branches: [develop]
paths:
- 'web/messages/en/*.json'
- 'web/messages/en/**/*.json'
- '.github/scripts/build_web_docs_i18n.py'
- '.github/scripts/build_translation_cache.py'
- '.github/workflows/build-web-docs-i18n.yml'
# Source and locale corrections are reviewed together. An automatic run
# would treat the changed English fingerprints as stale translations and
# overwrite those reviewed locale strings with provider output.
workflow_dispatch:
inputs:
languages:
+48
View File
@@ -28,7 +28,55 @@ jobs:
python-version: '3.13'
- name: Install OCI test dependencies
run: python3 -m pip install -r oci/requirements.txt
- name: Run OCI image reuse and resource edit regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
- name: Run carried record, recovery and configuration cache regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
- name: Run read-only volume preparation regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
test -x /bin/bash
test -x /usr/bin/jq
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
- name: Run offline registry account tests
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
- name: Run OCI Modify latency regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
- name: Run OCI modification menu regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
- name: Run external OCI settings regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
+6 -2
View File
@@ -12,6 +12,7 @@ on:
- 'AppImage/package-lock.json'
- 'AppImage/scripts/*.py'
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
- 'AppImage/scripts/tests/test_native_app_discovery.py'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
@@ -32,6 +33,7 @@ on:
- 'AppImage/package-lock.json'
- 'AppImage/scripts/*.py'
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
- 'AppImage/scripts/tests/test_native_app_discovery.py'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
@@ -56,7 +58,9 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run nine qualified Python files
- name: Install native discovery route fixture dependency
run: python3 -m pip install 'Flask>=3,<4'
- name: Run qualified Python files
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
@@ -82,7 +86,7 @@ jobs:
run: |
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
- name: Run eleven qualified Node files
- name: Run qualified Node files
env:
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
PYTHONDONTWRITEBYTECODE: '1'
+4 -1
View File
@@ -17,6 +17,9 @@ on:
- 'scripts/global/repository-functions.sh'
- 'scripts/menus/pbs_menu.sh'
- 'scripts/utilities/pbs_update.sh'
- 'scripts/global/update-pbs.sh'
- 'scripts/menus/menu_post_install.sh'
- 'scripts/post_install/*.sh'
push:
branches: [main, develop]
paths:
@@ -49,7 +52,7 @@ jobs:
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh; do
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
bash -n "$script"
done
- uses: actions/setup-node@v6