🤞 Sync 2026-09-27 22:16:29

This commit is contained in:
github-actions[bot] committed 2026-09-27 22:16:29 +08:00
1 parent 8ede4a8320
commit 618f51e9dc
459 files changed
+44817 -2191

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
#!/bin/sh
set -eu
fail() {
printf 'build-apk-release: %s\n' "$*" >&2
exit 1
}
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
. "$root/apk-feed.env"
sdk="${OPENWRT_SDK_DIR:-}"
signing_key="${OPENWRT_APK_SIGNING_KEY:-}"
public_key="$root/$OPENWRT_APK_KEY_FILE"
output="$root/dist/apk"
[ -n "$sdk" ] || fail 'OPENWRT_SDK_DIR is required'
[ -d "$sdk" ] || fail "SDK directory not found: $sdk"
[ -n "$signing_key" ] || fail 'OPENWRT_APK_SIGNING_KEY is required'
[ -r "$signing_key" ] || fail "signing key not readable: $signing_key"
[ -r "$public_key" ] || fail "public key not found: $public_key"
case "$(basename "$sdk")" in
"${OPENWRT_APK_SDK_ARCHIVE%.tar.zst}") ;;
*) fail "unexpected SDK directory: $(basename "$sdk")" ;;
esac
for command in make openssl rsync sha256sum; do
command -v "$command" >/dev/null 2>&1 ||
fail "required command is missing: $command"
done
"$root/scripts/check-apk-trust.sh"
tmp="$(mktemp -d)"
cleanup() {
rm -rf "$tmp"
}
trap cleanup EXIT HUP INT TERM
openssl ec -in "$signing_key" -pubout \
-out "$tmp/derived-public.pem" >/dev/null 2>&1 ||
fail 'invalid EC signing key'
cmp -s "$tmp/derived-public.pem" "$public_key" ||
fail 'signing key does not match the shared public release key'
OPENWRT_APK_PUBLIC_KEY="$public_key" \
OPENWRT_APK_SIGNING_KEY="$signing_key" \
"$root/scripts/build-apk.sh"
apk_tool="$sdk/staging_dir/host/bin/apk"
[ -x "$apk_tool" ] || fail "SDK apk tool not found: $apk_tool"
package="$root/dist/${PKG_NAME}-${PKG_VERSION}.apk"
[ -r "$package" ] || fail "built APK was not found: $package"
"$apk_tool" --allow-untrusted adbsign --sign-key "$signing_key" "$package"
"$apk_tool" --keys-dir "$root/keys" verify "$package"
# Only this package is published. Nikitid/openwrt-feed collects the released
# APK and builds the shared signed index, so no public key, bootstrap script or
# index is shipped from here.
rm -rf "$output"
mkdir -p "$output"
cp "$package" "$output/"
(
cd "$output"
sha256sum "${PKG_NAME}-${PKG_VERSION}.apk" >SHA256SUMS.apk
sha256sum -c SHA256SUMS.apk
)
printf 'Signed APK built in %s\n' "$output"
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
set -eu
fail() {
printf 'build-apk: %s\n' "$*" >&2
exit 1
}
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
sdk="${OPENWRT_SDK_DIR:-}"
[ -n "$sdk" ] || fail 'OPENWRT_SDK_DIR is required'
[ -d "$sdk" ] || fail "SDK directory not found: $sdk"
command -v rsync >/dev/null 2>&1 || fail 'rsync is required'
"$root/scripts/check-version-sync.sh"
sdk_package="$sdk/package/$PKG_NAME"
rm -rf "$sdk_package"
mkdir -p "$sdk_package"
rsync -a --delete \
--exclude .git \
--exclude build \
--exclude dist \
--exclude .DS_Store \
"$root/" "$sdk_package/"
make -C "$sdk" defconfig
make -C "$sdk" "package/$PKG_NAME/clean" V=s
if [ -n "${OPENWRT_APK_SIGNING_KEY:-}" ] && [ -n "${OPENWRT_APK_PUBLIC_KEY:-}" ]; then
make -C "$sdk" \
BUILD_KEY_APK_SEC="$OPENWRT_APK_SIGNING_KEY" \
BUILD_KEY_APK_PUB="$OPENWRT_APK_PUBLIC_KEY" \
"package/$PKG_NAME/compile" V=s
else
make -C "$sdk" "package/$PKG_NAME/compile" V=s
fi
package_path="$(find "$sdk/bin/packages" -type f \
-name "${PKG_NAME}-${PKG_VERSION}.apk" -print -quit)"
[ -n "$package_path" ] || fail 'built APK was not found'
mkdir -p "$root/dist"
rm -f "$root/dist"/*.apk
cp "$package_path" "$root/dist/"
printf '%s\n' "$root/dist/${package_path##*/}"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
stage="${BUILD_DIR:-$root/build/ipk}/stage"
mkdir -p "$root/dist"
"$root/scripts/check-version-sync.sh"
"$root/scripts/stage-package.sh" "$stage"
rm -f "$root/dist"/*.ipk
python3 "$root/scripts/pack-ipk.py" "$stage" "$root/dist"
(
cd "$root/dist"
sha256sum ./*.ipk >SHA256SUMS
)
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
set -eu
fail() {
printf 'check-apk-trust: %s\n' "$*" >&2
exit 1
}
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/apk-feed.env"
public_key="$root/$OPENWRT_APK_KEY_FILE"
[ -r "$public_key" ] || fail "public key not found: $OPENWRT_APK_KEY_FILE"
actual="$(sha256sum "$public_key" | awk '{ print $1 }')"
[ "$actual" = "$OPENWRT_APK_TRUST_SHA256" ] ||
fail "public key checksum mismatch: $actual"
openssl pkey -pubin -in "$public_key" -noout >/dev/null 2>&1 ||
fail 'release public key is not a valid PEM public key'
tracked_files() {
find "$root" -path "$root/.git" -prune -o \
-path "$root/build" -prune -o -path "$root/dist" -prune -o \
-type f -print
}
# Keep this in sync with the secret patterns in .gitignore. The previous
# anchored form missed names such as release-private.pem.
if tracked_files |
grep -Ei '\.key$|private[^/]*\.(pem|key)$|signing[^/]*\.(pem|key)$|/signing/' \
>/dev/null; then
fail 'private signing material is present in the source tree'
fi
# A private key under an unexpected name still has recognisable contents. The
# scan runs in a subshell, so it only reports and leaves failing to the caller.
key_material="$(tracked_files |
grep -Ei '\.(pem|key|crt)$' |
while IFS= read -r candidate; do
if grep -q 'PRIVATE KEY' "$candidate"; then
printf '%s\n' "${candidate#"$root"/}"
fi
done)"
[ -z "$key_material" ] ||
fail "private key material found in: $(printf '%s' "$key_material" | tr '\n' ' ')"
# Contract with the shared feed (see openwrt-feed/docs/MEMBER_INTEGRATION.md):
# this repository signs
# and publishes only its own package. It must not host a feed URL, ship a trust
# bootstrap of its own or assemble an index.
[ "${OPENWRT_FEED_REPOSITORY:-}" = Nikitid/openwrt-feed ] ||
fail 'OPENWRT_FEED_REPOSITORY must name the shared feed'
[ ! -e "$root/scripts/install-openwrt25.sh" ] ||
fail 'the shared installer replaced scripts/install-openwrt25.sh; remove it'
if grep -rlE 'raw\.githubusercontent\.com/Nikitid/[A-Za-z0-9._-]+/apk-feed' \
"$root/scripts" "$root/docs" "$root/.github" "$root"/*.md "$root"/*.env \
2>/dev/null | grep -q .; then
fail 'a retired per-application feed URL is still referenced'
fi
# Contract rule: every package transaction names the packages it touches, so a
# release never upgrades the whole router.
blanket="$(tracked_files |
grep -E '\.(sh|md|yml|env)$' |
xargs awk '
/(apk|opkg)[ \t]+upgrade/ {
rest = $0
sub(/.*(apk|opkg)[ \t]+upgrade/, "", rest)
gsub(/[ \t]*--[a-zA-Z-]+/, "", rest)
gsub(/[ \t]/, "", rest)
if (rest == "" || rest ~ /^([;&|#]|\|\||&&)/)
printf "%s:%d\n", FILENAME, FNR
}
' 2>/dev/null)"
[ -z "$blanket" ] ||
fail "package upgrade without an explicit package name: $(
printf '%s' "$blanket" | tr '\n' ' ')"
printf 'APK trust configuration OK\n'
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
# Check the shell that actually lands on the router for constructs BusyBox does
# not provide. The test suite runs on GNU coreutils, which masks these: sort -o,
# base64, grep -P and find -printf all exist there and none exist on a stock
# OpenWrt image.
#
# The scan runs over a staged package tree so it can never drift from what is
# installed. When a busybox binary is available it also syntax-checks every
# script with BusyBox ash itself.
set -eu
fail() {
printf 'check-busybox-compat: %s\n' "$*" >&2
exit 1
}
stage="${1:?usage: check-busybox-compat.sh STAGE_DIR}"
[ -d "$stage" ] || fail "stage directory not found: $stage"
# Every regular file in the stage whose first line is a POSIX sh shebang.
shell_files() {
find "$stage" -type f -print | sort | while IFS= read -r file; do
case "$(head -n 1 "$file" 2>/dev/null)" in
'#!/bin/sh' | '#!/bin/sh '* | '#!/bin/ash' | '#!/usr/bin/env sh') ;;
*) continue ;;
esac
printf '%s\n' "$file"
done
}
files="$(shell_files)"
[ -n "$files" ] || fail 'no shell scripts found in the stage'
# Each entry is "regex<TAB>explanation".
findings=''
scan() {
pattern="$1"
message="$2"
hits="$(printf '%s\n' "$files" |
xargs grep -nE "$pattern" 2>/dev/null || true)"
if [ -n "$hits" ]; then
findings="$findings
$message
$(printf '%s' "$hits" | sed "s|^$stage/||")"
fi
}
# Absent from BusyBox, or present with different semantics.
scan '\bsort\b[^|;&]*(-o[[:space:]]|--output)' \
'sort -o is not supported by BusyBox sort; redirect to a temporary file'
scan '\bbase64\b' \
'base64 is not present on a stock OpenWrt image'
scan '\bgrep\b[^|;&]*(-[a-zA-Z]*P|--perl-regexp)' \
'grep -P is not supported by BusyBox grep'
scan '\bfind\b[^|;&]*-printf' \
'find -printf is not supported by BusyBox find'
scan '\bgensub[[:space:]]*\(' \
'gensub() is a GNU awk extension'
scan '\bstat\b[^|;&]*--format' \
'stat --format is GNU-only; BusyBox stat accepts -c'
scan '\breadlink\b[^|;&]*--canonicalize' \
'readlink --canonicalize is GNU-only; BusyBox accepts -f'
# Bash-only syntax that ash rejects outright.
scan '\[\[' \
'[[ ]] is a bash keyword; ash only has [ ]'
scan 'set[[:space:]]+-o[[:space:]]+pipefail' \
'set -o pipefail is not supported by ash'
scan '\$\{[A-Za-z_][A-Za-z0-9_]*//' \
'${var//pattern/replacement} is bash-only; use tr or sed'
scan '\$\{[A-Za-z_][A-Za-z0-9_]*\^\^' \
'${var^^} is bash-only'
scan "\\\$'" \
"\$'...' quoting is bash-only"
scan '\becho[[:space:]]+-e\b' \
'echo -e is not portable; use printf'
scan '=\([[:space:]]*[^)]' \
'arrays are bash-only'
if [ -n "$findings" ]; then
printf 'BusyBox incompatibilities in the packaged shell:%s\n' "$findings" >&2
exit 1
fi
# A real parser is stricter than any pattern list, so use one when present.
if command -v busybox >/dev/null 2>&1; then
# The loop runs in a subshell, so it reports names and leaves failing to the
# caller; parser diagnostics go to stderr on their own.
rejected="$(printf '%s\n' "$files" | while IFS= read -r file; do
busybox ash -n "$file" || printf '%s\n' "${file#"$stage"/}"
done)"
[ -z "$rejected" ] ||
fail "BusyBox ash rejected: $(printf '%s' "$rejected" | tr '\n' ' ')"
printf 'BusyBox compatibility OK (patterns and ash -n)\n'
else
printf 'BusyBox compatibility OK (patterns only; busybox not installed)\n'
fi
+115
View File
@@ -0,0 +1,115 @@
#!/bin/sh
#
# template: check-readme v1 (repo-templates)
# Formatted to pass both `shfmt -i 2` and `shfmt -i 2 -bn -ci`, the styles
# the consuming repositories check with; each gets this file verbatim.
# Do not edit in place: change templates/shared/ in repo-templates
# and run scripts/sync-templates.sh --update.
#
# Hold README.md (English) and README.ru.md (Russian) to the shared layout:
#
# # <Product> for <platform> # <Product> для <platform>
# [Русский](README.ru.md) [English](README.md)
# badges: CI, Release, License
# ## Features ## Возможности
# ## Requirements ## Требования
# ## Installation ## Установка
# ## <project sections> ## <project sections>
# ## Development ## Разработка
# ## Documentation ## Документация
# ## Support ## Поддержка
# ## License ## Лицензия
#
# Both files must have the same number of sections, so one language cannot
# silently drop what the other says. Punctuation is plain ASCII in both: no
# typographic dashes, arrows, quotes or ellipses. The release badge is required
# only where .github/workflows/release.yml exists. Every relative link must
# resolve.
set -eu
root="$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd)"
cd "$root"
status=0
fail() {
printf 'README: %s\n' "$*" >&2
status=1
}
headings() {
sed -n 's/^## //p' "$1"
}
# check_layout <file> <first three sections> <last four sections>, each list
# separated by '|'. Project sections may only sit between the two.
check_layout() {
file="$1"
head_want="$2"
tail_want="$3"
if [ ! -f "$file" ]; then
fail "$file is missing"
return
fi
if ! head -n 1 "$file" | grep -q '^# '; then
fail "$file does not start with a '# ' title"
fi
head_have="$(headings "$file" | head -n 3 | paste -sd '|' -)"
tail_have="$(headings "$file" | tail -n 4 | paste -sd '|' -)"
if [ "$head_have" != "$head_want" ]; then
fail "$file: the first sections are '$head_have', expected '$head_want'"
fi
if [ "$tail_have" != "$tail_want" ]; then
fail "$file: the last sections are '$tail_have', expected '$tail_want'"
fi
}
check_layout README.md 'Features|Requirements|Installation' \
'Development|Documentation|Support|License'
check_layout README.ru.md 'Возможности|Требования|Установка' \
'Разработка|Документация|Поддержка|Лицензия'
if ! grep -qF '[Русский](README.ru.md)' README.md; then
fail 'README.md does not link to README.ru.md'
fi
if ! grep -qF '[English](README.md)' README.ru.md; then
fail 'README.ru.md does not link to README.md'
fi
en="$(headings README.md | wc -l | tr -d ' ')"
ru="$(headings README.ru.md | wc -l | tr -d ' ')"
if [ "$en" != "$ru" ]; then
fail "README.md has $en sections and README.ru.md has $ru"
fi
for file in README.md README.ru.md; do
if ! grep -q 'actions/workflows/.*badge\.svg' "$file"; then
fail "$file has no CI badge"
fi
if ! grep -q 'img\.shields\.io/github/license\|License-\|license-' "$file"; then
fail "$file has no license badge"
fi
if [ -f .github/workflows/release.yml ]; then
if ! grep -q 'img\.shields\.io/github/v/release' "$file"; then
fail "$file has no release badge"
fi
fi
# The curly quotes in the pattern are what it looks for, not a typo.
# shellcheck disable=SC1112
typographic="$(grep -n '—\|–\|→\|←\|«\|»\|“\|”\|‘\|’\|…' "$file" | head -n 3 || true)"
if [ -n "$typographic" ]; then
fail "$file has non-ASCII punctuation: $typographic"
fi
# Relative links: [text](path) and [text](path#anchor), not URLs or anchors.
links="$(grep -o '](\([^)#:]*\)[)#]' "$file" | sed 's/^](//; s/[)#]$//' | sort -u)"
missing=""
for target in $links; do
[ -e "$target" ] || missing="$missing $target"
done
[ -z "$missing" ] || fail "$file links to missing files:$missing"
done
[ "$status" -eq 0 ] && printf 'README layout OK: %s sections each\n' "$en"
exit "$status"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
tag="${1:?usage: check-release-tag.sh TAG}"
expected="v$PKG_VERSION"
[ "$tag" = "$expected" ] || {
printf 'release tag mismatch: expected %s, got %s\n' "$expected" "$tag" >&2
exit 1
}
printf 'release tag OK: %s\n' "$tag"
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
. "$root/release.env"
make_name="$(sed -n 's/^PKG_NAME:=//p' "$root/Makefile" | head -n 1)"
make_version="$(sed -n 's/^PKG_VERSION:=//p' "$root/Makefile" | head -n 1)"
make_arch="$(sed -n 's/^PKGARCH:=//p' "$root/Makefile" | head -n 1)"
[ "$make_name" = "$PKG_NAME" ] || {
printf 'package name mismatch: %s != %s\n' "$make_name" "$PKG_NAME" >&2
exit 1
}
[ "$make_version" = "$PKG_VERSION" ] || {
printf 'package version mismatch: %s != %s\n' "$make_version" "$PKG_VERSION" >&2
exit 1
}
[ "$make_arch" = "$PKG_ARCH" ] || {
printf 'package architecture mismatch: %s != %s\n' "$make_arch" "$PKG_ARCH" >&2
exit 1
}
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
cd "$root"
./scripts/check-version-sync.sh
./scripts/check-readme.sh
./scripts/check-apk-trust.sh
find runtime scripts -type f -name '*.sh' -exec sh -n {} +
find luci -type f -name '*.js' -exec node --check {} +
node ./scripts/test-overview-ui.js
PYTHONPYCACHEPREFIX="$root/build/pycache" \
python3 -m py_compile scripts/pack-ipk.py scripts/po2lmo.py scripts/test-po2lmo.py
PYTHONPYCACHEPREFIX="$root/build/pycache" \
python3 ./scripts/test-po2lmo.py
python3 - <<'PY'
import json
from pathlib import Path
for path in Path("luci").glob("*.json"):
json.loads(path.read_text())
print(f"json OK: {path}")
PY
./scripts/test-runtime.sh
./scripts/build-ipk.sh
# The staged tree is exactly what lands on the router, so check it rather than
# the sources: the build host has GNU coreutils and the router does not.
./scripts/check-busybox-compat.sh "${BUILD_DIR:-$root/build/ipk}/stage"
first="$(sha256sum dist/*.ipk | awk '{print $1}')"
./scripts/build-ipk.sh
second="$(sha256sum dist/*.ipk | awk '{print $1}')"
[ "$first" = "$second" ]
git diff --check 2>/dev/null || true
printf 'ci-check OK\n'
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Build a deterministic opkg package without requiring an OpenWrt SDK."""
import gzip
import io
import os
import sys
import tarfile
EPOCH = 0
def normalize(info):
info.uid = info.gid = 0
info.uname = info.gname = "root"
info.mtime = EPOCH
return info
def archive(members):
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
for archive_name, path in members:
info = normalize(tar.gettarinfo(path, archive_name))
if info.isreg():
with open(path, "rb") as source:
tar.addfile(info, source)
else:
tar.addfile(info)
compressed = io.BytesIO()
with gzip.GzipFile(fileobj=compressed, mode="wb", mtime=EPOCH) as output:
output.write(raw.getvalue())
return compressed.getvalue()
def walk_payload(stage):
result = []
for root, directories, files in os.walk(stage):
directories.sort()
relative = os.path.relpath(root, stage)
if relative == "CONTROL" or relative.startswith("CONTROL" + os.sep):
continue
if relative != ".":
result.append(("./" + relative, root))
for name in sorted(files):
path = os.path.join(root, name)
result.append(("./" + os.path.relpath(path, stage), path))
return sorted(result)
def field(path, name):
with open(path, encoding="utf-8") as source:
for line in source:
if line.startswith(name + ":"):
return line.split(":", 1)[1].strip()
raise SystemExit(f"missing {name} in {path}")
def main():
if len(sys.argv) != 3:
raise SystemExit("usage: pack-ipk.py STAGE OUTPUT_DIR")
stage, output_dir = sys.argv[1:]
control_dir = os.path.join(stage, "CONTROL")
control_path = os.path.join(control_dir, "control")
control_members = [("./", control_dir)]
control_members.extend(
("./" + name, os.path.join(control_dir, name))
for name in sorted(os.listdir(control_dir))
)
parts = {
"debian-binary": b"2.0\n",
"control.tar.gz": archive(control_members),
"data.tar.gz": archive(walk_payload(stage)),
}
package = field(control_path, "Package")
version = field(control_path, "Version")
architecture = field(control_path, "Architecture")
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(
output_dir, f"{package}_{version}_{architecture}.ipk"
)
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode="w", format=tarfile.GNU_FORMAT) as tar:
for name in ("debian-binary", "control.tar.gz", "data.tar.gz"):
payload = parts[name]
info = normalize(tarfile.TarInfo("./" + name))
info.mode = 0o644
info.size = len(payload)
tar.addfile(info, io.BytesIO(payload))
with gzip.GzipFile(output_path, "wb", mtime=EPOCH) as output:
output.write(raw.getvalue())
print(output_path)
if __name__ == "__main__":
main()
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Compile a gettext PO catalog into LuCI's LMO format.
This is a byte-for-byte reimplementation of po2lmo from luci-base. Both package
build paths use it so the SDK-less IPK and the SDK-built APK ship identical
catalogs and neither depends on the LuCI feed host tools being available.
The reference implementation is modules/luci-base/src/po2lmo.c; the reader that
consumes the output is modules/luci-base/src/lib/lmo.c. scripts/test-po2lmo.py
pins the behaviour with known-answer tests and can diff against the real
po2lmo binary when one is available.
"""
import os
import struct
import sys
MASK = 0xFFFFFFFF
def sfh_hash(data):
"""Paul Hsieh's SuperFastHash, seeded with the length as lmo.c does."""
length = len(data)
if length <= 0:
return 0
def u16(offset):
return data[offset] | (data[offset + 1] << 8)
def s8(offset):
value = data[offset]
return value - 256 if value > 127 else value
hash_value = length
rem = length & 3
offset = 0
for _ in range(length >> 2):
hash_value = (hash_value + u16(offset)) & MASK
tmp = ((u16(offset + 2) << 11) ^ hash_value) & MASK
hash_value = ((hash_value << 16) ^ tmp) & MASK
hash_value = (hash_value + (hash_value >> 11)) & MASK
offset += 4
if rem == 3:
hash_value = (hash_value + u16(offset)) & MASK
hash_value = (hash_value ^ (hash_value << 16)) & MASK
hash_value = (hash_value ^ ((s8(offset + 2) << 18) & MASK)) & MASK
hash_value = (hash_value + (hash_value >> 11)) & MASK
elif rem == 2:
hash_value = (hash_value + u16(offset)) & MASK
hash_value = (hash_value ^ (hash_value << 11)) & MASK
hash_value = (hash_value + (hash_value >> 17)) & MASK
elif rem == 1:
hash_value = (hash_value + s8(offset)) & MASK
hash_value = (hash_value ^ (hash_value << 10)) & MASK
hash_value = (hash_value + (hash_value >> 1)) & MASK
hash_value = (hash_value ^ (hash_value << 3)) & MASK
hash_value = (hash_value + (hash_value >> 5)) & MASK
hash_value = (hash_value ^ (hash_value << 4)) & MASK
hash_value = (hash_value + (hash_value >> 17)) & MASK
hash_value = (hash_value ^ (hash_value << 25)) & MASK
hash_value = (hash_value + (hash_value >> 6)) & MASK
return hash_value
def extract_string(line):
"""Return the quoted payload of a PO line, or None when there is none.
Like the C original this only unescapes \\" and \\\\. Every other escape,
\\n included, is kept verbatim as backslash plus character, which is what
makes the PO header parse below work on literal "\\n" separators.
"""
if line.startswith("#"):
return None
out = []
escaped = False
started = False
for char in line:
if not started:
if char == '"':
started = True
continue
if escaped:
if char not in ('"', "\\"):
out.append("\\")
out.append(char)
escaped = False
elif char == "\\":
escaped = True
elif char != '"':
out.append(char)
else:
break
return "".join(out) if started else None
class Message:
"""One PO record, mirroring struct msg in po2lmo.c."""
def __init__(self):
self.reset()
def reset(self):
self.plural_num = -1
self.ctxt = None
self.msgid = None
self.id_plural = None
self.values = [None] * 10
self.current = None
def append(self, text):
if self.current is None:
return
field, index = self.current
if field == "values":
self.values[index] = (self.values[index] or "") + text
else:
setattr(self, field, (getattr(self, field) or "") + text)
class Catalog:
"""Accumulates the string blob and the index of an LMO archive."""
def __init__(self):
self.blob = bytearray()
self.entries = []
def _write(self, payload):
offset = len(self.blob)
self.blob += payload
# Every blob is padded to a 4-byte boundary, as print() does in C.
self.blob += b"\0" * ((4 - (len(payload) % 4)) % 4)
return offset
def add(self, key, value, plural_count):
key_id = sfh_hash(key)
# Identical hashes mean the string is its own translation; the C tool
# drops those entries so the reader falls back to the source string.
if key_id == sfh_hash(value):
return
offset = self._write(value)
self.entries.append((key_id, plural_count, offset, len(value)))
def add_plural_forms(self, formula):
offset = self._write(formula)
self.entries.append((0, 0, offset, len(formula)))
def serialize(self):
if not self.blob:
return None
out = bytearray(self.blob)
# Stable sort by key_id keeps the output deterministic where the C
# qsort would leave the order of equal keys unspecified.
for entry in sorted(self.entries, key=lambda item: item[0]):
out += struct.pack(">IIII", *entry)
out += struct.pack(">I", len(self.blob))
return bytes(out)
def add_message(catalog, msg):
"""Emit one PO record, mirroring print_msg() in po2lmo.c."""
if msg.msgid is not None and msg.values[0] is not None:
for index in range(msg.plural_num + 1):
value = msg.values[index]
if value is None:
continue
if msg.ctxt is not None and msg.id_plural is not None:
key = f"{msg.ctxt}\1{msg.msgid}\2{index}"
elif msg.ctxt is not None:
key = f"{msg.ctxt}\1{msg.msgid}"
elif msg.id_plural is not None:
key = f"{msg.msgid}\2{index}"
else:
key = msg.msgid
catalog.add(
key.encode(), value.encode(), msg.plural_num + 1
)
elif msg.values[0] is not None:
# The header record carries the plural formula. Fields are separated by
# literal "\n" two-character sequences left in place by extract_string,
# and a formula on an unterminated last line is ignored upstream too.
header = msg.values[0]
for field in header.split("\\n")[:-1]:
if field[:14].lower() == "plural-forms: ":
catalog.add_plural_forms(field[14:].encode())
break
msg.reset()
def compile_po(text):
catalog = Catalog()
msg = Message()
for line in text.splitlines() + [None]:
eof = line is None
if not eof and line.startswith('msgctxt "'):
if msg.msgid is not None or msg.values[0] is not None:
add_message(catalog, msg)
msg.ctxt = None
msg.current = ("ctxt", 0)
elif eof or line.startswith('msgid "'):
if msg.msgid is not None or msg.values[0] is not None:
add_message(catalog, msg)
msg.msgid = None
msg.current = ("msgid", 0)
elif line.startswith('msgid_plural "'):
msg.id_plural = None
msg.current = ("id_plural", 0)
elif line.startswith('msgstr "') or line.startswith("msgstr["):
if line.startswith("msgstr["):
msg.plural_num = int(line[7:].split("]")[0])
else:
msg.plural_num = 0
if msg.plural_num >= 10:
raise SystemExit("po2lmo: too many plural forms")
msg.values[msg.plural_num] = None
msg.current = ("values", msg.plural_num)
if eof:
break
text_part = extract_string(line)
if text_part:
msg.append(text_part)
return catalog.serialize()
def main():
if len(sys.argv) != 3:
raise SystemExit("usage: po2lmo.py input.po output.lmo")
source, target = sys.argv[1:]
with open(source, encoding="utf-8") as handle:
payload = compile_po(handle.read())
# An empty catalog produces no file at all, matching the C tool.
if payload is None:
if os.path.exists(target):
os.unlink(target)
return
with open(target, "wb") as handle:
handle.write(payload)
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
#!/bin/sh
#
# template: release-notes v3 (repo-templates)
# Formatted to pass both `shfmt -i 2` and `shfmt -i 2 -bn -ci`, the styles
# the consuming repositories check with; each gets this file verbatim.
# Do not edit in place: change templates/shared/ in repo-templates
# and run scripts/sync-templates.sh --update.
#
# Print release notes for a tag, built from the subjects of the commits since
# the previous release tag. There is no changelog file: the commit subject is
# the release note, so write it as the line a user should read.
#
# A commit whose subject is only a version number ("Release 1.2.0",
# "release: v0.2.0") says nothing a user can read and is left out.
#
# A commit that touches only documentation, CI, tests or checks is listed
# under "Documentation and tooling" rather than "Changes".
#
# Usage: scripts/release-notes.sh <tag> [previous-tag]
#
# The previous tag defaults to the nearest v* tag before <tag>. For a final
# release, pre-release tags (_rc, _beta, _alpha) are skipped, so its notes
# cover everything since the last final release rather than since its RC. The repository
# for the compare link comes from GITHUB_REPOSITORY, or from the origin remote.
# Needs the full history: check out with fetch-depth 0.
set -eu
[ $# -ge 1 ] || {
printf 'usage: %s <tag> [previous-tag]\n' "$0" >&2
exit 2
}
tag="$1"
if [ $# -ge 2 ]; then
previous="$2"
elif printf '%s\n' "$tag" | grep -Eq '_(rc|beta|alpha)'; then
previous="$(git describe --tags --abbrev=0 --match 'v*' "$tag^" 2>/dev/null || true)"
else
previous="$(git describe --tags --abbrev=0 --match 'v*' --exclude '*_rc*' \
--exclude '*_beta*' --exclude '*_alpha*' "$tag^" 2>/dev/null || true)"
fi
repository="${GITHUB_REPOSITORY:-}"
if [ -z "$repository" ]; then
url="$(git remote get-url origin 2>/dev/null || true)"
url="${url%.git}"
repository="$(printf '%s\n' "$url" | sed -n 's#.*github\.com[:/]\(.*\)$#\1#p')"
fi
if [ -n "$previous" ]; then
range="$previous..$tag"
else
range="$tag"
fi
# Subjects that carry nothing but a version number.
bump='^(release|bump|version)[: ]+(to )?v?[0-9]+(\.[0-9]+)*([-_][a-z0-9]+)?$'
# Paths that never change what a user runs.
tooling='^(docs/|\.github/|tests?/|scripts/(check|test|gen|ci)-|scripts/release-notes\.sh$|LICENSE|NOTICE|THIRD_PARTY|.*\.md$|\.[^/]+$)'
changes=""
tooling_changes=""
for commit in $(git rev-list --no-merges --reverse "$range"); do
subject="$(git log -1 --format=%s "$commit")"
if printf '%s\n' "$subject" | grep -Eiq "$bump"; then
continue
fi
paths="$(git diff-tree --no-commit-id --name-only -r --root "$commit")"
if printf '%s\n' "$paths" | grep -Evq "$tooling"; then
changes="$changes- $subject
"
else
tooling_changes="$tooling_changes- $subject
"
fi
done
if [ -n "$changes" ]; then
printf '## Changes\n\n%s' "$changes"
fi
if [ -n "$tooling_changes" ]; then
[ -z "$changes" ] || printf '\n'
printf '## Documentation and tooling\n\n%s' "$tooling_changes"
fi
if [ -z "$changes$tooling_changes" ]; then
printf 'Version update only; no other changes since %s.\n' "$previous"
fi
if [ -n "$repository" ]; then
if [ -n "$previous" ]; then
printf '\n**Full diff**: https://github.com/%s/compare/%s...%s\n' \
"$repository" "$previous" "$tag"
else
printf '\n**Full history**: https://github.com/%s/commits/%s\n' \
"$repository" "$tag"
fi
fi
+96
View File
@@ -0,0 +1,96 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
stage="${1:?usage: stage-package.sh STAGE_DIR}"
. "$root/release.env"
rm -rf "$stage"
mkdir -p \
"$stage/CONTROL" \
"$stage/etc/config" \
"$stage/etc/uci-defaults" \
"$stage/usr/libexec" \
"$stage/usr/share/luci/menu.d" \
"$stage/usr/share/rpcd/acl.d" \
"$stage/usr/share/licenses/$PKG_NAME" \
"$stage/usr/lib/lua/luci/i18n" \
"$stage/www/luci-static/resources/overview-manager" \
"$stage/www/luci-static/resources/view/overview-manager" \
"$stage/www/luci-static/resources/view/status/include"
install -m 600 "$root/openwrt/files/etc/config/overview-manager" \
"$stage/etc/config/overview-manager"
install -m 755 "$root/runtime/overview-manager.sh" \
"$stage/usr/libexec/overview-manager"
install -m 755 "$root/openwrt/files/etc/uci-defaults/luci-app-overview-manager" \
"$stage/etc/uci-defaults/luci-app-overview-manager"
install -m 644 "$root/luci/menu.json" \
"$stage/usr/share/luci/menu.d/$PKG_NAME.json"
install -m 644 "$root/luci/acl.json" \
"$stage/usr/share/rpcd/acl.d/$PKG_NAME.json"
install -m 644 "$root/luci/shared.js" \
"$stage/www/luci-static/resources/overview-manager/shared.js"
install -m 644 "$root/luci/settings.js" \
"$stage/www/luci-static/resources/view/overview-manager/settings.js"
install -m 644 "$root/luci/overview.js" \
"$stage/www/luci-static/resources/view/status/include/00_overview-manager.js"
install -m 644 "$root/LICENSE" \
"$stage/usr/share/licenses/$PKG_NAME/LICENSE"
# The APK path compiles the same catalogs from the Makefile, so both packages
# ship identical translations without needing the LuCI feed host tools.
for po in "$root"/po/*/*.po; do
[ -f "$po" ] || continue
language="$(basename "$(dirname "$po")")"
[ "$language" != templates ] || continue
catalog="$stage/usr/lib/lua/luci/i18n/$(basename "$po" .po).$language.lmo"
python3 "$root/scripts/po2lmo.py" "$po" "$catalog"
# po2lmo.py honours the caller's umask; fix the mode so the packaged file
# stays byte-identical across build hosts.
chmod 644 "$catalog"
done
cat >"$stage/CONTROL/control" <<EOF
Package: $PKG_NAME
Version: $PKG_VERSION
Architecture: $PKG_ARCH
Maintainer: nikitid
Depends: luci-base, rpcd-mod-file
Section: luci
Priority: optional
Description: Configure the order and visibility of Status Overview widgets.
EOF
cat >"$stage/CONTROL/conffiles" <<'EOF'
/etc/config/overview-manager
EOF
cat >"$stage/CONTROL/postinst" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT:-}" ] && exit 0
# This package defines its own postinst, so the default handler that drains
# /etc/uci-defaults does not run. Apply the language registration here so a
# runtime install takes effect without waiting for the next boot.
if [ -x /etc/uci-defaults/luci-app-overview-manager ]; then
/etc/uci-defaults/luci-app-overview-manager &&
rm -f /etc/uci-defaults/luci-app-overview-manager
fi
rm -f /tmp/luci-indexcache
rm -rf /tmp/luci-modulecache
echo "Overview Manager installed. Open LuCI -> Status -> Overview Manager."
exit 0
EOF
cat >"$stage/CONTROL/prerm" <<'EOF'
#!/bin/sh
[ -n "${IPKG_INSTROOT:-}" ] && exit 0
case "${1:-}" in upgrade) exit 0 ;; esac
[ "${PKG_UPGRADE:-0}" = 1 ] && exit 0
rm -f /tmp/luci-indexcache
rm -rf /tmp/luci-modulecache
exit 0
EOF
chmod 755 "$stage/CONTROL/postinst" "$stage/CONTROL/prerm"
@@ -0,0 +1,207 @@
'use strict';
const fs = require('fs');
const vm = require('vm');
let classMutations = 0;
function classList() {
const values = new Set();
return {
add: value => { classMutations++; values.add(value); },
remove: value => { classMutations++; values.delete(value); },
contains: value => values.has(value),
/* The real DOM only mutates when the state actually changes, which is
* what keeps the toggle in overview.js from repainting on every poll. */
toggle: (value, force) => {
const wanted = force === undefined ? !values.has(value) : !!force;
if (wanted === values.has(value))
return wanted;
classMutations++;
if (wanted)
values.add(value);
else
values.delete(value);
return wanted;
}
};
}
function section() {
const attributes = {};
return {
classList: classList(),
parentNode: null,
getAttribute: name => attributes[name] || null,
setAttribute: (name, value) => { attributes[name] = value; }
};
}
function load(path, context) {
const source = fs.readFileSync(path, 'utf8');
return vm.runInNewContext(`(function() {${source}\n})()`, context);
}
const common = load('luci/shared.js', {
baseclass: { extend: value => value },
E: (tag, attrs, children) => ({ tag, attrs, children })
});
/* luci-mod-status requires its includes as module names, so the .js suffix is
* not part of the sort. Sorting raw file names would invert this pair and
* shift every widget onto the wrong section. */
const sorted = [ '20_x-y.js', '20_x.js' ].sort(common.compareWidgets);
if (JSON.stringify(sorted) !== JSON.stringify([ '20_x.js', '20_x-y.js' ]))
throw new Error(`unexpected widget sort order: ${sorted.join(',')}`);
const names = [
'00_overview-manager.js',
'10_system.js',
'20_memory.js',
'70_custom.js'
];
const sections = names.map(() => section());
sections.forEach(node => node.classList.add('cbi-section'));
let appendCalls = 0;
const parent = {
children: sections,
appendChild(node) {
appendCalls++;
const index = this.children.indexOf(node);
if (index >= 0)
this.children.splice(index, 1);
this.children.push(node);
node.parentNode = this;
}
};
sections.forEach(node => { node.parentNode = parent; });
let execCalls = [];
const context = {
baseclass: { extend: value => value },
common: common,
fs: {
exec: (path, args) => {
execCalls.push(args.join(' '));
return Promise.resolve({ stdout: stdoutFor(args[0]) });
}
},
L: { resolveDefault: value => value },
Promise,
window: {
requestAnimationFrame: callback => callback(),
setTimeout: callback => callback()
},
E(tag, attrs) {
if (attrs && attrs['data-overview-manager-marker']) {
return { closest: () => sections[0] };
}
return { tag, attrs };
}
};
let helperAvailable = true;
function stdoutFor(command) {
if (!helperAvailable)
return '';
if (command === 'files')
return `${names.join('\n')}\n`;
return 'order=70_custom.js 10_system.js 20_memory.js\nhidden=20_memory.js\n';
}
const widget = load('luci/overview.js', context);
const data = [
{ stdout: stdoutFor('files') },
{ stdout: stdoutFor('layout') }
];
widget.render(data);
function currentOrder() {
return parent.children.map(node => node.getAttribute('data-overview-widget'));
}
const expected = [
'00_overview-manager.js',
'70_custom.js',
'10_system.js',
'20_memory.js'
];
if (JSON.stringify(currentOrder()) !== JSON.stringify(expected))
throw new Error(`unexpected initial order: ${currentOrder().join(',')}`);
const hidden = parent.children.filter(node =>
node.classList.contains('overview-manager-hidden')
).map(node => node.getAttribute('data-overview-widget'));
if (JSON.stringify(hidden) !== JSON.stringify([
'00_overview-manager.js',
'20_memory.js'
]))
throw new Error(`unexpected hidden widgets: ${hidden.join(',')}`);
/* Regression test for the white flicker: a poll cycle that changes nothing
* must not move any section or touch any class. */
appendCalls = 0;
classMutations = 0;
widget.render(data);
if (JSON.stringify(currentOrder()) !== JSON.stringify(expected))
throw new Error(`polling changed the order: ${currentOrder().join(',')}`);
if (appendCalls !== 0)
throw new Error(`polling re-appended ${appendCalls} sections`);
if (classMutations !== 0)
throw new Error(`polling caused ${classMutations} class mutations`);
/* A changed layout must still be applied. */
widget.render([
{ stdout: `${names.join('\n')}\n` },
{ stdout: 'order=10_system.js 20_memory.js 70_custom.js\nhidden=\n' }
]);
const reordered = [
'00_overview-manager.js',
'10_system.js',
'20_memory.js',
'70_custom.js'
];
if (JSON.stringify(currentOrder()) !== JSON.stringify(reordered))
throw new Error(`layout change was not applied: ${currentOrder().join(',')}`);
if (parent.children.filter(node =>
node.classList.contains('overview-manager-hidden')
).length !== 1)
throw new Error('unhiding a widget did not take effect');
/* The layout is read once per page load instead of spawning the helper twice
* on every five-second poll cycle. */
function testLoadCaching() {
helperAvailable = false;
execCalls = [];
return widget.load().then(() => {
if (execCalls.length !== 2)
throw new Error(`first load ran ${execCalls.length} helper calls`);
return widget.load();
}).then(() => {
/* An unreachable helper must be retried, not cached as an empty list. */
if (execCalls.length !== 4)
throw new Error('a failed load was cached');
helperAvailable = true;
execCalls = [];
return widget.load();
}).then(() => {
if (execCalls.length !== 2)
throw new Error(`recovery load ran ${execCalls.length} helper calls`);
return widget.load();
}).then(() => {
if (execCalls.length !== 2)
throw new Error(`polling re-ran the helper: ${execCalls.join(', ')}`);
});
}
testLoadCaching().then(() => {
console.log('overview UI tests OK');
}).catch(error => {
console.error(error.message);
process.exit(1);
});
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Tests for the LMO compiler and the translation catalogs.
The hash vectors are pinned against the upstream po2lmo binary built from
luci-base. Set PO2LMO_REFERENCE to a real po2lmo executable to additionally
diff the compiled output byte for byte.
"""
import os
import re
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from po2lmo import compile_po, sfh_hash # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
LANGUAGES = ["ru"]
# Verified against sfh_hash() from luci-base/src/lib/lmo.c. The browser side
# computes the same values in sfh() from luci-base/htdocs/.../cbi.js.
HASH_VECTORS = {
"": 0x00000000,
"a": 0x115EA782,
"ab": 0x516B8B44,
"abc": 0xD2BE198A,
"abcd": 0xDAD8B8DB,
"Show": 0x49D1A0A4,
"Save layout": 0x18762B08,
"Saving…": 0x7A938C83,
"Переместить вверх": 0x669DABB0,
"ctx\1key": 0x999A0ABE,
}
def fail(message):
print(f"test-po2lmo: {message}", file=sys.stderr)
sys.exit(1)
def check(condition, message):
if not condition:
fail(message)
def read_lmo(payload):
"""Decode an LMO archive the way lmo.c reads it: {key_id: value}."""
(blob_size,) = struct.unpack(">I", payload[-4:])
index = payload[blob_size:-4]
check(len(index) % 16 == 0, "index size is not a multiple of the entry size")
catalog = {}
previous = None
for offset in range(0, len(index), 16):
key_id, val_id, value_offset, length = struct.unpack(
">IIII", index[offset:offset + 16]
)
check(
previous is None or key_id >= previous,
"index is not sorted by key_id",
)
previous = key_id
check(
value_offset + length <= blob_size,
f"entry {key_id:08x} points outside the string blob",
)
catalog[key_id] = payload[value_offset:value_offset + length].decode()
return catalog
def trimws(value):
"""The normalisation _() applies before hashing, from cbi.js."""
return re.sub(r"[ \t\n]+", " ", value.strip())
def parse_po(path):
"""Return {msgid: msgstr} for the simple single-form records we ship."""
entries = {}
key = None
field = None
values = {"msgid": "", "msgstr": ""}
def flush():
if key:
entries[key] = values["msgstr"]
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith("#") or not line:
continue
match = re.match(r'^(msgid|msgstr)\s+"(.*)"$', line)
if match:
if match.group(1) == "msgid":
flush()
values = {"msgid": match.group(2), "msgstr": ""}
field = "msgid"
else:
field = "msgstr"
values["msgstr"] = match.group(2)
key = values["msgid"] or None
continue
match = re.match(r'^"(.*)"$', line)
if match and field:
values[field] += match.group(1)
if field == "msgid":
key = values["msgid"] or None
flush()
return entries
def source_strings():
"""Every literal passed to _() in the shipped JavaScript."""
found = set()
for path in sorted((ROOT / "luci").glob("*.js")):
text = path.read_text(encoding="utf-8")
for match in re.finditer(r"\b_\(\s*'((?:[^'\\]|\\.)*)'", text):
found.add(match.group(1).replace("\\'", "'").replace("\\\\", "\\"))
return found
def test_hash_vectors():
for value, expected in HASH_VECTORS.items():
actual = sfh_hash(value.encode())
check(
actual == expected,
f"sfh_hash({value!r}) = {actual:08x}, expected {expected:08x}",
)
def test_empty_catalog():
header = 'msgid ""\nmsgstr "Content-Type: text/plain; charset=UTF-8\\n"\n'
check(
compile_po(header) is None,
"a catalog without translations must not produce a file",
)
def test_identical_translation_omitted():
payload = compile_po('msgid "same"\nmsgstr "same"\n')
check(
payload is None,
"a translation identical to its source must be omitted",
)
def test_plural_forms_recorded():
payload = compile_po(
'msgid ""\nmsgstr ""\n'
'"Plural-Forms: nplurals=2; plural=(n != 1);\\n"\n'
)
check(payload is not None, "the plural formula must be compiled")
catalog = read_lmo(payload)
check(
catalog.get(0) == "nplurals=2; plural=(n != 1);",
f"unexpected plural formula entry: {catalog.get(0)!r}",
)
def test_catalogs_round_trip():
"""Every translation must be reachable through the hash _() computes."""
for language in LANGUAGES:
path = ROOT / "po" / language / "overview-manager.po"
entries = parse_po(path)
check(bool(entries), f"{path} has no translations")
payload = compile_po(path.read_text(encoding="utf-8"))
check(payload is not None, f"{path} compiled to an empty catalog")
catalog = read_lmo(payload)
for msgid, msgstr in entries.items():
check(
msgid == trimws(msgid),
f"{path}: msgid is not whitespace-normalised: {msgid!r}",
)
if msgid == msgstr:
continue
key = sfh_hash(trimws(msgid).encode())
check(
catalog.get(key) == msgstr,
f"{path}: {msgid!r} resolves to {catalog.get(key)!r}, "
f"expected {msgstr!r}",
)
def test_catalogs_cover_sources():
used = source_strings()
check(bool(used), "no _() strings were found in the sources")
template = parse_po(ROOT / "po" / "templates" / "overview-manager.pot")
check(
set(template) == used,
"po/templates/overview-manager.pot is out of sync with the sources; "
f"only in template: {sorted(set(template) - used)}; "
f"only in sources: {sorted(used - set(template))}",
)
check(
not any(template.values()),
"the template must not carry translations",
)
for language in LANGUAGES:
path = ROOT / "po" / language / "overview-manager.po"
entries = parse_po(path)
check(
set(entries) == used,
f"{path} is out of sync with the sources; "
f"only in catalog: {sorted(set(entries) - used)}; "
f"only in sources: {sorted(used - set(entries))}",
)
for msgid, msgstr in entries.items():
check(msgstr != "", f"{path}: {msgid!r} is untranslated")
def test_languages_registered():
"""Every shipped catalog needs a display name in the uci-defaults script.
LuCI only offers languages listed in luci.languages, so a catalog with no
entry there can never be selected.
"""
script = (
ROOT / "openwrt" / "files" / "etc" / "uci-defaults"
/ "luci-app-overview-manager"
).read_text(encoding="utf-8")
for language in LANGUAGES:
check(
re.search(rf"^\s*{re.escape(language)}\)\s", script, re.MULTILINE),
f"{language} has no display name in the uci-defaults script",
)
def test_reference_binary():
reference = os.environ.get("PO2LMO_REFERENCE")
if not reference:
print("test-po2lmo: PO2LMO_REFERENCE not set, skipping oracle diff")
return
for language in LANGUAGES:
source = ROOT / "po" / language / "overview-manager.po"
with tempfile.TemporaryDirectory() as directory:
expected = Path(directory) / "reference.lmo"
subprocess.run(
[reference, str(source), str(expected)], check=True
)
check(
expected.read_bytes()
== compile_po(source.read_text(encoding="utf-8")),
f"{source}: output differs from {reference}",
)
print("test-po2lmo: output matches the reference po2lmo binary")
def main():
test_hash_vectors()
test_empty_catalog()
test_identical_translation_omitted()
test_plural_forms_recorded()
test_catalogs_round_trip()
test_catalogs_cover_sources()
test_languages_registered()
test_reference_binary()
print("po2lmo tests OK")
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
#!/bin/sh
set -eu
root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
mkdir -p "$tmp/includes"
touch \
"$tmp/includes/00_overview-manager.js" \
"$tmp/includes/10_system.js" \
"$tmp/includes/20_memory.js" \
"$tmp/includes/70_custom.js"
cat >"$tmp/uci" <<'EOF'
#!/bin/sh
while [ "${1:-}" = -q ]; do shift; done
case "${1:-}:${2:-}" in
get:overview-manager.main.order)
printf '%s\n' "${FAKE_ORDER:-}"
;;
get:overview-manager.main.hidden)
printf '%s\n' "${FAKE_HIDDEN:-}"
;;
set:* | commit:*)
printf '%s\n' "$*" >>"${FAKE_UCI_LOG:?}"
;;
*)
exit 1
;;
esac
EOF
chmod 755 "$tmp/uci"
run() {
OVERVIEW_MANAGER_INCLUDE_DIR="$tmp/includes" \
OVERVIEW_MANAGER_UCI="$tmp/uci" \
FAKE_UCI_LOG="$tmp/uci.log" \
FAKE_ORDER="${FAKE_ORDER:-}" \
FAKE_HIDDEN="${FAKE_HIDDEN:-}" \
"$root/runtime/overview-manager.sh" "$@"
}
expected_files='00_overview-manager.js
10_system.js
20_memory.js
70_custom.js'
[ "$(run files)" = "$expected_files" ]
FAKE_ORDER='70_custom.js 10_system.js' \
FAKE_HIDDEN='20_memory.js' \
run layout >"$tmp/layout"
grep -qx 'order=70_custom.js 10_system.js' "$tmp/layout"
grep -qx 'hidden=20_memory.js' "$tmp/layout"
: >"$tmp/uci.log"
run save \
'70_custom.js,10_system.js,20_memory.js,70_custom.js' \
'20_memory.js'
grep -qx 'set overview-manager.main.order=70_custom.js 10_system.js 20_memory.js' \
"$tmp/uci.log"
grep -qx 'set overview-manager.main.hidden=20_memory.js' "$tmp/uci.log"
grep -qx 'commit overview-manager' "$tmp/uci.log"
if run save '00_overview-manager.js' '' >/dev/null 2>&1; then
printf 'self widget was accepted unexpectedly\n' >&2
exit 1
fi
if run save '99_missing.js' '' >/dev/null 2>&1; then
printf 'missing widget was accepted unexpectedly\n' >&2
exit 1
fi
printf 'runtime tests OK\n'