mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(e2e/linux): silence dead-session noise + local docker harness refresh (#1777)
This commit is contained in:
@@ -118,8 +118,12 @@ jobs:
|
||||
xvfb-run -a --server-args="-screen 0 1280x960x24" \
|
||||
bash app/scripts/e2e-run-session.sh
|
||||
|
||||
- name: Upload e2e artifacts on failure
|
||||
if: failure() || cancelled()
|
||||
- name: Upload e2e artifacts
|
||||
# `always()` so artifacts upload even when an earlier step is
|
||||
# `continue-on-error: true` (mega-flow) and the job is marked
|
||||
# success. Without this we have no logs to diagnose the Linux
|
||||
# CEF session-death issue.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v5
|
||||
with:
|
||||
name: e2e-artifacts-linux
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
/**
|
||||
* Deep-link trigger utilities for E2E tests.
|
||||
*
|
||||
* ## tauri-driver (Linux — preferred CI path)
|
||||
* `browser.execute()` is fully supported, so `window.__simulateDeepLink()` is
|
||||
* the primary strategy. Shell fallback uses `xdg-open`.
|
||||
* All three platforms now run under Appium chromium driver attached to CEF,
|
||||
* which supports W3C `browser.execute()`. The primary path is therefore
|
||||
* `window.__simulateDeepLink()` injected into the WebView.
|
||||
*
|
||||
* ## Appium Mac2 (macOS — local dev path)
|
||||
* Mac2 does NOT support W3C Execute Script in WKWebView. Strategies (in order):
|
||||
* 1. `macos: activateApp` + `macos: deepLink` extension commands
|
||||
* 2. Shell `open -a ... "url"` fallback
|
||||
* Strategy order:
|
||||
* 1. WebView `__simulateDeepLink()` — works on every platform.
|
||||
* 2. macOS-only `macos: launchApp` + `macos: deepLink` extension commands
|
||||
* (kept for the macOS shell-out path that lets the OS dispatch the URL
|
||||
* scheme through Launch Services).
|
||||
* 3. macOS shell `open -a … "url"`.
|
||||
*
|
||||
* Linux has no shell fallback: `xdg-open openhuman://…` requires a
|
||||
* `.desktop` file registering the URL scheme, which the CI container does
|
||||
* not have, so attempting it just produces noise. If the WebView simulate
|
||||
* fails on Linux, `triggerDeepLink` throws immediately.
|
||||
*
|
||||
* When the WebDriver session has been torn down (`A session is either
|
||||
* terminated or not started`), every fallback also fails with the same
|
||||
* error — so we detect that case once via `isSessionDeadError` and rethrow
|
||||
* a clear message instead of letting the cascade of retries spam the log.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -32,6 +44,26 @@ function execCommand(command: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A "session is either terminated or not started" error from the WebDriver
|
||||
* client means CEF/Appium has already dropped the connection. Retrying or
|
||||
* falling back to other strategies is pointless — every subsequent call
|
||||
* will produce the same error (which is what caused the ~hundred-line
|
||||
* WARN/ERROR cascade in the Linux job we're trying to fix). Detect it
|
||||
* once, fail fast, and surface a clean error.
|
||||
*/
|
||||
function isSessionDeadError(err: unknown): boolean {
|
||||
if (!err) return false;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// First two patterns are what WebDriver / Appium raise directly; the
|
||||
// third matches our own wrapped error (rethrown from inner helpers) so
|
||||
// an outer catcher still recognises the dead-session case after we
|
||||
// replace the message for clarity.
|
||||
return /session is either terminated or not started|invalid session id|WebDriver session is dead/i.test(
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the WebDriver session supports `browser.execute()` for running
|
||||
* JS inside the WebView.
|
||||
@@ -66,6 +98,15 @@ async function trySimulateDeepLinkInWebView(url: string): Promise<boolean> {
|
||||
if (ping !== true) return false;
|
||||
} catch (err) {
|
||||
deepLinkDebug('execute ping failed', err instanceof Error ? err.message : err);
|
||||
// Bubble up dead-session so the caller can short-circuit the
|
||||
// macOS / xdg-open fallback chain instead of spamming the log
|
||||
// with identical "session terminated" errors for every retry.
|
||||
if (isSessionDeadError(err)) {
|
||||
throw new Error(
|
||||
`WebDriver session is dead — cannot deliver deep link ${url}. ` +
|
||||
`The CEF app or driver likely crashed in an earlier step.`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -85,18 +126,38 @@ async function trySimulateDeepLinkInWebView(url: string): Promise<boolean> {
|
||||
poll += 1;
|
||||
} catch (err) {
|
||||
deepLinkDebug('ready check failed', err instanceof Error ? err.message : err);
|
||||
// Same rationale as the ping check above: once the session is gone
|
||||
// there's nothing to recover to. Bubble up so triggerDeepLink can
|
||||
// skip the macOS / Linux fallbacks instead of returning a generic
|
||||
// `false` that hides the root cause.
|
||||
if (isSessionDeadError(err)) {
|
||||
throw new Error(
|
||||
`WebDriver session is dead — cannot deliver deep link ${url}. ` +
|
||||
`The CEF app or driver likely crashed in an earlier step.`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ready) {
|
||||
deepLinkDebug('invoking window.__simulateDeepLink');
|
||||
await browser.execute(async (u: string) => {
|
||||
const w = window as Window & { __simulateDeepLink?: (x: string) => Promise<void> };
|
||||
if (!w.__simulateDeepLink) {
|
||||
throw new Error('__simulateDeepLink is not available');
|
||||
try {
|
||||
await browser.execute(async (u: string) => {
|
||||
const w = window as Window & { __simulateDeepLink?: (x: string) => Promise<void> };
|
||||
if (!w.__simulateDeepLink) {
|
||||
throw new Error('__simulateDeepLink is not available');
|
||||
}
|
||||
await w.__simulateDeepLink(u);
|
||||
}, url);
|
||||
} catch (err) {
|
||||
if (isSessionDeadError(err)) {
|
||||
throw new Error(
|
||||
`WebDriver session is dead — cannot deliver deep link ${url}. ` +
|
||||
`The CEF app or driver likely crashed in an earlier step.`
|
||||
);
|
||||
}
|
||||
await w.__simulateDeepLink(u);
|
||||
}, url);
|
||||
throw err;
|
||||
}
|
||||
deepLinkDebug('simulate deep link finished OK');
|
||||
return true;
|
||||
}
|
||||
@@ -140,52 +201,76 @@ export async function triggerDeepLink(url: string): Promise<void> {
|
||||
});
|
||||
|
||||
if (typeof browser !== 'undefined') {
|
||||
// Strategy 1: WebView simulate (works on tauri-driver, skipped on Mac2)
|
||||
if (await trySimulateDeepLinkInWebView(url)) {
|
||||
deepLinkDebug('deep link delivered via WebView simulate');
|
||||
return;
|
||||
// Strategy 1: WebView simulate — the only reliable path on the unified
|
||||
// CEF/Appium-Chromium harness. If it succeeds we're done; if it throws
|
||||
// a dead-session error there's nothing more to try (the macOS extension
|
||||
// commands and shell fallback both require a live driver too, or in
|
||||
// Linux's case a `.desktop` file registering the `openhuman://` scheme
|
||||
// that the CI container doesn't have).
|
||||
try {
|
||||
if (await trySimulateDeepLinkInWebView(url)) {
|
||||
deepLinkDebug('deep link delivered via WebView simulate');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// Dead-session: rethrow with the clear message so WDIO reports the
|
||||
// real cause instead of a "Failed to trigger deep link: xdg-open"
|
||||
// red herring.
|
||||
if (isSessionDeadError(err)) throw err;
|
||||
// Other errors (e.g. JS exception inside the page): keep trying
|
||||
// the platform-specific fallbacks below.
|
||||
deepLinkDebug(
|
||||
'WebView simulate threw, continuing to fallbacks',
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await browser.execute('macos: launchApp', {
|
||||
bundleId: 'com.openhuman.app',
|
||||
arguments: [url],
|
||||
} as Record<string, unknown>);
|
||||
deepLinkDebug('macos: launchApp OK');
|
||||
} catch (err) {
|
||||
deepLinkDebug('macos: launchApp failed', err instanceof Error ? err.message : err);
|
||||
}
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
// Strategy 2: macOS-only extension commands. Skip outright on Linux —
|
||||
// they always fail there with the same "session terminated" cascade
|
||||
// we're trying to silence.
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
await browser.execute('macos: deepLink', { url, bundleId: 'com.openhuman.app' } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
deepLinkDebug('macos: deepLink OK', { attempt });
|
||||
await browser.pause(300);
|
||||
return;
|
||||
await browser.execute('macos: launchApp', {
|
||||
bundleId: 'com.openhuman.app',
|
||||
arguments: [url],
|
||||
} as Record<string, unknown>);
|
||||
deepLinkDebug('macos: launchApp OK');
|
||||
} catch (err) {
|
||||
deepLinkDebug('macos: deepLink failed', {
|
||||
attempt,
|
||||
error: err instanceof Error ? err.message : err,
|
||||
});
|
||||
await browser.pause(250);
|
||||
if (isSessionDeadError(err)) throw err;
|
||||
deepLinkDebug('macos: launchApp failed', err instanceof Error ? err.message : err);
|
||||
}
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await browser.execute('macos: deepLink', { url, bundleId: 'com.openhuman.app' } as Record<
|
||||
string,
|
||||
unknown
|
||||
>);
|
||||
deepLinkDebug('macos: deepLink OK', { attempt });
|
||||
await browser.pause(300);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (isSessionDeadError(err)) throw err;
|
||||
deepLinkDebug('macos: deepLink failed', {
|
||||
attempt,
|
||||
error: err instanceof Error ? err.message : err,
|
||||
});
|
||||
await browser.pause(250);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Shell fallback
|
||||
if (process.platform === 'linux') {
|
||||
// On Linux, use xdg-open for URL scheme dispatch
|
||||
try {
|
||||
deepLinkDebug('fallback shell: xdg-open', url);
|
||||
await execCommand(`xdg-open "${url}"`);
|
||||
deepLinkDebug('deep link dispatched via xdg-open');
|
||||
return;
|
||||
} catch (err) {
|
||||
deepLinkDebug('xdg-open failed', err instanceof Error ? err.message : err);
|
||||
throw new Error(`Failed to trigger deep link: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
// The Linux CI container does not register `openhuman://` with
|
||||
// xdg-mime, so `xdg-open` cannot dispatch the URL — it just errors
|
||||
// with `Command failed`. The only deep-link path that works under
|
||||
// CEF/Appium-Chromium on Linux is the in-WebView simulate above; if
|
||||
// we got here it already failed, and there is nothing to recover to.
|
||||
throw new Error(
|
||||
`Failed to trigger deep link ${url}: WebView simulate failed and ` +
|
||||
`xdg-open is not a valid fallback on Linux (no .desktop registration).`
|
||||
);
|
||||
}
|
||||
|
||||
// macOS shell fallback
|
||||
|
||||
+48
-17
@@ -1,38 +1,69 @@
|
||||
##
|
||||
# Run Linux E2E tests locally from macOS via Docker.
|
||||
# Run the Linux E2E flow locally on macOS via Docker.
|
||||
#
|
||||
# This mirrors the `e2e-linux` job in `.github/workflows/e2e.yml` so any
|
||||
# fix that turns the local run green is what CI will run too:
|
||||
# - same image: ghcr.io/tinyhumansai/openhuman_ci:latest
|
||||
# - same Xvfb + dbus bootstrap (via e2e/docker-entrypoint.sh)
|
||||
# - same runner: app/scripts/e2e-run-session.sh (Appium chromium driver
|
||||
# attached to CEF on :19222), NOT the retired tauri-driver path.
|
||||
#
|
||||
# Usage:
|
||||
# # Build + run all E2E flows
|
||||
# docker compose -f e2e/docker-compose.yml run --rm e2e
|
||||
#
|
||||
# # Run a specific spec
|
||||
# # Build the E2E app once (slow — produces the CEF bundle):
|
||||
# docker compose -f e2e/docker-compose.yml run --rm e2e \
|
||||
# bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke
|
||||
# bash -lc "pnpm install --frozen-lockfile && pnpm --filter openhuman-app test:e2e:build"
|
||||
#
|
||||
# # Build the E2E app first (if not already built)
|
||||
# # Then run a spec (smoke / mega-flow / etc):
|
||||
# docker compose -f e2e/docker-compose.yml run --rm e2e \
|
||||
# yarn workspace openhuman-app test:e2e:build
|
||||
# bash -lc "bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow"
|
||||
#
|
||||
# # Or run the full suite:
|
||||
# docker compose -f e2e/docker-compose.yml run --rm e2e \
|
||||
# bash -lc "bash app/scripts/e2e-run-session.sh"
|
||||
#
|
||||
# Notes:
|
||||
# - Uses the same CI image from GHCR (built by .github/workflows/docker-ci-image.yml)
|
||||
# - The repo is bind-mounted at /app so builds persist between runs
|
||||
# - Rust target/ and node_modules/ are cached via named volumes
|
||||
# - Xvfb provides a virtual display for webkit2gtk rendering
|
||||
# - The repo is bind-mounted at /workspace so builds + edits round-trip.
|
||||
# - Rust target/ and pnpm/npm caches live in named volumes so warm
|
||||
# re-runs are fast.
|
||||
# - Appium 3 + appium-chromium-driver are installed on first entry by
|
||||
# the entrypoint (cached into the npm volume after that).
|
||||
#
|
||||
services:
|
||||
e2e:
|
||||
image: ghcr.io/tinyhumansai/openhuman_ci:latest
|
||||
entrypoint: ["/docker-entrypoint.sh"]
|
||||
command: ["yarn", "workspace", "openhuman-app", "test:e2e:all"]
|
||||
working_dir: /app
|
||||
image: ${OPENHUMAN_CI_IMAGE:-ghcr.io/tinyhumansai/openhuman_ci:latest}
|
||||
# Use the bind-mounted local bootstrap (Xvfb + dbus + Appium + pnpm).
|
||||
# The image's baked-in /docker-entrypoint.sh is the CI version; the
|
||||
# local wrapper additionally lazy-installs Appium and JS deps so a
|
||||
# fresh checkout becomes runnable with one command.
|
||||
entrypoint: ["/workspace/e2e/docker-local-bootstrap.sh"]
|
||||
# Default to an interactive shell so the user can `docker compose run`
|
||||
# without an explicit command. CI-style one-shot invocations override
|
||||
# this on the command line.
|
||||
command: ["bash", "-l"]
|
||||
working_dir: /workspace
|
||||
volumes:
|
||||
- ..:/app
|
||||
- ../:/workspace
|
||||
- e2e-cargo-registry:/usr/local/cargo/registry
|
||||
- e2e-cargo-git:/usr/local/cargo/git
|
||||
- e2e-rust-target:/workspace/target
|
||||
- e2e-tauri-target:/workspace/app/src-tauri/target
|
||||
- e2e-pnpm-store:/root/.local/share/pnpm/store
|
||||
- e2e-npm-global:/usr/lib/node_modules
|
||||
environment:
|
||||
- DISPLAY=:99
|
||||
- CI=true
|
||||
# Appium chromium driver downloads a chromedriver matching CEF's
|
||||
# Chromium version; cache it next to the repo so re-runs are fast.
|
||||
- WDIO_CHROMEDRIVER_CACHE=/workspace/.cache/chromedriver
|
||||
# CEF spawns a lot of helper processes; the default IPC namespace
|
||||
# sometimes runs out of shm. Bump it to match what GitHub-hosted
|
||||
# ubuntu-22.04 runners give CEF.
|
||||
shm_size: 2gb
|
||||
|
||||
volumes:
|
||||
e2e-cargo-registry:
|
||||
e2e-cargo-git:
|
||||
e2e-rust-target:
|
||||
e2e-tauri-target:
|
||||
e2e-pnpm-store:
|
||||
e2e-npm-global:
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local-only bootstrap for the Linux E2E Docker container.
|
||||
#
|
||||
# The CI image (`ghcr.io/tinyhumansai/openhuman_ci:latest`) intentionally
|
||||
# does NOT bake in Appium or pnpm deps so the image stays small. CI
|
||||
# installs them per-job. This wrapper performs the same install steps the
|
||||
# CI workflow runs (`.github/workflows/e2e.yml` → `e2e-linux`), but only
|
||||
# when they're missing — so warm re-runs are instant.
|
||||
#
|
||||
# Sequence:
|
||||
# 1. Hand off to the shipped entrypoint (Xvfb + dbus).
|
||||
# 2. Install Appium 3 + appium-chromium-driver if missing.
|
||||
# 3. Install JS deps (`pnpm install --frozen-lockfile`) if missing.
|
||||
# 4. exec the user's command.
|
||||
#
|
||||
# Triggered by `e2e/docker-compose.yml` as the container entrypoint
|
||||
# wrapper. The image's own /docker-entrypoint.sh is invoked from here
|
||||
# so Xvfb/dbus are still set up.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Run the image's entrypoint logic (Xvfb + dbus) in-process via `source`
|
||||
# so the resulting env vars (DISPLAY, DBUS_SESSION_BUS_ADDRESS) reach
|
||||
# our exec below. The shipped script ends with `exec "$@"` so we use a
|
||||
# trimmed inline version here.
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if ! pgrep -x Xvfb >/dev/null 2>&1; then
|
||||
Xvfb "$DISPLAY" -screen 0 1280x1024x24 &
|
||||
XVFB_PID=$!
|
||||
for _ in 1 2 3 4 5; do
|
||||
sleep 0.3
|
||||
kill -0 "$XVFB_PID" 2>/dev/null && break
|
||||
done
|
||||
if ! kill -0 "$XVFB_PID" 2>/dev/null; then
|
||||
echo "[e2e-bootstrap] ERROR: Xvfb failed to start" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then
|
||||
eval "$(dbus-launch --sh-syntax)"
|
||||
fi
|
||||
export RUST_BACKTRACE=1
|
||||
mkdir -p "${HOME}/.local/share/applications"
|
||||
|
||||
# 2. Appium + chromium driver — install once, cache via the npm_global volume.
|
||||
if ! command -v appium >/dev/null 2>&1; then
|
||||
echo "[e2e-bootstrap] Installing Appium 3 + chromium driver..."
|
||||
npm install -g appium@3 >/dev/null
|
||||
appium driver install --source=npm appium-chromium-driver >/dev/null
|
||||
fi
|
||||
|
||||
# 3. JS deps — only run pnpm install when node_modules is empty or stale.
|
||||
# `pnpm install --frozen-lockfile` is a no-op when up to date, so this
|
||||
# is cheap on warm re-runs.
|
||||
cd /workspace
|
||||
if [ ! -d node_modules ] || [ ! -e node_modules/.modules.yaml ]; then
|
||||
echo "[e2e-bootstrap] Installing JS deps..."
|
||||
pnpm install --frozen-lockfile
|
||||
fi
|
||||
|
||||
# 4. Ensure stub env files exist (CI does this too).
|
||||
[ -f .env ] || touch .env
|
||||
[ -f app/.env ] || touch app/.env
|
||||
|
||||
echo "[e2e-bootstrap] Ready (DISPLAY=$DISPLAY)."
|
||||
exec "$@"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# One-shot wrapper: reproduce the Linux E2E job locally on macOS.
|
||||
#
|
||||
# Mirrors `.github/workflows/e2e.yml` `e2e-linux` step-for-step inside
|
||||
# the same CI container so any fix that lands green here lands green on CI.
|
||||
#
|
||||
# Usage:
|
||||
# ./e2e/run-local.sh # smoke spec
|
||||
# ./e2e/run-local.sh mega-flow # mega-flow spec
|
||||
# ./e2e/run-local.sh smoke mega-flow # both, in order
|
||||
# ./e2e/run-local.sh all # full suite
|
||||
# ./e2e/run-local.sh shell # interactive shell
|
||||
# ./e2e/run-local.sh build # rebuild CEF bundle only
|
||||
#
|
||||
# The first run is slow (pulls the image, installs Appium, builds the
|
||||
# CEF bundle). Subsequent runs are fast — volumes cache everything.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
COMPOSE=(docker compose -f e2e/docker-compose.yml)
|
||||
RUN=("${COMPOSE[@]}" run --rm e2e)
|
||||
|
||||
ensure_built() {
|
||||
if [ ! -d "$REPO_ROOT/app/src-tauri/target/debug/bundle" ]; then
|
||||
echo "[run-local] CEF bundle not built yet — building (slow first run)..."
|
||||
"${RUN[@]}" bash -lc "pnpm --filter openhuman-app test:e2e:build"
|
||||
fi
|
||||
}
|
||||
|
||||
run_spec() {
|
||||
local name="$1"
|
||||
local spec
|
||||
case "$name" in
|
||||
smoke) spec="test/e2e/specs/smoke.spec.ts" ;;
|
||||
mega-flow) spec="test/e2e/specs/mega-flow.spec.ts" ;;
|
||||
*) spec="test/e2e/specs/${name}.spec.ts" ;;
|
||||
esac
|
||||
echo "[run-local] === $name ($spec) ==="
|
||||
"${RUN[@]}" bash -lc "bash app/scripts/e2e-run-session.sh '$spec' '$name'"
|
||||
}
|
||||
|
||||
case "${1:-smoke}" in
|
||||
shell)
|
||||
"${RUN[@]}" bash -l
|
||||
;;
|
||||
build)
|
||||
"${RUN[@]}" bash -lc "pnpm --filter openhuman-app test:e2e:build"
|
||||
;;
|
||||
all)
|
||||
ensure_built
|
||||
"${RUN[@]}" bash -lc "bash app/scripts/e2e-run-session.sh"
|
||||
;;
|
||||
*)
|
||||
ensure_built
|
||||
# `case "${1:-smoke}"` only defaults the test for matching — the loop
|
||||
# below iterates the real `"$@"`, which is empty on a no-arg invocation.
|
||||
# Re-set the positional params so a bare `./e2e/run-local.sh` actually
|
||||
# runs the smoke spec instead of silently doing nothing.
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set -- smoke
|
||||
fi
|
||||
for name in "$@"; do
|
||||
run_spec "$name"
|
||||
done
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user