Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8136d05
Initial plan
Copilot Mar 19, 2026
a639a79
Add comprehensive Playwright E2E tests for SSH Command
Copilot Mar 19, 2026
51ac5e6
Address review: full lifecycle test, strict single-instance check, in…
Copilot Mar 24, 2026
9d51085
Merge remote-tracking branch 'origin/main' into copilot/add-playwrigh…
gensyn Mar 25, 2026
4891ffd
Add Docker encapsulation and GitHub Actions workflow for Playwright E…
Copilot Mar 25, 2026
e479dbe
Fix test failures: pytest rootdir, SSH port, asyncssh version, servic…
Copilot Mar 25, 2026
1e32729
Fix DNS resolution, asyncssh version, service_response wrapper, statu…
Copilot Mar 25, 2026
6366e8b
Fix last failing UI test: use direct config flow URL instead of shado…
Copilot Mar 25, 2026
17302dd
Fix test_add_integration_via_ui: two-step navigation to establish aut…
Copilot Mar 25, 2026
6eb5d96
Fix browser auth: replace add_init_script with storage_state for hass…
Copilot Mar 25, 2026
5b486d6
Fix onboarding: complete integration step with client_id/redirect_uri…
Copilot Mar 25, 2026
e365594
Extract Playwright test logic into dedicated run_playwright_tests.sh …
Copilot Mar 25, 2026
af1075d
Fix connect_timeout: apply user timeout to asyncssh connect() call, n…
Copilot Mar 26, 2026
75d239f
Updated Playwright tests
gensyn Mar 26, 2026
5ace910
Add key-file auth and known-hosts E2E tests with shared Docker volume…
Copilot Mar 26, 2026
9baaad5
Updated Playwright tests
gensyn Mar 26, 2026
c9235fd
Make Playwright workflow manual-only (workflow_dispatch)
Copilot Mar 26, 2026
804f004
Updated manifest.json
gensyn Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/playwright-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Playwright E2E Tests

on:
workflow_dispatch:

jobs:
playwright-e2e:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4

- name: Build Docker images
run: docker compose build

- name: Run Playwright E2E tests
# `docker compose run` starts the declared dependencies (homeassistant,
# ssh_docker_test) and then runs the playwright-tests container.
# The exit code of the run command mirrors the test container's exit code.
run: docker compose run --rm playwright-tests

- name: Stop services
if: always()
run: docker compose down -v

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-e2e-results
path: playwright-results/
if-no-files-found: ignore
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ __pycache__/
/htmlcov/
/.coverage
custom_components/
playwright-results/
1 change: 1 addition & 0 deletions coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ async def async_execute(self, data: dict[str, Any]) -> dict[str, Any]:
CONF_PASSWORD: password,
CONF_CLIENT_KEYS: key_file,
CONF_KNOWN_HOSTS: await self._resolve_known_hosts(check_known_hosts, known_hosts),
"connect_timeout": timeout,
}

