Skip to content

Commit ce61a8d

Browse files
authored
Merge pull request #10 from gensyn/copilot/add-playwright-e2e-tests-ssh-command
Add Playwright E2E tests for SSH Command integration
2 parents eab11a0 + 804f004 commit ce61a8d

22 files changed

Lines changed: 2037 additions & 4 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: Playwright E2E Tests
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
playwright-e2e:
8+
runs-on: ubuntu-latest
9+
permissions:
10+
contents: read
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Build Docker images
16+
run: docker compose build
17+
18+
- name: Run Playwright E2E tests
19+
# `docker compose run` starts the declared dependencies (homeassistant,
20+
# ssh_docker_test) and then runs the playwright-tests container.
21+
# The exit code of the run command mirrors the test container's exit code.
22+
run: docker compose run --rm playwright-tests
23+
24+
- name: Stop services
25+
if: always()
26+
run: docker compose down -v
27+
28+
- name: Upload test results
29+
if: always()
30+
uses: actions/upload-artifact@v4
31+
with:
32+
name: playwright-e2e-results
33+
path: playwright-results/
34+
if-no-files-found: ignore

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ __pycache__/
33
/htmlcov/
44
/.coverage
55
custom_components/
6+
playwright-results/

coordinator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]:
7171
CONF_PASSWORD: password,
7272
CONF_CLIENT_KEYS: key_file,
7373
CONF_KNOWN_HOSTS: await self._resolve_known_hosts(check_known_hosts, known_hosts),
74+
"connect_timeout": timeout,
7475
}
7576

