#!/usr/bin/env bash set -eo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" UI_UC="$ROOT_DIR/tachyon/files/usr/lib/service/ui.uc" TACHYON_FILES="$ROOT_DIR/tachyon/files" TACHYON_BIN="$TACHYON_FILES/usr/bin/tachyon" TACHYON_INIT="$TACHYON_FILES/etc/init.d/tachyon" UI_RUNTIME_SH="$TACHYON_FILES/usr/lib/ui_runtime.sh" WORK_DIR="$(mktemp -d)" cleanup() { rm -rf "$WORK_DIR" } trap cleanup EXIT fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } ui_ucode() { ucode -L "$TACHYON_FILES/usr/lib" "$UI_UC" "$@" } assert_eq() { local expected="$1" local actual="$2" local label="$3" [ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'" } write_state() { local name="$1" local content="$2" printf '%s\n' "$content" >"$WORK_DIR/$name.json" printf '%s\n' "$WORK_DIR/$name.json" } [ ! -e "$UI_RUNTIME_SH" ] || fail "ui_runtime.sh shell owner must be removed" if grep -R -n "ui_runtime.sh" "$TACHYON_FILES" >/dev/null 2>&1; then fail "runtime files must not reference ui_runtime.sh" fi ui_runtime_shell_symbols='ui_runtime_|tachyon_fast_get_ui_state|TACHYON_UI_RUNTIME|load_ui_runtime' if grep -R -n -E "$ui_runtime_shell_symbols" "$TACHYON_BIN" "$TACHYON_INIT" "$TACHYON_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "ui_runtime shell symbols must not remain in runtime shell" fi if grep -R -n '^get_ui_capabilities()' "$TACHYON_FILES/usr/lib" --include='*.sh' >/dev/null 2>&1; then fail "UI capabilities must be owned by service/ui.uc, not shell" fi grep -Fq 'require("core.uci")' "$UI_UC" || fail "service/ui.uc must use core.uci for UI UCI-derived state" if grep -n -E 'require\("uci"\)\.cursor|uci -q|uci", "-q"' "$UI_UC" >/dev/null 2>&1; then fail "service/ui.uc must not own direct UCI cursor or CLI reads" fi grep -Fq '"tachyon-stably-running"' "$UI_UC" || fail "UI Tachyon status must use stable runtime state to avoid crash-loop flicker" grep -Fq '"sing-box-service-stable"' "$UI_UC" || fail "UI sing-box status must use stable runtime state to avoid crash-loop flicker" if sed -n '/^function ensure_dir(/,/^}/p' "$UI_UC" | grep -Fq 'mkdir", "-p'; then fail "UI state refresh must not spawn mkdir for existing directories" fi if sed -n '/^function cleanup_dir(/,/^}/p' "$UI_UC" | grep -Fq '"find"'; then fail "UI state refresh must clean action files without spawning find" fi grep -Fq 'run_pending_reload_after_service_action(action, success)' "$UI_UC" || fail "UI service actions must run pending reload after the current action finishes" grep -Fq 'service_action_wait_for_expected_state(action, SERVICE_ACTION_TIMEOUT_SECONDS, SERVICE_ACTION_SETTLE_SECONDS)' "$UI_UC" || fail "UI service action worker must keep action state until the expected service state is stable" grep -Fq 'let args = [ SERVICE_INIT, action ];' "$UI_UC" || fail "UI service action worker must use init.d so procd triggers stay registered" grep -Fq 'start_service_action("reload", "initd", "pending")' "$UI_UC" || fail "pending reload must enter the normal reload service action immediately" if grep -n -E 'pgrep.*sing-box|service-list-instance-running' "$UI_UC" >/dev/null 2>&1; then fail "service/ui.uc must not use transient sing-box process probes for visible status" fi for mode in \ get-ui-capabilities \ get-ui-state \ component-action-running-for \ service-action-begin-if-idle \ service-action-update-pid \ service-action-finish-after-command \ latency-progress-state \ service-action-async \ service-action-status \ latency-test-async \ latency-test-status \ action-ack; do grep -Fq "\"$mode\"" "$UI_UC" || fail "service/ui.uc must expose $mode" done assert_eq "/tmp/ui/job-1.json" \ "$(ui_ucode job-state-path /tmp/ui job-1)" \ "valid UI job path" if ui_ucode job-state-path /tmp/ui '../bad' >/dev/null 2>&1; then fail "invalid UI job id should be rejected" fi ui_ucode service-action-valid restart >/dev/null || fail "restart service action should be valid" if ui_ucode service-action-valid invalid >/dev/null 2>&1; then fail "invalid service action should be rejected" fi assert_eq "1" \ "$(ui_ucode service-action-expected-running start)" \ "start expects running service" assert_eq "1" \ "$(ui_ucode service-action-expected-running reload)" \ "reload expects running service" assert_eq "0" \ "$(ui_ucode service-action-expected-running stop)" \ "stop expects stopped service" if ui_ucode service-action-expected-running invalid >/dev/null 2>&1; then fail "invalid service action expected state should be rejected" fi ui_ucode latency-type-valid proxy_list >/dev/null || fail "proxy_list latency type should be valid" if ui_ucode latency-type-valid invalid >/dev/null 2>&1; then fail "invalid latency type should be rejected" fi latency_action_dir="$WORK_DIR/latency-actions" mkdir -p "$latency_action_dir" latency_state="$latency_action_dir/latency-1.json" printf '%s\n' '{"success":true,"running":true,"kind":"latency","latency_type":"proxy_list","section":"main","tag":"[]","started_at":100}' >"$latency_state" TACHYON_UI_LATENCY_ACTION_DIR="$latency_action_dir" \ ui_ucode latency-progress-state "$latency_state" 2 5 1 >/dev/null || fail "latency-progress-state should update running latency jobs" JOB_STATE="$latency_state" node - <<'NODE' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8")); if (!value.progress || value.progress.completed !== 2 || value.progress.total !== 5 || value.progress.failed !== 1) { console.error("latency progress state mismatch"); process.exit(1); } NODE assert_eq "running & enabled" \ "$(ui_ucode service-status-text 1 1)" \ "running enabled status" assert_eq "running but disabled" \ "$(ui_ucode service-status-text 1 0)" \ "running disabled status" assert_eq "stopped but enabled" \ "$(ui_ucode service-status-text 0 1)" \ "stopped enabled status" assert_eq "stopped & disabled" \ "$(ui_ucode service-status-text 0 0)" \ "stopped disabled status" running="$(write_state running '{"running":true,"pid":"456","started_at":100}')" assert_eq "$(printf 'pid\t456\t0')" \ "$(ui_ucode job-refresh-plan "$running" 105 15)" \ "running UI job within grace" assert_eq "$(printf 'pid\t456\t1')" \ "$(ui_ucode job-refresh-plan "$running" 120 15)" \ "running UI job after grace" invalid_pid="$(write_state invalid-pid '{"running":true,"pid":"","started_at":100}')" assert_eq "skip" \ "$(ui_ucode job-refresh-plan "$invalid_pid" 105 15)" \ "invalid UI pid within grace" assert_eq "stale" \ "$(ui_ucode job-refresh-plan "$invalid_pid" 120 15)" \ "invalid UI pid after grace" finished="$(write_state finished '{"running":false,"pid":"456","started_at":100}')" assert_eq "skip" \ "$(ui_ucode job-refresh-plan "$finished" 120 15)" \ "finished UI job refresh" stale_json="$(ui_ucode stale-action-state "$invalid_pid" "worker exited" 200)" JSON_VALUE="$stale_json" node - <<'NODE' const value = JSON.parse(process.env.JSON_VALUE); if (value.running !== false || value.success !== false || value.message !== "worker exited") { console.error("UI stale state shape mismatch"); process.exit(1); } NODE acked="$(write_state acked '{"running":false,"acked_at":100}')" ui_ucode action-ack-expired "$acked" 200 90 >/dev/null || fail "acked state should be expired" if ui_ucode action-ack-expired "$acked" 150 90 >/dev/null 2>&1; then fail "acked state should not expire before TTL" fi bad_ack="$(write_state bad-ack '{"running":false,"acked_at":"invalid"}')" if ui_ucode action-ack-expired "$bad_ack" 200 90 >/dev/null 2>&1; then fail "invalid ack timestamp should not expire" fi cleanup_dir="$WORK_DIR/cleanup-actions" mkdir -p "$cleanup_dir" printf '%s\n' '{"running":false,"acked_at":1}' >"$cleanup_dir/done.json" printf 'stdout\n' >"$cleanup_dir/done.out" printf '{}\n' >"$cleanup_dir/done.out.json" ui_ucode cleanup-action-dir-fixture "$cleanup_dir" [ ! -e "$cleanup_dir/done.json" ] || fail "expired acknowledged action state should be cleaned" [ ! -e "$cleanup_dir/done.out" ] || fail "expired acknowledged action stdout sidecar should be cleaned" [ ! -e "$cleanup_dir/done.out.json" ] || fail "expired acknowledged action JSON sidecar should be cleaned" old_state="$cleanup_dir/old.json" printf '%s\n' '{"running":false}' >"$old_state" two_hours_ago="$(( $(date +%s) - 7200 ))" touch -d "@$two_hours_ago" "$old_state" TACHYON_UI_ACTION_FINISHED_TTL_MINUTES=60 ui_ucode cleanup-action-dir-fixture "$cleanup_dir" [ ! -e "$old_state" ] || fail "old finished action state should be cleaned without find" export TACHYON_UI_STATE_DIR="$WORK_DIR/ui-state" export TACHYON_UI_SERVICE_ACTION_DIR="$TACHYON_UI_STATE_DIR/service-actions" export TACHYON_UI_SERVICE_ACTION_LOCK_DIR="$TACHYON_UI_STATE_DIR/service-actions.lock" export TACHYON_UI_LATENCY_ACTION_DIR="$TACHYON_UI_STATE_DIR/latency-actions" export TACHYON_UI_COMPONENT_ACTION_DIR="$TACHYON_UI_STATE_DIR/component-actions" export TACHYON_UI_SUBSCRIPTION_ACTION_DIR="$TACHYON_UI_STATE_DIR/subscription-actions" latency_start="$( TACHYON_BIN=/bin/true \ TACHYON_LIB="$TACHYON_FILES/usr/lib" \ ui_ucode latency-test-async proxy_list main '["proxy-a","proxy-b"]' 5000 )" latency_job_id="$(printf '%s' "$latency_start" | sed -n 's/.*"job_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" [ -n "$latency_job_id" ] || fail "latency-test-async should return a job id" latency_async_state="$TACHYON_UI_LATENCY_ACTION_DIR/$latency_job_id.json" [ -f "$latency_async_state" ] || fail "latency-test-async should create a state file" JOB_STATE="$latency_async_state" node - <<'NODE' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8")); if (!value.progress || value.progress.completed !== 0 || value.progress.total !== 2 || value.progress.failed !== 0) { console.error("initial latency progress state mismatch"); process.exit(1); } NODE job_id="$(ui_ucode service-action-begin-if-idle reload test)" [ -n "$job_id" ] || fail "service-action-begin-if-idle should create a job" service_state="$TACHYON_UI_SERVICE_ACTION_DIR/$job_id.json" [ -f "$service_state" ] || fail "service action state file should be created" assert_eq "reload" \ "$(ui_ucode active-service-action)" \ "active-service-action should use the default service action dir" if ui_ucode service-action-begin-if-idle restart test >/dev/null 2>&1; then fail "second service action should be rejected while first is running" fi ui_ucode service-action-update-pid "$job_id" "$$" >/dev/null || fail "service-action-update-pid should update running job" TACHYON_UI_SERVICE_ACTION_TIMEOUT_SECONDS=1 \ TACHYON_UI_SERVICE_ACTION_SETTLE_SECONDS=0 \ TACHYON_SERVICE_INIT=/bin/true \ TACHYON_BIN=/bin/true \ TACHYON_LIB="$TACHYON_FILES/usr/lib" \ ui_ucode service-action-finish-after-command reload "$job_id" 0 >/dev/null || fail "service-action-finish-after-command should spawn waiter without ucode declaration-order failure" sleep 2 if grep -q '"running"[[:space:]]*:[[:space:]]*true' "$service_state"; then fail "service-action-finish-after-command waiter should finish the running service action" fi ui_ucode service-action-finish "$job_id" true done 0 >/dev/null || fail "service-action-finish should finish running job" JOB_STATE="$service_state" node - <<'NODE' const fs = require("fs"); const value = JSON.parse(fs.readFileSync(process.env.JOB_STATE, "utf8")); if (value.running !== false || value.success !== true || value.message !== "done" || value.exit_code !== 0) { console.error("finished service action state mismatch"); process.exit(1); } NODE component_job="$TACHYON_UI_COMPONENT_ACTION_DIR/component-1.json" mkdir -p "$TACHYON_UI_COMPONENT_ACTION_DIR" bash -c 'while true; do sleep 1; done' component-action-worker & worker_pid=$! printf '%s\n' '{"running":true,"component":"sing_box","pid":"'"$worker_pid"'","started_at":100}' >"$component_job" ui_ucode component-action-running-for sing_box >/dev/null || fail "component-action-running-for should detect running sing-box component action" if ui_ucode component-action-running-for zapret >/dev/null 2>&1; then fail "component-action-running-for should ignore other components" fi kill "$worker_pid" 2>/dev/null || true ui_ucode action-ack service "$job_id" >/dev/null || fail "action-ack should acknowledge finished service action" printf 'UI runtime job checks passed\n'