run_kwargs: dict[str, Any] = {
Expand Down
88 changes: 88 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
services:

# ── Home Assistant ──────────────────────────────────────────────────────────
homeassistant:
image: ghcr.io/home-assistant/home-assistant:stable
container_name: homeassistant_test
volumes:
# Persistent HA config (survives container restarts; start fresh with
# `docker compose down -v`).
- ha_config:/config
# Mount the integration source as a custom component so HA loads it on
# startup without any extra copy step.
- ./:/config/custom_components/ssh_command:ro
# Startup wrapper that pre-populates /etc/hosts before launching HA.
# Alpine Linux (musl libc) cannot resolve Docker container hostnames via
# Python's socket module because of iptables/UDP limitations in this
# environment. The wrapper uses busybox nslookup (which works) to add
# entries to /etc/hosts so that all resolver calls succeed via the
# "files" nsswitch path.
- ./tests/playwright/ha-init-wrapper.sh:/ha-init-wrapper.sh:ro
# SSH test-key data written by ssh_docker_test_1 at startup.
# Provides the user auth private key and a known_hosts file so that
# key-file and known-hosts E2E tests can reference them by path.
- ssh_test_init:/ssh-test-keys:ro
environment:
- TZ=UTC
entrypoint: ["/bin/sh", "/ha-init-wrapper.sh"]
# Clear the external search domain that musl's resolver would try first,
# which causes timeouts in this Azure-hosted environment.
dns_search: "."
restart: unless-stopped

# ── SSH test servers ────────────────────────────────────────────────────────
# Two identical Ubuntu-based containers each run a single sshd on port 22
# (the SSH default). The Home Assistant integration connects to port 22 by
# default, so no port mapping is required.
# Credentials: user=foo password=pass
ssh_docker_test_1:
build:
context: tests/playwright
dockerfile: Dockerfile.ssh
container_name: ssh_docker_test_1
environment:
# Injected into the startup script so the known_hosts entry uses the
# correct hostname rather than the container's random short hostname.
- CONTAINER_NAME=ssh_docker_test_1
volumes:
# Shared with the HA container (read-only) at /ssh-test-keys so tests
# can reference /ssh-test-keys/id_ed25519 and /ssh-test-keys/known_hosts.
- ssh_test_init:/ssh-init-data

ssh_docker_test_2:
build:
context: tests/playwright
dockerfile: Dockerfile.ssh
container_name: ssh_docker_test_2

# ── Playwright E2E test runner ──────────────────────────────────────────────
# Not started by default (`docker compose up`); invoke explicitly:
# docker compose run --rm playwright-tests
playwright-tests:
build:
context: .
dockerfile: tests/playwright/Dockerfile
environment:
- HOMEASSISTANT_URL=http://homeassistant:8123
- SSH_HOST_1=ssh_docker_test_1
- SSH_HOST_2=ssh_docker_test_2
- SSH_USER=foo
- SSH_PASSWORD=pass
- HA_USERNAME=admin
- HA_PASSWORD=admin
volumes:
# Test results (JUnit XML) written here are available on the host after
# the container exits, e.g. for CI artifact upload.
- ./playwright-results:/app/playwright-results
depends_on:
- homeassistant
- ssh_docker_test_1
- ssh_docker_test_2

volumes:
ha_config:
# Populated by ssh_docker_test_1 at container startup; mounted read-only
# into the HA container at /ssh-test-keys so that key-file and known-hosts
# E2E tests can access the credentials by path.
ssh_test_init:

83 changes: 83 additions & 0 deletions run_playwright_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# run_playwright_tests.sh
#
# Runs the Playwright E2E test suite in a fully isolated Docker environment.
# No local Python environment or browser installation is required.
#
# The suite spins up Home Assistant, two SSH test servers, and the Playwright
# test runner via docker compose, then tears everything down on exit.
#
# Usage:
# ./run_playwright_tests.sh

set -euo pipefail

# ── Colour helpers ────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
BOLD='\033[1m'
NC='\033[0m'

info() { echo -e "${BLUE}[INFO]${NC} $*"; }
success() { echo -e "${GREEN}[PASS]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[FAIL]${NC} $*"; }
header() { echo -e "\n${BOLD}$*${NC}"; }

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yaml"

# ── Resolve docker compose command ───────────────────────────────────────────
get_compose_cmd() {
if command -v docker &>/dev/null && sudo docker compose version &>/dev/null 2>&1; then
echo "sudo docker compose"
else
error "docker compose is not available. Please install Docker with the Compose plugin."
exit 1
fi
}

# ── Main ──────────────────────────────────────────────────────────────────────
main() {
if [[ $# -gt 0 ]]; then
error "This script takes no arguments."
echo "Usage: $0"
exit 1
fi

if [[ ! -f "$COMPOSE_FILE" ]]; then
error "docker-compose.yaml not found at $COMPOSE_FILE"
exit 1
fi

header "════════════════════════════════════════════════════"
header " Playwright E2E tests (docker compose)"
header "════════════════════════════════════════════════════"

local compose_cmd
compose_cmd="$(get_compose_cmd)"

info "Building Docker images…"
$compose_cmd -f "$COMPOSE_FILE" build

info "Running test container (this may take several minutes on first run)…"
local exit_code=0
$compose_cmd -f "$COMPOSE_FILE" run --rm playwright-tests || exit_code=$?

info "Stopping services…"
$compose_cmd -f "$COMPOSE_FILE" down -v || true

if [[ $exit_code -eq 0 ]]; then
echo ""
success "All Playwright E2E tests passed."
exit 0
else
echo ""
error "Playwright E2E tests failed (exit code ${exit_code})."
exit "${exit_code}"
fi
}

main "$@"
3 changes: 0 additions & 3 deletions run_tests.sh

This file was deleted.

30 changes: 29 additions & 1 deletion run_workflows_locally.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,29 @@ run_workflow() {
fi
}

# ── Playwright E2E tests via docker compose ───────────────────────────────────
# The playwright-tests.yml workflow uses `docker compose run` internally, which
# requires a real Docker daemon. act (Docker-in-Docker) cannot reliably run
# that workflow, so we delegate to the dedicated run_playwright_tests.sh script.
run_playwright_tests() {
local script="$SCRIPT_DIR/run_playwright_tests.sh"

if [[ ! -f "$script" ]]; then
warn "run_playwright_tests.sh not found – skipping Playwright E2E tests."
return 1
fi

if bash "$script"; then
success "playwright-tests.yml passed"
return 0
else
error "playwright-tests.yml failed"
return 1
fi
}

run_all_workflows() {
# Only workflows that run entirely locally (tests and linting).
# Only act-compatible workflows (no Docker-in-Docker requirement).
# Workflows that depend on GitHub infrastructure (hassfest, HACS validation,
# release) are silently omitted.
local workflow_files=(
Expand Down Expand Up @@ -144,6 +165,13 @@ run_all_workflows() {
fi
done

# ── Playwright E2E tests (docker compose, not act) ────────────────────────
if run_playwright_tests; then
passed+=("playwright-tests.yml")
else
failed+=("playwright-tests.yml")
fi

# ── Summary ───────────────────────────────────────────────────────────────
header "══════════════════════════════════════════════"
header " Results"
Expand Down
27 changes: 27 additions & 0 deletions tests/playwright/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Playwright E2E test-runner image.
#
# Build context: the repository root (so all test files and the component
# source are available inside the container).
FROM python:3.12-slim

WORKDIR /app

# System packages needed by Playwright's bundled Chromium
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*

# Python dependencies (test suite)
COPY tests/playwright/requirements.txt ./playwright-requirements.txt
RUN pip install --no-cache-dir -r playwright-requirements.txt && \
playwright install chromium && \
playwright install-deps chromium

# Copy the full repository so the component source and all test files
# are available at /app (component root) and /app/tests/playwright/.
COPY . /app

COPY tests/playwright/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
49 changes: 49 additions & 0 deletions tests/playwright/Dockerfile.ssh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Minimal SSH test server with a single user (foo / pass).
# One sshd daemon runs on the standard port 22 so the Home Assistant
# integration (which defaults to port 22) can connect without any
# port-number configuration.
FROM ubuntu:24.04

RUN apt-get update -qq && \
DEBIAN_FRONTEND=noninteractive apt-get install -qq -y --no-install-recommends \
openssh-server \
&& rm -rf /var/lib/apt/lists/*

# Create the test user
RUN useradd -m -s /bin/sh foo && \
echo "foo:pass" | chpasswd

# Write an sshd config
RUN printf '%s\n' \
'HostKey /etc/ssh/ssh_host_rsa_key' \
'HostKey /etc/ssh/ssh_host_ecdsa_key' \
'HostKey /etc/ssh/ssh_host_ed25519_key' \
'AuthorizedKeysFile .ssh/authorized_keys' \
'PasswordAuthentication yes' \
'PubkeyAuthentication yes' \
'KbdInteractiveAuthentication no' \
'UsePAM no' \
'PrintMotd no' \
'PrintLastLog no' \
'Subsystem sftp /usr/lib/openssh/sftp-server' \
> /etc/ssh/sshd_config.d/test.conf

# Generate host keys, create the privilege-separation directory, and create
# the test user's ed25519 auth key pair (used by key-file authentication tests).
RUN ssh-keygen -A && \
mkdir -p /run/sshd && \
mkdir -p /home/foo/.ssh && \
chmod 700 /home/foo/.ssh && \
ssh-keygen -t ed25519 -f /home/foo/.ssh/id_ed25519 -N "" && \
cat /home/foo/.ssh/id_ed25519.pub > /home/foo/.ssh/authorized_keys && \
chmod 600 /home/foo/.ssh/id_ed25519 && \
chmod 644 /home/foo/.ssh/id_ed25519.pub /home/foo/.ssh/authorized_keys && \
chown -R foo:foo /home/foo/.ssh

# Startup script: populates the shared init volume then starts sshd.
COPY ssh-init-entrypoint.sh /ssh-init-entrypoint.sh
RUN chmod +x /ssh-init-entrypoint.sh

EXPOSE 22

CMD ["/ssh-init-entrypoint.sh"]
Loading
Loading