mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
i18n: complete Slovak Monitor and CLI messages
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
msg_title "Synology VM Creator"
|
||||
msg_warn "$(translate "No compatible GPU detected for VM passthrough. Skipping GPU wizard option.")"
|
||||
msg_warn "$gpu_notice"
|
||||
msg_info2 "$(translate "Launching GPU passthrough assistant for VM") ${VMID}..."
|
||||
echo -e "${DEF}Using Default Settings${CL}"
|
||||
echo -e "${CUS}Using Advanced Settings${CL}"
|
||||
echo -e " ${TAB}${DGN}Using Virtual Machine ID: ${BGN}${VMID}${CL}"
|
||||
echo -e " ${TAB}${DGN}Using Machine Type: ${BGN}q35${CL}"
|
||||
echo -e " ${TAB}${DGN}Using BIOS Type: ${BGN}OVMF (UEFI)${CL}"
|
||||
echo -e " ${TAB}${DGN}Using Hostname: ${BGN}${HN}${CL}"
|
||||
echo -e " ${TAB}${DGN}Using CPU Model: ${BGN}Host${CL}"
|
||||
echo -e " ${TAB}${DGN}Allocated Cores: ${BGN}${CORE_COUNT}${CL}"
|
||||
echo -e " ${TAB}${DGN}Allocated RAM: ${BGN}${RAM_SIZE}${CL}"
|
||||
echo -e " ${TAB}${DGN}Using Bridge: ${BGN}${BRG}${CL}"
|
||||
echo -e " ${TAB}${DGN}Using MAC Address: ${BGN}${MAC}${CL}"
|
||||
echo -e " ${TAB}${DGN}Using VLAN: ${BGN}Default${CL}"
|
||||
echo -e " ${TAB}${DGN}Using Interface MTU Size: ${BGN}Default${CL}"
|
||||
echo -e " ${TAB}${DGN}Configuring Serial Port: ${BGN}${SERIAL_PORT}${CL}"
|
||||
echo -e " ${TAB}${DGN}Start VM when completed: ${BGN}${START_VM}${CL}"
|
||||
echo -e "${DEF}Creating a $NAME using the above default settings${CL}"
|
||||
echo -e "${CROSS}${RD} ID $VMID is already in use${CL}"
|
||||
echo -e "${DGN}Virtual Machine ID: ${BGN}$VMID${CL}"
|
||||
echo -e "${DGN}Using Machine Type: ${BGN}$MACH${CL}"
|
||||
echo -e "${DGN}Using Machine Type: ${BGN}$MACH${CL}"
|
||||
echo -e "${DGN}Using BIOS Type: ${BGN}SeaBIOS${CL}"
|
||||
echo -e "${DGN}Using BIOS Type: ${BGN}OVMF (UEFI)${CL}"
|
||||
echo -e "${DGN}Using Hostname: ${BGN}$HN${CL}"
|
||||
echo -e "${DGN}Using Hostname: ${BGN}$HN${CL}"
|
||||
echo -e "${DGN}Using CPU Model: ${BGN}Host${CL}"
|
||||
echo -e "${DGN}Using CPU Model: ${BGN}KVM64${CL}"
|
||||
echo -e "${DGN}Allocated Cores: ${BGN}$CORE_COUNT${CL}"
|
||||
echo -e "${DGN}Allocated Cores: ${BGN}$CORE_COUNT${CL}"
|
||||
echo -e "${DGN}Allocated RAM: ${BGN}$RAM_SIZE${CL}"
|
||||
echo -e "${DGN}Allocated RAM: ${BGN}$RAM_SIZE${CL}"
|
||||
echo -e "${DGN}Using Bridge: ${BGN}$BRG${CL}"
|
||||
echo -e "${DGN}Using Bridge: ${BGN}$BRG${CL}"
|
||||
echo -e "${DGN}Using MAC Address: ${BGN}$MAC${CL}"
|
||||
echo -e "${DGN}Using MAC Address: ${BGN}$MAC1${CL}"
|
||||
echo -e "${DGN}Using Vlan: ${BGN}$VLAN1${CL}"
|
||||
echo -e "${DGN}Using Vlan: ${BGN}$VLAN1${CL}"
|
||||
echo -e "${DGN}Using Interface MTU Size: ${BGN}$MTU1${CL}"
|
||||
echo -e "${DGN}Using Interface MTU Size: ${BGN}$MTU1${CL}"
|
||||
echo -e "${CUS}Creating a $NAME using the above advanced settings${CL}"
|
||||
echo -e "${CUS}Using Advanced Settings${CL}"
|
||||
echo -e "$summary"
|
||||
msg_warn "$(translate "Storage plan selection cancelled.")"
|
||||
msg_ok "$(translate "Virtual Disks Created:")"
|
||||
msg_ok "$(translate "Import Disks Selected:")"
|
||||
msg_ok "$(translate "Controllers + NVMe Selected:")"
|
||||
msg_info "$(translate "Detecting storage for disk images...")"
|
||||
msg_error "Unable to detect a valid storage location."
|
||||
msg_info "$(translate "Detecting PCI storage controllers and NVMe devices...")"
|
||||
msg_info "Creating images directory"
|
||||
msg_ok "Images directory created: $IMAGES_DIR"
|
||||
echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}"
|
||||
echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}"
|
||||
echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}"
|
||||
echo -e "${DGN}${TAB}Selected Loader: ${BGN}$LOADER_NAME${CL}"
|
||||
msg_error "No custom image selected"
|
||||
echo -e "${DGN}${TAB}Using Custom Image: ${BGN}$(basename "$LOADER_FILE")${CL}"
|
||||
echo -e "${DGN}${TAB}Retrieving the URL for the ${BGN}$LOADER_NAME loader${CL}"
|
||||
msg_info "Installing unzip..."
|
||||
msg_error "Failed to install unzip"
|
||||
msg_ok "Installed unzip successfully."
|
||||
msg_error "$(translate "Failed to retrieve loader release metadata.")"
|
||||
msg_error "$(translate "No unambiguous loader download found in release metadata.")"
|
||||
msg_error "$(translate "Failed to prepare loader download directory.")"
|
||||
msg_error "$(translate "Failed to download loader image.")"
|
||||
msg_error "$(translate "Failed to extract a nonempty loader image.")"
|
||||
msg_error "$(translate "Failed to save loader image.")"
|
||||
msg_ok "Downloaded ${CL}${BL}${FILE}${CL} to ${IMAGES_DIR}"
|
||||
msg_error "Unable to detect a valid storage location for EFI disk." >&2
|
||||
msg_warn "$(translate "EFI storage selection cancelled.")" >&2
|
||||
msg_error "Unable to detect a valid storage location." >&2
|
||||
msg_warn "$(translate "Storage selection cancelled for $purpose.")" >&2
|
||||
msg_error "Failed to create base VM. Check VM ID and host configuration."
|
||||
msg_ok "Create a $NAME"
|
||||
msg_info "Configuring EFI disk"
|
||||
msg_error "EFI storage selection failed or was cancelled."
|
||||
msg_ok "EFI disk created (raw) and configured on ${CL}${BL}$EFI_STORAGE${GN}${CL}"
|
||||
msg_error "Failed to configure EFI disk"
|
||||
msg_ok "EFI disk created and configured on ${CL}${BL}$EFI_STORAGE${GN}${CL}"
|
||||
msg_error "Failed to configure EFI disk"
|
||||
msg_error "Failed to create EFI disk"
|
||||
msg_error "Loader storage selection failed or was cancelled."
|
||||
echo -n "Importing loader disk: "
|
||||
echo -n "."
|
||||
msg_error "Loader import failed. No disk detected."
|
||||
msg_ok "Loader imported successfully to ${CL}${BL}$LOADER_STORAGE${GN}${CL}"
|
||||
msg_ok "Configured loader disk as ide0"
|
||||
msg_error "Failed to assign loader disk to ide0"
|
||||
msg_error "Loader import failed. No disk detected in config."
|
||||
msg_ok "Configured loader disk as ide0"
|
||||
msg_error "Failed to assign loader disk"
|
||||
msg_ok "Loader configured as boot device."
|
||||
msg_info "Creating virtual disk (format=raw) for $STORAGE_TYPE..."
|
||||
msg_error "Failed to assign disk $DISK_NUM ($SATA_ID) on $STORAGE"
|
||||
msg_info "Allocating virtual disk for $STORAGE_TYPE..."
|
||||
msg_error "Failed to allocate virtual disk $DISK_NUM"
|
||||
msg_error "Failed to configure virtual disk as $SATA_ID"
|
||||
msg_ok "Configured virtual disk as $SATA_ID, ${SIZE}GB on ${CL}${BL}$STORAGE${CL} ${GN}"
|
||||
msg_ok "Configured import disk ${CL}${BL}($DISK_MODEL $DISK_SIZE)${CL}${GN} as $DISK_ID"
|
||||
msg_error "Failed to configure import disk $DISK as $DISK_ID"
|
||||
msg_warn "$(translate "Skipping SR-IOV device"): ${SRIOV_SKIPPED}"
|
||||
msg_warn "$(translate "No eligible Controller/NVMe devices remain after SR-IOV filtering. Skipping.")"
|
||||
msg_warn "$(translate "IOMMU was configured during this wizard and a reboot is pending.")"
|
||||
msg_warn "$(translate "Controller + NVMe assignment will be written now and become active after host reboot.")"
|
||||
msg_error "$(translate "IOMMU is not active. Skipping Controller + NVMe assignment.")"
|
||||
msg_error "$(translate "Controller + NVMe passthrough requires machine type q35. Skipping controller assignment.")"
|
||||
msg_warn "Controller/NVMe already present in VM config (${PCI_DEV})"
|
||||
msg_warn "Controller/NVMe already present in VM config (${PCI_DEV})"
|
||||
msg_warn "$(translate "Controller/NVMe is in use by running VM") ${SRC_VMID} ($(translate "stop source VM first"))"
|
||||
msg_warn "$(translate "Start on boot disabled for VM") ${SRC_VMID}"
|
||||
msg_ok "$(translate "Controller/NVMe removed from source VM") ${SRC_VMID} (${PCI_DEV})"
|
||||
msg_info2 "$(translate "Skipped device"): ${PCI_DEV}"
|
||||
msg_ok "Configured controller/NVMe as hostpci${HOSTPCI_INDEX}: ${PCI_DEV}"
|
||||
msg_error "Failed to configure controller/NVMe: ${PCI_DEV}"
|
||||
msg_error "No disks/controllers configured."
|
||||
msg_info "Setting VM description"
|
||||
msg_error "Failed to set VM description"
|
||||
msg_ok "Configured VM description"
|
||||
msg_error "VM created with errors. Check configuration."
|
||||
msg_success "$(translate "Completed Successfully with GPU passthrough configured!")"
|
||||
msg_warn "$(translate "Host VFIO configuration changed (initramfs updated). Reboot required before starting the VM.")"
|
||||
msg_success "$(translate "Completed Successfully!")"
|
||||
msg_warn "$(translate "GPU passthrough was skipped (no compatible GPU detected).")"
|
||||
msg_warn "$(translate "GPU passthrough was not applied.")"
|
||||
echo -e "${TAB}${GN}$(translate "Next Steps:")${CL}"
|
||||
echo -e "${TAB}1. $(translate "Start the VM")"
|
||||
echo -e "${TAB}2. $(translate "Open the VM console and wait for the loader to boot")"
|
||||
echo -e "${TAB}3. $(translate "In the loader interface, follow the instructions to select your Synology model")"
|
||||
echo -e "${TAB}4. $(translate "Complete the DSM installation wizard")"
|
||||
echo -e "${TAB}5. $(translate "Find your device using https://finds.synology.com")"
|
||||
echo -e "${TAB}- $(translate "If you want to use a physical monitor on the passthrough GPU:")"
|
||||
echo -e "${TAB}• $(translate "First complete DSM setup and verify Web UI/SSH access.")"
|
||||
echo -e "${TAB}• $(translate "Then change the VM display to none (vga: none) when the system is stable.")"
|
||||
msg_ok "$(translate "IOMMU has been enabled — a system reboot is required")"
|
||||
msg_warn "$(translate "Rebooting the system...")"
|
||||
msg_info2 "$(translate "To use the VM without issues, the host must be restarted before starting it.")"
|
||||
msg_info2 "$(translate "Do not start the VM until the system has been rebooted.")"
|
||||
msg_warn "$(translate "VM creation cancelled before disk planning.")"
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Sprint 12B: re-run a single post-install function
|
||||
# ==========================================================
|
||||
# Invoked by the Monitor's "Update" buttons (Settings → ProxMenux
|
||||
# Optimizations) and by the bash menu's update flow. Sources the
|
||||
# appropriate post_install script and invokes one specific function so
|
||||
# the user can update a single tool without re-running the whole flow.
|
||||
#
|
||||
# Two invocation modes:
|
||||
#
|
||||
# SINGLE — set SOURCE_TYPE + FUNCTION_NAME (+ optionally TOOL_KEY):
|
||||
# SOURCE_TYPE=auto FUNCTION_NAME=install_log2ram_auto
|
||||
#
|
||||
# BATCH — set FUNCTIONS_BATCH to a newline-separated list of
|
||||
# "source:function:tool_key" triples (tool_key optional):
|
||||
# FUNCTIONS_BATCH="auto:install_log2ram_auto:log2ram
|
||||
# custom:install_ceph:ceph"
|
||||
#
|
||||
# In batch mode the wrapper iterates the list, sourcing each flow once
|
||||
# and re-using its function definitions. Tools coming from the same
|
||||
# flow share a single source step so the terminal output stays clean.
|
||||
# ==========================================================
|
||||
|
||||
set -e
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if command -v load_language >/dev/null 2>&1; then
|
||||
load_language
|
||||
fi
|
||||
if command -v initialize_cache >/dev/null 2>&1; then
|
||||
initialize_cache
|
||||
fi
|
||||
|
||||
if command -v show_proxmenux_logo >/dev/null 2>&1; then
|
||||
show_proxmenux_logo
|
||||
fi
|
||||
|
||||
SOURCE_TYPE="${SOURCE_TYPE:-}"
|
||||
FUNCTION_NAME="${FUNCTION_NAME:-}"
|
||||
TOOL_KEY="${TOOL_KEY:-${FUNCTION_NAME}}"
|
||||
FUNCTIONS_BATCH="${FUNCTIONS_BATCH:-}"
|
||||
|
||||
# Build the list of (source, function, tool_key) tuples from either the
|
||||
# single-mode env vars or the batch payload.
|
||||
declare -a BATCH=()
|
||||
if [[ -n "$FUNCTIONS_BATCH" ]]; then
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
BATCH+=("$line")
|
||||
done <<< "$FUNCTIONS_BATCH"
|
||||
elif [[ -n "$SOURCE_TYPE" && -n "$FUNCTION_NAME" ]]; then
|
||||
BATCH+=("${SOURCE_TYPE}:${FUNCTION_NAME}:${TOOL_KEY}")
|
||||
else
|
||||
echo "ERROR: provide either SOURCE_TYPE+FUNCTION_NAME (single mode) or FUNCTIONS_BATCH (batch mode)."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Source the flow scripts on first use. Both auto and customizable
|
||||
# guard their entry point with `if [[ "${BASH_SOURCE[0]}" == "${0}" ]]`,
|
||||
# so sourcing loads function definitions without triggering the
|
||||
# interactive menu or the extremeshok dialog.
|
||||
# ----------------------------------------------------------------------
|
||||
SOURCED_AUTO=0
|
||||
SOURCED_CUSTOM=0
|
||||
ensure_flow_loaded() {
|
||||
local source_type="$1"
|
||||
case "$source_type" in
|
||||
# `detected` is the synthetic source the registry assigns to a
|
||||
# tool when a legacy detector finds it on the host but the
|
||||
# entry was never written to installed_tools.json (e.g.
|
||||
# proxmox_repos on a pre-1.2.2 install). Treat it as `auto`
|
||||
# for the source-loading step — the legacy-tool's wrapper
|
||||
# function lives in auto_post_install.sh, and once the update
|
||||
# runs successfully `register_tool` overwrites the synthetic
|
||||
# entry with source="auto" so subsequent runs don't hit this
|
||||
# branch again.
|
||||
auto|detected)
|
||||
if [[ $SOURCED_AUTO -eq 0 ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$LOCAL_SCRIPTS/post_install/auto_post_install.sh"
|
||||
SOURCED_AUTO=1
|
||||
fi
|
||||
;;
|
||||
custom)
|
||||
if [[ $SOURCED_CUSTOM -eq 0 ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$LOCAL_SCRIPTS/post_install/customizable_post_install.sh"
|
||||
SOURCED_CUSTOM=1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: invalid source '$source_type' (must be 'auto', 'custom', or 'detected')"
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Suppress the Monitor's `service_fail` notifications while this
|
||||
# wrapper runs. Some post-install functions legitimately restart their
|
||||
# services, and systemd can expose a transient "failed" state before the
|
||||
# fresh unit starts — same class of noise as PVE services restarting
|
||||
# during an apt full-upgrade. Shared markers with
|
||||
# scripts/utilities/proxmox_update.sh; consumed by
|
||||
# AppImage/scripts/notification_events.py::is_apt_active_on_host.
|
||||
_PROXMENUX_UPDATE_MARKER="/var/run/proxmenux-update-in-progress"
|
||||
_PROXMENUX_UPDATE_FINISHED_MARKER="/var/run/proxmenux-update-just-finished"
|
||||
_proxmenux_postinstall_cleanup() {
|
||||
rm -f "$_PROXMENUX_UPDATE_MARKER"
|
||||
touch "$_PROXMENUX_UPDATE_FINISHED_MARKER"
|
||||
}
|
||||
trap _proxmenux_postinstall_cleanup EXIT
|
||||
touch "$_PROXMENUX_UPDATE_MARKER"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Run each tool. We don't bail on the first failure — the user marked a
|
||||
# multi-select, they expect every chosen tool to be attempted. RCs are
|
||||
# collected and the wrapper exits non-zero if any failed.
|
||||
# ----------------------------------------------------------------------
|
||||
TOTAL=${#BATCH[@]}
|
||||
FAILED=0
|
||||
INDEX=0
|
||||
for entry in "${BATCH[@]}"; do
|
||||
INDEX=$((INDEX + 1))
|
||||
IFS=':' read -r src fn tkey <<< "$entry"
|
||||
[[ -z "$tkey" ]] && tkey="$fn"
|
||||
|
||||
if command -v msg_info2 >/dev/null 2>&1; then
|
||||
msg_info2 "[$INDEX/$TOTAL] Updating ${tkey} (running ${fn} from ${src} flow)..."
|
||||
else
|
||||
echo "[ProxMenux] [$INDEX/$TOTAL] Updating ${tkey} (running ${fn} from ${src} flow)..."
|
||||
fi
|
||||
|
||||
if ! ensure_flow_loaded "$src"; then
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! declare -F "$fn" >/dev/null 2>&1; then
|
||||
if command -v msg_error >/dev/null 2>&1; then
|
||||
msg_error "Function '$fn' is not defined in the ${src} flow."
|
||||
else
|
||||
echo "ERROR: function '$fn' is not defined in the ${src} flow."
|
||||
fi
|
||||
FAILED=$((FAILED + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
set +e
|
||||
"$fn"
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
if [[ $RC -eq 0 ]]; then
|
||||
if command -v msg_ok >/dev/null 2>&1; then
|
||||
msg_ok "Update for ${tkey} completed."
|
||||
else
|
||||
echo "[ProxMenux] Update for ${tkey} completed."
|
||||
fi
|
||||
else
|
||||
if command -v msg_error >/dev/null 2>&1; then
|
||||
msg_error "Update for ${tkey} exited with status $RC."
|
||||
else
|
||||
echo "ERROR: update for ${tkey} exited with status $RC."
|
||||
fi
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $FAILED -eq 0 ]]; then
|
||||
if command -v msg_success >/dev/null 2>&1; then
|
||||
msg_success "All $TOTAL update(s) completed successfully."
|
||||
else
|
||||
echo "[ProxMenux] All $TOTAL update(s) completed successfully."
|
||||
fi
|
||||
else
|
||||
if command -v msg_warn >/dev/null 2>&1; then
|
||||
msg_warn "$FAILED of $TOTAL updates failed — see the messages above."
|
||||
else
|
||||
echo "WARNING: $FAILED of $TOTAL updates failed."
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v msg_success >/dev/null 2>&1; then
|
||||
msg_success "Press Enter to close this terminal..."
|
||||
else
|
||||
echo "Press Enter to close this terminal..."
|
||||
fi
|
||||
read -r 2>/dev/null || true
|
||||
|
||||
[[ $FAILED -eq 0 ]] && exit 0 || exit 1
|
||||
@@ -0,0 +1,23 @@
|
||||
msg_info "$(translate "Installing dependencies: ${MISSING[*]}")"
|
||||
msg_ok "$(translate "All dependencies installed and verified.")"
|
||||
msg_error "$(translate "Failed to install: ${MISSING[*]}")"
|
||||
msg_error "$(translate "Failed to install: ${MISSING[*]}")"
|
||||
msg_error "$(translate "Could not determine a valid ISO storage directory.")"
|
||||
msg_error "$(translate "Failed to create temporary directory:") $TMP_DIR"
|
||||
msg_warn "$(translate "Cancelled by user or empty URL.")"
|
||||
msg_error "$(translate "The URL does not contain the required parameters (id, pack, edition).")"
|
||||
echo -e "\n${BGN}=============== UUP Dump Creator ===============${CL}"
|
||||
echo -e " ${BGN}🆔 ID:${CL} ${DGN}$BUILD_ID${CL}"
|
||||
echo -e " ${BGN}🌐 Language:${CL} ${DGN}$LANG${CL}"
|
||||
echo -e " ${BGN}💿 Edition:${CL} ${DGN}$EDITION${CL}"
|
||||
echo -e " ${BGN}🖥️ Architecture:${CL} ${DGN}$ARCH${CL}"
|
||||
echo -e " ${BGN}📁 Destination:${CL} ${DGN}$OUT_DIR${CL}"
|
||||
echo -e "${BGN}===============================================${CL}\n"
|
||||
echo -e "\n${GN}=======================================${CL}"
|
||||
echo -e " 💿 ${GN}Starting ISO conversion...${CL}"
|
||||
echo -e "${GN}=======================================${CL}\n"
|
||||
msg_ok "$(translate "ISO created successfully:") $OUT_DIR/$(basename "$ISO_FILE")"
|
||||
msg_ok "$(translate "Cleaning temporary files...")"
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
msg_warn "$(translate "No ISO was generated.")"
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
@@ -0,0 +1,61 @@
|
||||
// Actual Settings handlers and real fetchApi; all responses are inert fixtures.
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
|
||||
const {execFileSync}=require('node:child_process')
|
||||
const root=path.resolve(__dirname,'..'),app=path.join(root,'AppImage')
|
||||
const ts=require(path.join(app,'node_modules/typescript'))
|
||||
const catalogs=Object.fromEntries(['en','sk','de'].map(locale=>[locale,JSON.parse(fs.readFileSync(path.join(app,`messages/${locale}/common.json`)))]))
|
||||
const translate=locale=>(key,params={})=>{
|
||||
const find=data=>key.split('.').reduce((item,name)=>item?.[name],data)
|
||||
return (find(catalogs[locale])??find(catalogs.en)??key).replace(/\{(\w+)\}/g,(all,key)=>params[key]??all)
|
||||
}
|
||||
const flatten=node=>!node||typeof node!=='object'?[]:[node,...[node.props?.children].flat(Infinity).flatMap(flatten)]
|
||||
const text=node=>node==null||node===false?'':typeof node!=='object'?String(node):[node.props?.children].flat(Infinity).map(text).join(' ')
|
||||
const compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,jsx:ts.JsxEmit.ReactJSX}}).outputText
|
||||
const source=fs.readFileSync(path.join(app,'components/settings.tsx'),'utf8')
|
||||
const descriptions={exports:{},require:name=>({default:require(path.resolve(app,'lib',name))})};vm.runInNewContext(compile(fs.readFileSync(path.join(app,'lib/optimization-descriptions.ts'),'utf8')),descriptions)
|
||||
const tools=['one','two'].map(key=>({key,name:'Demo '+key,enabled:true,has_source:true,version:'1.0',available_version:'1.1'}))
|
||||
const sourceBody={success:true,function:'demo',script:'demo.sh',source:'demo() {\n echo "<tag>& $HOME"\n}\n',deprecated:false}
|
||||
function fixture(locale='sk',catalog=tools){
|
||||
let cursor=0,states=[],refs=[],effects=[],memo=[],tree,language=locale,reply=()=>sourceBody
|
||||
const calls=[],events={},timers=[],copies=[];let fallbackCopies=0
|
||||
const t=(key,params)=>translate(language)(key,params)
|
||||
const react={
|
||||
useState(value){const i=cursor++;if(!(i in states))states[i]=typeof value==='function'?value():value;return[states[i],v=>{states[i]=typeof v==='function'?v(states[i]):v}]},
|
||||
useRef(value){const i=cursor++;return refs[i]??=( {current:value})},
|
||||
useEffect(fn,deps){const i=cursor++,old=memo[i];if(!old||deps?.some((v,n)=>v!==old.deps?.[n]))effects.push(()=>{old?.cleanup?.();memo[i]={deps,cleanup:fn()}})},
|
||||
}
|
||||
const response=(body,status=200)=>({ok:status<400,status,statusText:'fixture',headers:{get:()=> 'application/json'},json:async()=>body})
|
||||
const fetch=async(url,options={})=>{
|
||||
const endpoint=String(url).replace(/^https?:\/\/[^/]+/,'');calls.push({endpoint,method:options.method||'GET'})
|
||||
assert.equal(options.method||'GET','GET','Source viewer may never mutate')
|
||||
if(endpoint.includes('/tool-source/')){const result=await reply(endpoint);if(result instanceof Error)throw result;return result?.http?response(result.body,result.http):response(result)}
|
||||
if(endpoint.endsWith('/installed-tools'))return response({success:true,installed_tools:catalog,updates_available_count:catalog.filter(tool=>tool.has_update).length})
|
||||
return response({categories:[],storages:[],interfaces:[],dismissed:[],success:true,candidates:[],configured:false})
|
||||
}
|
||||
const api={exports:{},fetch,process:{env:{}},console:{error(){}},require:()=>require('./ts-fixture.cjs').loadTypeScript('lib/api-errors.ts')};vm.runInNewContext(compile(fs.readFileSync(path.join(app,'lib/api-config.ts'),'utf8')),api)
|
||||
const add=(name,handler)=>{(events[name]??=new Set()).add(handler)},remove=(name,handler)=>events[name]?.delete(handler)
|
||||
const win={addEventListener:add,removeEventListener:remove,setTimeout:fn=>{timers.push(fn);return timers.length},isSecureContext:true}
|
||||
const doc={addEventListener:add,removeEventListener:remove,body:{appendChild(){},removeChild(){}},createElement:()=>({style:{},focus(){},select(){}}),execCommand:()=>{fallbackCopies++;return true}}
|
||||
const clipboard={writeText:async value=>copies.push(value)}
|
||||
const context={exports:{},window:win,document:doc,navigator:{clipboard},console:{error(){}},setTimeout:win.setTimeout,
|
||||
require:name=>{
|
||||
if(name==='react')return react
|
||||
if(name==='react/jsx-runtime')return{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})}
|
||||
if(name.includes('optimization-descriptions'))return descriptions.exports
|
||||
if(name.includes('api-config'))return api.exports
|
||||
if(name.includes('i18n/provider'))return{SUPPORTED_LANGUAGES:[],useI18n:()=>({t,language,setLanguage:value=>language=value})}
|
||||
if(name.includes('use-platform'))return{useIsPbs:()=>false}
|
||||
if(name.includes('format-network'))return{getNetworkUnit:()=> 'Bytes'}
|
||||
return new Proxy({},{get:(_,key)=>key})
|
||||
}}
|
||||
vm.runInNewContext(compile(source),context)
|
||||
const render=()=>{cursor=0;effects=[];tree=context.exports.Settings();for(const effect of effects)effect();return tree}
|
||||
const settle=async()=>{for(let i=0;i<24;i++)await Promise.resolve();render()}
|
||||
const tool=name=>flatten(tree).find(node=>node.props?.title===t('settings.optimizations.sourceTitle')&&text(node).includes(descriptions.exports.optimizationToolName(name,catalog.find(tool=>tool.key===name)?.name||'Demo '+name,t)))
|
||||
const dialog=()=>flatten(tree).find(node=>node.props?.role==='dialog')
|
||||
const close=()=>flatten(tree).find(node=>node.props?.['aria-label']===t('actions.close')).props.onClick()
|
||||
return{render,settle,tool,dialog,close,calls,copies,timers,tree:()=>tree,setReply:value=>reply=value,t,
|
||||
language:value=>{language=value;render()},escape:()=>events.keydown?.forEach(fn=>fn({key:'Escape'})),
|
||||
unmount:()=>memo.forEach(effect=>effect?.cleanup?.()),clipboard,win,fallbackCopies:()=>fallbackCopies}
|
||||
}
|
||||
module.exports={fixture,flatten,text,translate,catalogs,descriptions}
|
||||
@@ -0,0 +1,66 @@
|
||||
const assert=require('node:assert/strict')
|
||||
const {loadTypeScript}=require('./ts-fixture.cjs')
|
||||
function fixture(language='sk',token='fixture-token'){
|
||||
const local=new Map(token?[['proxmenux-auth-token',token]]:[]),session=new Map(),calls=[];let reloads=0,reply=()=>new Response('{}',{headers:{'Content-Type':'application/json'}})
|
||||
const storage=map=>({getItem:key=>map.get(key)||null,setItem:(key,v)=>map.set(key,v),removeItem:key=>map.delete(key)})
|
||||
const win={localStorage:storage(local),location:{protocol:'https:',hostname:'fixture.invalid',port:'',reload:()=>reloads++}}
|
||||
const globals={window:win,document:{documentElement:{lang:language}},localStorage:win.localStorage,sessionStorage:storage(session),console:{error(){}},fetch:async(url,options)=>{calls.push({url,options});return reply()}}
|
||||
const {fetchApi}=loadTypeScript('lib/api-config.ts',globals)
|
||||
return{fetchApi,calls,reply:value=>reply=value,globals,local,reloads:()=>reloads}
|
||||
}
|
||||
async function main(){
|
||||
for(const lang of ['sk','en','de']){
|
||||
const f=fixture(lang),sk=lang==='sk'
|
||||
f.reply(()=>new Response('html',{status:502,statusText:'Bad Gateway',headers:{'Content-Type':'text/html'}}))
|
||||
await assert.rejects(f.fetchApi('/api/demo'),e=>e.message===(sk?'Požiadavka na API zlyhala: 502 Bad Gateway':'API request failed: 502 Bad Gateway'))
|
||||
f.reply(()=>new Response('<html>',{headers:{'Content-Type':'text/html'}}))
|
||||
await assert.rejects(f.fetchApi('/api/demo'),e=>e.message===(sk?'Očakávala sa odpoveď JSON, ale prišiel typ text/html':'Expected JSON response but got text/html'))
|
||||
f.reply(()=>new Response('{',{headers:{'Content-Type':'application/json'}}))
|
||||
await assert.rejects(f.fetchApi('/api/demo'),e=>e.message===(sk?'Odpoveď z /api/demo obsahuje neplatný JSON':'Invalid JSON response from /api/demo'))
|
||||
f.reply(()=>({ok:true,headers:{get:()=>null},text:async()=>''}))
|
||||
await assert.rejects(f.fetchApi('/api/demo'),e=>e.message.includes(sk?'neznámy typ obsahu':'unknown content type'))
|
||||
const detail={error:'Original technical detail <raw>&',details:{keep:1},suggestion:'Keep diagnostics'}
|
||||
f.reply(()=>new Response(JSON.stringify(detail),{status:400,headers:{'Content-Type':'application/json'}}))
|
||||
await assert.rejects(f.fetchApi('/api/demo',{method:'POST',body:'fixture'}),e=>{assert.equal(e.message,detail.error);assert.equal(e.status,400);assert.deepEqual(e.body,detail);return true})
|
||||
f.reply(()=>new Response('{',{status:400,statusText:'Bad Request',headers:{'Content-Type':'application/json'}}))
|
||||
await assert.rejects(f.fetchApi('/api/demo'),e=>e.message.includes('400 Bad Request'))
|
||||
const result={ok:true,items:[1,2],empty:null};f.reply(()=>new Response(JSON.stringify(result),{headers:{'Content-Type':'application/json'}}))
|
||||
assert.deepEqual(await f.fetchApi('/api/demo',{method:'PUT',body:'unchanged',headers:{'X-Fixture':'keep'}}),result)
|
||||
const call=f.calls.at(-1);assert.equal(call.url,'/api/demo');assert.equal(call.options.method,'PUT');assert.equal(call.options.body,'unchanged');assert.equal(call.options.headers.Authorization,'Bearer fixture-token');assert.equal(call.options.headers['X-Fixture'],'keep');assert.equal(call.options.cache,'no-store')
|
||||
assert.equal(f.calls.length,7,'Each request calls fetch exactly once, no retries or side calls')
|
||||
const network=new Error('Original network diagnostic');f.reply(()=>{throw network});await assert.rejects(f.fetchApi('/api/demo'),e=>e===network)
|
||||
assert.equal(f.calls.length,8);assert.equal(f.reloads(),0)
|
||||
}
|
||||
for(const token of ['', 'fixture-token']){
|
||||
const f=fixture('sk',token);f.reply(()=>new Response('',{status:401}))
|
||||
await assert.rejects(f.fetchApi('/api/demo'),/Chýba platné prihlásenie/)
|
||||
assert.equal(f.local.has('proxmenux-auth-token'),false);assert.equal(f.reloads(),token?1:0)
|
||||
await assert.rejects(f.fetchApi('/api/demo'),/Chýba platné prihlásenie/);assert.equal(f.reloads(),token?1:0)
|
||||
assert.equal(f.calls.length,2)
|
||||
}
|
||||
const privateMode=loadTypeScript('lib/api-errors.ts',{document:{documentElement:{lang:'sk'}},window:{get localStorage(){throw new Error('denied')}}})
|
||||
assert.equal(privateMode.apiFailure('invalidJson',{endpoint:'/api/demo'}),'Odpoveď z /api/demo obsahuje neplatný JSON')
|
||||
const stored=loadTypeScript('lib/api-errors.ts',{window:{localStorage:{getItem:()=> 'sk'}}})
|
||||
assert.equal(stored.apiFailure('unknownContentType'),'neznámy typ obsahu')
|
||||
// Existing consumer paths, with their actual imports bound to real fetchApi.
|
||||
const helper=fixture();Object.assign(helper.globals,{AbortSignal,setTimeout:fn=>{fn();return 1}})
|
||||
const control=loadTypeScript('lib/vm-control.ts',helper.globals)
|
||||
helper.reply(()=>new Response(JSON.stringify({status:'stopped',exitstatus:'OK',guest_status:'running'}),{headers:{'Content-Type':'application/json'}}))
|
||||
await control.waitForVmControlTask(999,'reboot','UPID:fixture');assert.ok(helper.calls.at(-1).url.includes('/control-task/UPID%3Afixture'))
|
||||
const archive=loadTypeScript('lib/guest-backup-archives.ts',helper.globals),backup={volid:'store:fixture',storage:'store'}
|
||||
helper.reply(()=>new Response(JSON.stringify({results:[{volid:backup.volid,status:'deleted'}]}),{headers:{'Content-Type':'application/json'}}))
|
||||
assert.equal((await archive.deleteGuestBackups(999,[backup]))[0].status,'deleted')
|
||||
const deletion=helper.calls.at(-1);assert.equal(deletion.options.method,'POST');assert.equal(JSON.parse(deletion.options.body).confirm,true)
|
||||
helper.reply(()=>new Response('fixture',{status:500,statusText:'Fixture'}));const count=helper.calls.length
|
||||
await assert.rejects(archive.deleteGuestBackups(999,[backup]),/Požiadavka na API zlyhala/);assert.equal(helper.calls.length,count+1,'No retry of delete after a lost response')
|
||||
const updates=loadTypeScript('lib/lxc-update-observation.ts',helper.globals)
|
||||
helper.reply(()=>new Response(JSON.stringify({count:2,available:true}),{headers:{'Content-Type':'application/json'}}))
|
||||
assert.equal((await updates.checkLxcUpdates(999)).count,2);assert.equal(helper.calls.at(-1).url,'/api/vms/999/updates/check')
|
||||
helper.reply(()=>new Response(JSON.stringify(helper.calls.at(-1).options.method==='POST'?{job_id:'fixture'}:{state:'complete',update_check:{count:0}}),{headers:{'Content-Type':'application/json'}}))
|
||||
assert.equal((await updates.refreshCheckLxcUpdates(999)).count,0)
|
||||
const terminal=loadTypeScript('lib/terminal-ws.ts',helper.globals)
|
||||
helper.reply(()=>new Response('fixture',{status:500}));assert.equal(await terminal.getTicketedWsUrl('wss://fixture.invalid/ws'),'wss://fixture.invalid/ws')
|
||||
assert.equal(helper.calls.at(-1).url,'/api/terminal/ticket')
|
||||
console.log('PASS actual fetchApi: EN/SK/fallback, HTTP/type/JSON/401, backend detail/body/status, original requests and success; no extra fetch/retry/mutation')
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||
@@ -0,0 +1,18 @@
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
|
||||
const root=path.resolve(__dirname,'..'),ts=require(root+'/AppImage/node_modules/typescript')
|
||||
const code=ts.transpileModule(fs.readFileSync(root+'/AppImage/lib/api-config.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText
|
||||
async function main(){
|
||||
let calls=0,reply=()=>new Response(JSON.stringify({ok:true}),{headers:{'Content-Type':'application/json'}})
|
||||
// Existing consumers compile the actual transport in VM contexts without imports.
|
||||
const context={exports:{},process:{env:{}},console:{error(){}},fetch:async()=>{calls++;return reply()}}
|
||||
vm.runInNewContext(code,context)
|
||||
assert.deepEqual(await context.exports.fetchApi('/api/fixture'),{ok:true})
|
||||
const body={error:'Original backend detail',code:'fixture',details:{keep:true}}
|
||||
reply=()=>new Response(JSON.stringify(body),{status:400,headers:{'Content-Type':'application/json'}})
|
||||
await assert.rejects(context.exports.fetchApi('/api/fixture',{method:'POST',body:'unchanged'}),error=>{assert.equal(error.message,body.error);assert.equal(error.status,400);assert.deepEqual(error.body,body);return true})
|
||||
const network=new Error('Original network detail');reply=()=>{throw network}
|
||||
await assert.rejects(context.exports.fetchApi('/api/fixture'),error=>error===network)
|
||||
assert.equal(calls,3,'No locale import, retry or extra request on these paths')
|
||||
console.log('PASS legacy transport imports: success, backend payload/status and network identity without a locale loader; exactly three calls')
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||
@@ -0,0 +1,46 @@
|
||||
// Actual Settings callbacks; terminal is a JSX stub, never a real updater.
|
||||
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path')
|
||||
const {fixture,flatten,text,translate,catalogs,descriptions}=require('./optimization-settings-fixture.cjs')
|
||||
async function main(){
|
||||
const known={key:'time_sync',name:'Time Synchronization',has_source:true,has_update:true,version:'1.0',available_version:'1.1',function:'configure_time_sync',function_auto:'configure_time_sync',source:'auto',description:catalogs.en.settings.optimizations.toolDescriptions.timeSync}
|
||||
const unknown={...known,key:'future',name:'Future Tool',function:'future_fn',function_auto:'future_fn',description:'New server wording <raw>&'}
|
||||
for(const locale of ['sk','en','de']){
|
||||
const f=fixture(locale,[known,unknown]);f.render();await f.settle()
|
||||
const expected=translate(locale)('settings.optimizations.toolNames.time_sync')
|
||||
assert.ok(text(f.tree()).includes(expected));assert.ok(text(f.tree()).includes('Future Tool'))
|
||||
const open=flatten(f.tree()).find(n=>n.props?.title===f.t('settings.optimizations.viewUpdates'));assert.ok(open)
|
||||
open.props.onClick();f.render()
|
||||
assert.ok(text(f.tree()).includes(descriptions.exports.optimizationDescription(known.description,translate(locale))))
|
||||
assert.ok(text(f.tree()).includes(unknown.description))
|
||||
const count=f.calls.length
|
||||
flatten(f.tree()).find(n=>n.type==='button'&&text(n)===f.t('actions.cancel')).props.onClick();f.render()
|
||||
assert.equal(f.calls.length,count,'Cancel has no scan/update effects')
|
||||
open.props.onClick();f.render()
|
||||
flatten(f.tree()).find(n=>n.type==='button'&&text(n).trim()===f.t('settings.optimizations.updateSelected')).props.onClick();f.render()
|
||||
const terminal=flatten(f.tree()).find(n=>n.props?.scriptName==='update_post_install_function')
|
||||
assert.equal(terminal.props.params.FUNCTIONS_BATCH,'auto:configure_time_sync:time_sync\nauto:future_fn:future')
|
||||
assert.equal(terminal.props.params.EXECUTION_MODE,'web');assert.equal(f.calls.length,count,'Only an inert terminal node was created')
|
||||
const single=fixture(locale,[known]);single.render();await single.settle()
|
||||
const button=flatten(single.tree()).find(n=>n.props?.title===single.t('settings.optimizations.updateToolTitle',{name:expected,version:'1.1'}));assert.ok(button)
|
||||
button.props.onClick({stopPropagation(){}});single.render()
|
||||
const one=flatten(single.tree()).find(n=>n.props?.scriptName==='update_post_install_function')
|
||||
assert.equal(one.props.title,single.t('settings.optimizations.updateOneTitle',{name:expected}))
|
||||
assert.equal(one.props.params.FUNCTIONS_BATCH,'auto:configure_time_sync:time_sync')
|
||||
assert.equal([...f.calls,...single.calls].filter(c=>c.method!=='GET'||/scan|script-runner/.test(c.endpoint)).length,0)
|
||||
}
|
||||
for(const locale of ['sk','en','de']){
|
||||
const t=translate(locale)
|
||||
for(const [key,name] of Object.entries(catalogs.en.settings.optimizations.toolNames)){
|
||||
assert.equal(descriptions.exports.optimizationToolName(key,name,t),locale==='sk'?catalogs.sk.settings.optimizations.toolNames[key]:name)
|
||||
}
|
||||
assert.equal(descriptions.exports.optimizationToolName('future','Future <raw>&',t),'Future <raw>&')
|
||||
}
|
||||
const source=path.join(__dirname,'../scripts/post_install')
|
||||
const actual=new Set(['auto_post_install.sh','customizable_post_install.sh'].flatMap(file=>[...fs.readFileSync(path.join(source,file),'utf8').matchAll(/^\s*# description: (.+)$/gm)].map(m=>m[1].trim())))
|
||||
const english=catalogs.en.settings.optimizations.toolDescriptions
|
||||
assert.deepEqual(new Set(Object.values(english)),actual)
|
||||
for(const [key,description]of Object.entries(english))assert.equal(descriptions.exports.optimizationDescription(description,translate('sk')),catalogs.sk.settings.optimizations.toolDescriptions[key])
|
||||
assert.equal(descriptions.exports.optimizationDescription('Changed version description',translate('sk')),'Changed version description')
|
||||
console.log('PASS optimization names/descriptions: actual Settings cards/single/batch/cancel, unchanged payload, EN/SK/fallback, exact live-description matching; host calls 0')
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1})
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Run the real wrapper only against inert flow scripts and temporary markers."""
|
||||
from pathlib import Path
|
||||
import os,re,shlex,subprocess,tempfile,unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
FILE='scripts/post_install/update_post_install_function.sh'
|
||||
TRANSLATE=re.search(r'(?ms)^translate\(\) \{.*?^\}',(ROOT/'scripts/utils.sh').read_text()).group()
|
||||
|
||||
def run(source,params,language='en',utilities=True):
|
||||
with tempfile.TemporaryDirectory(prefix='postinstall-i18n-') as temp:
|
||||
tmp=Path(temp);base=tmp/'base';flow=base/'scripts/post_install';flow.mkdir(parents=True)
|
||||
trace=tmp/'trace';trace.write_text('');blocked=tmp/'blocked';blocked.write_text('')
|
||||
for name in ('auto_post_install.sh','customizable_post_install.sh'):
|
||||
(flow/name).write_text('fixture_ok() { echo ok >> "$FIXTURE_TRACE"; return 0; }\nfixture_fail() { echo fail >> "$FIXTURE_TRACE"; return 7; }\n')
|
||||
if utilities:
|
||||
(base/'utils.sh').write_text('load_language(){ LANGUAGE="$FIXTURE_LANG"; }; initialize_cache(){ :; }; show_proxmenux_logo(){ :; }; LANG_DIR='+shlex.quote(str(ROOT/'lang'))+'\n'+TRANSLATE+'\n'+'\n'.join(name+'(){ printf "%s\\n" "$1"; }' for name in ('msg_info2','msg_error','msg_ok','msg_warn','msg_success'))+'\n')
|
||||
binary=tmp/'bin';binary.mkdir()
|
||||
for name in ('qm','pct','pvesh','pvesm','apt-get','curl','wget','mount','systemctl','reboot'):
|
||||
p=binary/name;p.write_text('#!/bin/sh\necho '+shlex.quote(name)+' >> "$FIXTURE_BLOCKED"\nexit 97\n');p.chmod(0o755)
|
||||
rewritten=source.replace('/usr/local/share/proxmenux',str(base)).replace('/var/run/proxmenux-update-',str(tmp/'proxmenux-update-'))
|
||||
script=tmp/'wrapper.sh';script.write_text(rewritten)
|
||||
env={**os.environ,'PATH':str(binary)+':'+os.environ['PATH'],'FIXTURE_TRACE':str(trace),'FIXTURE_BLOCKED':str(blocked),'FIXTURE_LANG':language,'SOURCE_TYPE':'','FUNCTION_NAME':'','TOOL_KEY':'','FUNCTIONS_BATCH':'',**params}
|
||||
result=subprocess.run(['bash',str(script)],input='\n',text=True,capture_output=True,env=env,timeout=10)
|
||||
assert blocked.read_text()=='','Forbidden host command'
|
||||
assert not (tmp/'proxmenux-update-in-progress').exists(),'Marker cleanup'
|
||||
return result.returncode,result.stdout+result.stderr,trace.read_text()
|
||||
|
||||
class PostInstallI18n(unittest.TestCase):
|
||||
def test_existing_single_batch_failures_and_fallback_contract(self):
|
||||
before=(ROOT/'tests/fixtures/cli-before-i18n/update_post_install_function.sh').read_text();after=(ROOT/FILE).read_text()
|
||||
cases=[{}, {'SOURCE_TYPE':'auto','FUNCTION_NAME':'fixture_ok','TOOL_KEY':'fixture'}, {'SOURCE_TYPE':'detected','FUNCTION_NAME':'fixture_ok','TOOL_KEY':'fixture'}, {'SOURCE_TYPE':'custom','FUNCTION_NAME':'missing_fn','TOOL_KEY':'fixture'}, {'SOURCE_TYPE':'wrong','FUNCTION_NAME':'fixture_ok'}, {'FUNCTIONS_BATCH':'auto:fixture_ok:one\ncustom:fixture_fail:two\nauto:missing_fn:three\ndetected:fixture_ok:four'}]
|
||||
for utilities in (False,True):
|
||||
for params in cases:
|
||||
with self.subTest(utilities=utilities,params=params):self.assertEqual(run(before,params,utilities=utilities),run(after,params,utilities=utilities))
|
||||
def test_slovak_missing_failure_summary_and_no_real_host_calls(self):
|
||||
source=(ROOT/FILE).read_text()
|
||||
code,text,trace=run(source,{'FUNCTIONS_BATCH':'auto:fixture_ok:one\ncustom:fixture_fail:two\nauto:missing_fn:three'},language='sk')
|
||||
self.assertEqual(code,1);self.assertEqual(trace,'ok\nfail\n')
|
||||
self.assertIn('Aktualizácia one je dokončená.',text)
|
||||
self.assertIn('Aktualizácia two skončila s návratovým kódom 7.',text)
|
||||
self.assertIn("Funkcia 'missing_fn' nie je definovaná v postupe auto.",text)
|
||||
self.assertIn('Zlyhalo 2 z 3 aktualizácií',text)
|
||||
self.assertIn('Stlač Enter na zatvorenie terminálu',text)
|
||||
def test_syntax(self):subprocess.run(['bash','-n',str(ROOT/FILE)],check=True)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Real translate and display-only shell calls; never source a VM creator."""
|
||||
from pathlib import Path
|
||||
import importlib.util,json,os,re,shlex,subprocess,tempfile,unittest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
SPEC=importlib.util.spec_from_file_location('official_i18n',ROOT/'.github/scripts/build_translation_cache.py')
|
||||
OFFICIAL=importlib.util.module_from_spec(SPEC);SPEC.loader.exec_module(OFFICIAL)
|
||||
TRANSLATE=re.search(r'(?ms)^translate\(\) \{.*?^\}',(ROOT/'scripts/utils.sh').read_text()).group()
|
||||
FILES=('scripts/vm/synology.sh','scripts/utilities/uup_dump_iso_creator.sh')
|
||||
DISPLAY=re.compile(r'^\s*(?:msg_(?:info2?|ok|warn|error|success|title)|echo -[en])\s+".*"\s*(?:>&2)?\s*$')
|
||||
|
||||
def display_calls(source):
|
||||
return [line for line in source.splitlines() if DISPLAY.fullmatch(line) and not re.search(r'\$\((?!(?:translate|printf|basename)\b)',line)]
|
||||
|
||||
def render(calls,language):
|
||||
with tempfile.TemporaryDirectory(prefix='remaining-sk-display-') as tmp:
|
||||
blocked=Path(tmp)/'blocked';blocked.write_text('')
|
||||
definitions='\n'.join(name+'() { printf "%s\\n" "$1"; }' for name in ('msg_info','msg_info2','msg_ok','msg_warn','msg_error','msg_success','msg_title'))
|
||||
forbidden='\n'.join(name+'() { echo '+shlex.quote(name)+' >> '+shlex.quote(str(blocked))+'; return 97; }' for name in ('qm','pct','pvesh','apt-get','curl','wget','mount','reboot','systemctl','rm','touch','mkdir','chmod'))
|
||||
values={'NAME':'Synology VM','VMID':'999','IMAGES_DIR':'/fixture/images','EFI_STORAGE':'efi-store','LOADER_STORAGE':'loader-store','STORAGE':'data-store','STORAGE_TYPE':'zfspool','LOADER_NAME':'Arc','LOADER_FILE':'/fixture/loader.img','FILE':'loader.img','DISK_NUM':'2','SATA_ID':'sata2','SIZE':'32','DISK_MODEL':'Fixture SSD','DISK_SIZE':'32G','DISK_ID':'sata3','DISK':'/dev/fixture','PCI_DEV':'0000:01:00.0','HOSTPCI_INDEX':'0','HN':'fixture-vm','CORE_COUNT':'2','RAM_SIZE':'4096','BRG':'vmbr0','MAC':'02:00:00:00:00:01','VLAN1':'Default','MTU1':'Default','SERIAL_PORT':'socket','START_VM':'no','MACH':'q35','OUT_DIR':'/fixture/iso','BUILD_ID':'fixture','EDITION':'professional','ARCH':'amd64','DEFAULT_TMP':'/fixture/tmp','TMP_DIR':'/fixture/tmp','ISO_FILE':'fixture.iso','user_path':'/fixture'}
|
||||
catalog={} if language=='en' else json.loads((ROOT/'lang'/f'{language}.json').read_text())
|
||||
values.update({key:catalog.get('Default','Default') for key in ('VLAN1','MTU1')})
|
||||
assignments='\n'.join(key+'='+shlex.quote(value) for key,value in values.items())
|
||||
script='LANGUAGE='+shlex.quote(language)+'\nLANG_DIR='+shlex.quote(str(ROOT/'lang'))+'\n'+TRANSLATE+'\n'+definitions+'\n'+forbidden+'\n'+assignments+'\n'+'\n'.join(calls)+'\n'
|
||||
result=subprocess.run(['bash','-c',script],text=True,capture_output=True,timeout=15,check=True)
|
||||
assert blocked.read_text()==''
|
||||
return result.stdout+result.stderr
|
||||
|
||||
class RemainingCliI18n(unittest.TestCase):
|
||||
def test_real_default_and_advanced_wizard_cancel_before_host_calls(self):
|
||||
source=(ROOT/'scripts/vm/synology.sh').read_text()
|
||||
functions='\n'.join(re.search(r'(?ms)^function '+name+r'\(\) \{.*?^\}',source).group() for name in ('default_settings','select_disk_type','_build_storage_plan_summary','advanced_settings','exit_script'))
|
||||
for action in ('default_settings','advanced_settings'):
|
||||
with tempfile.TemporaryDirectory(prefix='remaining-sk-cancel-') as tmp:
|
||||
blocked=Path(tmp)/'blocked';blocked.write_text('');dialog_log=Path(tmp)/'dialog';dialog_log.write_text('')
|
||||
forbidden='\n'.join(name+'() { echo '+shlex.quote(name)+' >> '+shlex.quote(str(blocked))+'; return 97; }' for name in ('qm','pct','pvesh','pvesm','curl','wget','apt-get','mount','systemctl','reboot','select_loader'))
|
||||
script='LANGUAGE=sk\nLANG_DIR='+shlex.quote(str(ROOT/'lang'))+'\n'+TRANSLATE+'\n'+forbidden+'\n'+functions+'\nclear(){ :; }; sleep(){ :; }; header_info(){ :; }; msg_warn(){ echo "$1"; }; whiptail(){ printf "%s\\n" "$*" >> '+shlex.quote(str(dialog_log))+'; return 1; }; NAME="Synology VM"; NEXTID=999; GEN_MAC="02:00:00:00:00:01"; '+action
|
||||
result=subprocess.run(['bash','-c',script],text=True,capture_output=True,timeout=10)
|
||||
self.assertEqual(blocked.read_text(),'')
|
||||
self.assertIn('Zrušiť' if action=='default_settings' else 'Ukončiť skript',dialog_log.read_text())
|
||||
self.assertNotIn('command not found',result.stderr)
|
||||
def test_generated_uup_helper_is_localized_and_valid_without_execution(self):
|
||||
source=(ROOT/FILES[1]).read_text();body=source.split('cat > uup_download_linux.sh <<EOF\n',1)[1].split('\nEOF',1)[0]
|
||||
script='LANGUAGE=sk\nLANG_DIR='+shlex.quote(str(ROOT/'lang'))+'\n'+TRANSLATE+'\ncat <<EOF\n'+body+'\nEOF\n'
|
||||
generated=subprocess.check_output(['bash','-c',script],text=True,timeout=10)
|
||||
self.assertIn('Príkaz sa nenašiel.',generated);self.assertIn('Vytvorenie zoznamu súborov UUP',generated)
|
||||
subprocess.run(['bash','-n'],input=generated,text=True,check=True)
|
||||
def test_english_display_contract_and_slovak_errors(self):
|
||||
for file in FILES:
|
||||
old=(ROOT/'tests/fixtures/cli-before-i18n'/(Path(file).stem+'-display.sh')).read_text()
|
||||
new=(ROOT/file).read_text();before=display_calls(old);after=display_calls(new)
|
||||
self.assertEqual(len(before),len(after),file)
|
||||
self.assertEqual(render(before,'en'),render(after,'en'),file)
|
||||
translated=render(after,'sk')
|
||||
if file.endswith('synology.sh'):
|
||||
self.assertIn('Priradenie disku 2 (sata2) na data-store zlyhalo',translated)
|
||||
self.assertIn('Nenašlo sa použiteľné úložisko.',translated)
|
||||
self.assertNotIn('Failed to configure EFI disk',translated)
|
||||
self.assertIn('/fixture/images',translated)
|
||||
self.assertIn('Veľkosť MTU rozhrania: Predvolené',translated)
|
||||
self.assertNotIn('Default',translated)
|
||||
def test_official_extractor_and_placeholders(self):
|
||||
catalog=json.loads((ROOT/'lang/sk.json').read_text())
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
keys=OFFICIAL.extract_translate_texts(Path(tmp),[ROOT/file for file in FILES])
|
||||
self.assertGreater(len(keys),90)
|
||||
self.assertEqual([key for key in keys if not catalog.get(key)],[])
|
||||
for key in keys:
|
||||
self.assertEqual(re.findall(r'%(?:[0-9]+\$)?[sd]',key),re.findall(r'%(?:[0-9]+\$)?[sd]',catalog[key]),key)
|
||||
self.assertEqual(catalog['Boot disk:'],'Zavádzací disk:')
|
||||
self.assertEqual(catalog['Mount point:'],'Bod pripojenia:')
|
||||
def test_shell_syntax(self):
|
||||
for file in FILES:subprocess.run(['bash','-n',str(ROOT/file)],check=True)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,19 @@
|
||||
// Load real TS modules with injected host boundaries; no bundler or network.
|
||||
const fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
|
||||
const app=path.resolve(__dirname,'../AppImage'),ts=require(path.join(app,'node_modules/typescript'))
|
||||
function loadTypeScript(file,globals={}){
|
||||
const resolved=path.isAbsolute(file)?file:path.join(app,file)
|
||||
if(resolved.endsWith('.json'))return JSON.parse(fs.readFileSync(resolved,'utf8'))
|
||||
const exports={}
|
||||
const context={process:{env:{}},console,Error,SyntaxError,...globals,exports,module:{exports},require:name=>{
|
||||
if(name.startsWith('@/'))return loadTypeScript(path.join(app,name.slice(2))+'.ts',globals)
|
||||
if(!name.startsWith('.'))return require(name)
|
||||
let target=path.resolve(path.dirname(resolved),name)
|
||||
if(!fs.existsSync(target))target+='.ts'
|
||||
return loadTypeScript(target,globals)
|
||||
}}
|
||||
const compiled=ts.transpileModule(fs.readFileSync(resolved,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,esModuleInterop:true}}).outputText
|
||||
vm.runInNewContext(compiled,context,{filename:resolved})
|
||||
return context.module.exports
|
||||
}
|
||||
module.exports={loadTypeScript}
|
||||
Reference in New Issue
Block a user