7677
run_kwargs: dict[str, Any] = {

docker-compose.yaml

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
services:
2+
3+
# ── Home Assistant ──────────────────────────────────────────────────────────
4+
homeassistant:
5+
image: ghcr.io/home-assistant/home-assistant:stable
6+
container_name: homeassistant_test
7+
volumes:
8+
# Persistent HA config (survives container restarts; start fresh with
9+
# `docker compose down -v`).
10+
- ha_config:/config
11+
# Mount the integration source as a custom component so HA loads it on
12+
# startup without any extra copy step.
13+
- ./:/config/custom_components/ssh_command:ro
14+
# Startup wrapper that pre-populates /etc/hosts before launching HA.
15+
# Alpine Linux (musl libc) cannot resolve Docker container hostnames via
16+
# Python's socket module because of iptables/UDP limitations in this
17+
# environment. The wrapper uses busybox nslookup (which works) to add
18+
# entries to /etc/hosts so that all resolver calls succeed via the
19+
# "files" nsswitch path.
20+
- ./tests/playwright/ha-init-wrapper.sh:/ha-init-wrapper.sh:ro
21+
# SSH test-key data written by ssh_docker_test_1 at startup.
22+
# Provides the user auth private key and a known_hosts file so that
23+
# key-file and known-hosts E2E tests can reference them by path.
24+
- ssh_test_init:/ssh-test-keys:ro
25+
environment:
26+
- TZ=UTC
27+
entrypoint: ["/bin/sh", "/ha-init-wrapper.sh"]
28+
# Clear the external search domain that musl's resolver would try first,
29+
# which causes timeouts in this Azure-hosted environment.
30+
dns_search: "."
31+
restart: unless-stopped
32+
33+
# ── SSH test servers ────────────────────────────────────────────────────────
34+
# Two identical Ubuntu-based containers each run a single sshd on port 22
35+
# (the SSH default). The Home Assistant integration connects to port 22 by
36+
# default, so no port mapping is required.
37+
# Credentials: user=foo password=pass
38+
ssh_docker_test_1:
39+
build:
40+
context: tests/playwright
41+
dockerfile: Dockerfile.ssh
42+
container_name: ssh_docker_test_1
43+
environment:
44+
# Injected into the startup script so the known_hosts entry uses the
45+
# correct hostname rather than the container's random short hostname.
46+
- CONTAINER_NAME=ssh_docker_test_1
47+
volumes:
48+
# Shared with the HA container (read-only) at /ssh-test-keys so tests
49+
# can reference /ssh-test-keys/id_ed25519 and /ssh-test-keys/known_hosts.
50+
- ssh_test_init:/ssh-init-data
51+
52+
ssh_docker_test_2:
53+
build:
54+
context: tests/playwright
55+
dockerfile: Dockerfile.ssh
56+
container_name: ssh_docker_test_2
57+
58+
# ── Playwright E2E test runner ──────────────────────────────────────────────
59+
# Not started by default (`docker compose up`); invoke explicitly:
60+
# docker compose run --rm playwright-tests
61+
playwright-tests:
62+
build:
63+
context: .
64+
dockerfile: tests/playwright/Dockerfile
65+
environment:
66+
- HOMEASSISTANT_URL=http://homeassistant:8123
67+
- SSH_HOST_1=ssh_docker_test_1
68+
- SSH_HOST_2=ssh_docker_test_2
69+
- SSH_USER=foo
70+
- SSH_PASSWORD=pass
71+
- HA_USERNAME=admin
72+
- HA_PASSWORD=admin
73+
volumes:
74+
# Test results (JUnit XML) written here are available on the host after
75+
# the container exits, e.g. for CI artifact upload.
76+
- ./playwright-results:/app/playwright-results
77+
depends_on:
78+
- homeassistant
79+
- ssh_docker_test_1
80+
- ssh_docker_test_2
81+
82+
volumes:
83+
ha_config:
84+
# Populated by ssh_docker_test_1 at container startup; mounted read-only
85+
# into the HA container at /ssh-test-keys so that key-file and known-hosts
86+
# E2E tests can access the credentials by path.
87+
ssh_test_init:
88+

run_playwright_tests.sh

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env bash
2+
# run_playwright_tests.sh
3+
#
4+
# Runs the Playwright E2E test suite in a fully isolated Docker environment.
5+
# No local Python environment or browser installation is required.
6+
#
7+
# The suite spins up Home Assistant, two SSH test servers, and the Playwright
8+
# test runner via docker compose, then tears everything down on exit.
9+
#
10+
# Usage:
11+
# ./run_playwright_tests.sh
12+
13+
set -euo pipefail
14+
15+
# ── Colour helpers ────────────────────────────────────────────────────────────
16+
RED='\033[0;31m'
17+
GREEN='\033[0;32m'
18+
YELLOW='\033[1;33m'
19+
BLUE='\033[0;34m'
20+
BOLD='\033[1m'
21+
NC='\033[0m'
22+
23+
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
24+
success() { echo -e "${GREEN}[PASS]${NC} $*"; }
25+
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
26+
error() { echo -e "${RED}[FAIL]${NC} $*"; }
27+
header() { echo -e "\n${BOLD}$*${NC}"; }
28+
29+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
30+
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yaml"
31+
32+
# ── Resolve docker compose command ───────────────────────────────────────────
33+
get_compose_cmd() {
34+
if command -v docker &>/dev/null && sudo docker compose version &>/dev/null 2>&1; then
35+
echo "sudo docker compose"
36+
else
37+
error "docker compose is not available. Please install Docker with the Compose plugin."
38+
exit 1
39+
fi
40+
}
41+
42+
# ── Main ──────────────────────────────────────────────────────────────────────
43+
main() {
44+
if [[ $# -gt 0 ]]; then
45+
error "This script takes no arguments."
46+
echo "Usage: $0"
47+
exit 1
48+
fi
49+
50+
if [[ ! -f "$COMPOSE_FILE" ]]; then
51+
error "docker-compose.yaml not found at $COMPOSE_FILE"
52+
exit 1
53+
fi
54+
55+
header "════════════════════════════════════════════════════"
56+
header " Playwright E2E tests (docker compose)"
57+
header "════════════════════════════════════════════════════"
58+
59+
local compose_cmd
60+
compose_cmd="$(get_compose_cmd)"
61+
62+
info "Building Docker images…"
63+
$compose_cmd -f "$COMPOSE_FILE" build
64+
65+
info "Running test container (this may take several minutes on first run)…"
66+
local exit_code=0
67+
$compose_cmd -f "$COMPOSE_FILE" run --rm playwright-tests || exit_code=$?
68+
69+
info "Stopping services…"
70+
$compose_cmd -f "$COMPOSE_FILE" down -v || true
71+
72+
if [[ $exit_code -eq 0 ]]; then
73+
echo ""
74+
success "All Playwright E2E tests passed."
75+
exit 0
76+
else
77+
echo ""
78+
error "Playwright E2E tests failed (exit code ${exit_code})."
79+
exit "${exit_code}"
80+
fi
81+
}
82+
83+
main "$@"

run_tests.sh

Lines changed: 0 additions & 3 deletions
This file was deleted.

run_workflows_locally.sh

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,29 @@ run_workflow() {
108108
fi
109109
}
110110

111+
# ── Playwright E2E tests via docker compose ───────────────────────────────────
112+
# The playwright-tests.yml workflow uses `docker compose run` internally, which
113+
# requires a real Docker daemon. act (Docker-in-Docker) cannot reliably run
114+
# that workflow, so we delegate to the dedicated run_playwright_tests.sh script.
115+
run_playwright_tests() {
116+
local script="$SCRIPT_DIR/run_playwright_tests.sh"
117+
118+
if [[ ! -f "$script" ]]; then
119+
warn "run_playwright_tests.sh not found – skipping Playwright E2E tests."
120+
return 1
121+
fi
122+
123+
if bash "$script"; then
124+
success "playwright-tests.yml passed"
125+
return 0
126+
else
127+
error "playwright-tests.yml failed"
128+
return 1
129+
fi
130+
}
131+
111132
run_all_workflows() {
112-
# Only workflows that run entirely locally (tests and linting).
133+
# Only act-compatible workflows (no Docker-in-Docker requirement).
113134
# Workflows that depend on GitHub infrastructure (hassfest, HACS validation,
114135
# release) are silently omitted.
115136
local workflow_files=(
@@ -144,6 +165,13 @@ run_all_workflows() {
144165
fi
145166
done
146167

168+
# ── Playwright E2E tests (docker compose, not act) ────────────────────────
169+
if run_playwright_tests; then
170+
passed+=("playwright-tests.yml")
171+
else
172+
failed+=("playwright-tests.yml")
173+
fi
174+
147175
# ── Summary ───────────────────────────────────────────────────────────────
148176
header "══════════════════════════════════════════════"
149177
header " Results"

tests/playwright/Dockerfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Playwright E2E test-runner image.
2+
#
3+
# Build context: the repository root (so all test files and the component
4+
# source are available inside the container).
5+
FROM python:3.12-slim
6+
7+
WORKDIR /app
8+
9+
# System packages needed by Playwright's bundled Chromium
10+
RUN apt-get update && apt-get install -y --no-install-recommends \
11+
curl \
12+
&& rm -rf /var/lib/apt/lists/*
13+
14+
# Python dependencies (test suite)
15+
COPY tests/playwright/requirements.txt ./playwright-requirements.txt
16+
RUN pip install --no-cache-dir -r playwright-requirements.txt && \
17+
playwright install chromium && \
18+
playwright install-deps chromium
19+
20+
# Copy the full repository so the component source and all test files
21+
# are available at /app (component root) and /app/tests/playwright/.
22+
COPY . /app
23+
24+
COPY tests/playwright/entrypoint.sh /entrypoint.sh
25+
RUN chmod +x /entrypoint.sh
26+
27+
ENTRYPOINT ["/entrypoint.sh"]

tests/playwright/Dockerfile.ssh

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Minimal SSH test server with a single user (foo / pass).
2+
# One sshd daemon runs on the standard port 22 so the Home Assistant
3+
# integration (which defaults to port 22) can connect without any
4+
# port-number configuration.
5+
FROM ubuntu:24.04
6+
7+
RUN apt-get update -qq && \
8+
DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --no-install-recommends \
9+
openssh-server \
10+
&& rm -rf /var/lib/apt/lists/*
11+
12+
# Create the test user
13+
RUN useradd -m -s /bin/sh foo && \
14+
echo "foo:pass" | chpasswd
15+
16+
# Write an sshd config
17+
RUN printf '%s\n' \
18+
'HostKey /etc/ssh/ssh_host_rsa_key' \
19+
'HostKey /etc/ssh/ssh_host_ecdsa_key' \
20+
'HostKey /etc/ssh/ssh_host_ed25519_key' \
21+
'AuthorizedKeysFile .ssh/authorized_keys' \
22+
'PasswordAuthentication yes' \
23+
'PubkeyAuthentication yes' \
24+
'KbdInteractiveAuthentication no' \
25+
'UsePAM no' \
26+
'PrintMotd no' \
27+
'PrintLastLog no' \
28+
'Subsystem sftp /usr/lib/openssh/sftp-server' \
29+
> /etc/ssh/sshd_config.d/test.conf
30+
31+
# Generate host keys, create the privilege-separation directory, and create
32+
# the test user's ed25519 auth key pair (used by key-file authentication tests).
33+
RUN ssh-keygen -A && \
34+
mkdir -p /run/sshd && \
35+
mkdir -p /home/foo/.ssh && \
36+
chmod 700 /home/foo/.ssh && \
37+
ssh-keygen -t ed25519 -f /home/foo/.ssh/id_ed25519 -N "" && \
38+
cat /home/foo/.ssh/id_ed25519.pub > /home/foo/.ssh/authorized_keys && \
39+
chmod 600 /home/foo/.ssh/id_ed25519 && \
40+
chmod 644 /home/foo/.ssh/id_ed25519.pub /home/foo/.ssh/authorized_keys && \
41+
chown -R foo:foo /home/foo/.ssh
42+
43+
# Startup script: populates the shared init volume then starts sshd.
44+
COPY ssh-init-entrypoint.sh /ssh-init-entrypoint.sh
45+
RUN chmod +x /ssh-init-entrypoint.sh
46+
47+
EXPOSE 22
48+
49+
CMD ["/ssh-init-entrypoint.sh"]

0 commit comments

Comments
 (0)