Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
112 changes: 112 additions & 0 deletions .github/workflows/_terminal-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Terminal Test

# End-to-end browser coverage for the interactive terminal (#259).
#
# `tests/frontend/test_terminal.py` bundles the real vendored xterm frontend and
# drives the real `fastled` server and PTY from Chromium and WebKit. `bash test`
# does not run it, and until this workflow nothing did: #255 (every WebSocket
# session died at its handshake) and #256 (disconnected writers leaked every
# terminal slot) both shipped with a green board.
#
# Under CI the suite fails, rather than skips, when the binary, esbuild,
# Playwright or psutil is missing, so a misconfigured job cannot report green.

on:
workflow_call:
inputs:
runs-on:
required: true
type: string
pytest-args:
description: Extra pytest selection, e.g. a -k filter for a platform.
required: false
type: string
default: ""

env:
# Keep in step with ESBUILD_VERSION in crates/fastled-cli/src/install.rs.
ESBUILD_VERSION: "0.28.0"
PLAYWRIGHT_VERSION: "1.62.0"

jobs:
terminal-test:
runs-on: ${{ inputs.runs-on }}
timeout-minutes: 45
defaults:
run:
shell: bash
env:
PYTHONIOENCODING: utf-8
steps:
- uses: actions/checkout@v4

- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.11.9"

- name: Install Python
run: uv python install "$UV_PYTHON"

- uses: zackees/setup-soldr@v0.9.62
env:
GITHUB_TOKEN: ${{ github.token }}
with:
# Same cache namespace as the unit-test job on this runner, which
# also builds the debug workspace, so the two share warm caches.
cache-preset: foundation
cache-key-suffix: ${{ inputs.runs-on }}-native
linker: platform-default
toolchain-file: rust-toolchain.toml
prebuild-deps-flags: ""

- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev

- name: Build the fastled binary
run: soldr cargo build --bin fastled

- name: Install esbuild
run: |
npm install --prefix "$RUNNER_TEMP/esbuild" --no-save --no-audit --no-fund "esbuild@$ESBUILD_VERSION"
# soldr may build for an explicit host triple, which places the
# binary under target/<triple>/debug instead of target/debug.
if [ "$RUNNER_OS" = "Windows" ]; then
esbuild="$(cygpath -w "$RUNNER_TEMP/esbuild/node_modules/@esbuild/win32-x64/esbuild.exe")"
found="$(find target -path '*/debug/fastled.exe' -not -path '*/deps/*' | head -n 1)"
binary="$(cygpath -w "$GITHUB_WORKSPACE/$found")"
else
esbuild="$RUNNER_TEMP/esbuild/node_modules/.bin/esbuild"
found="$(find target -path '*/debug/fastled' -type f -not -path '*/deps/*' | head -n 1)"
binary="$GITHUB_WORKSPACE/$found"
fi
if [ -z "$found" ]; then
echo "no debug fastled binary under target/" >&2
exit 1
fi
"$esbuild" --version
"$binary" --version
echo "FASTLED_ESBUILD=$esbuild" >> "$GITHUB_ENV"
echo "FASTLED_TERMINAL_BINARY=$binary" >> "$GITHUB_ENV"

- name: Install Playwright browsers
run: uv run --no-project --with "playwright==$PLAYWRIGHT_VERSION" playwright install --with-deps chromium webkit

- name: Browser terminal tests
env:
FASTLED_TERMINAL_LOG_DIR: ${{ github.workspace }}/logs
PYTEST_ARGS: ${{ inputs.pytest-args }}
run: >-
uv run --no-project --with "playwright==$PLAYWRIGHT_VERSION" --with psutil --with pytest
pytest tests/frontend/test_terminal.py -v -rs $PYTEST_ARGS

- name: Upload terminal server logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: terminal-test-logs-${{ inputs.runs-on }}
path: logs/*
if-no-files-found: warn
20 changes: 20 additions & 0 deletions .github/workflows/linux-x86-terminal-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Linux x86 Terminal Test

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# One live run per workflow per ref: a new push to a branch or PR cancels the
# superseded run instead of queuing behind it in the shared account-wide runner
# pool. Runs on main get a unique group, so they are never cancelled or coalesced.
concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
call:
uses: ./.github/workflows/_terminal-test.yml
with:
runs-on: ubuntu-24.04
23 changes: 23 additions & 0 deletions .github/workflows/windows-x86-terminal-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Windows x86 Terminal Test

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

# One live run per workflow per ref: a new push to a branch or PR cancels the
# superseded run instead of queuing behind it in the shared account-wide runner
# pool. Runs on main get a unique group, so they are never cancelled or coalesced.
concurrency:
group: ${{ github.workflow }}-${{ github.ref == 'refs/heads/main' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
call:
uses: ./.github/workflows/_terminal-test.yml
with:
runs-on: windows-2025
# The interactive test drives a POSIX shell; ConPTY runs the slot-release
# regression only, selected here so the job reports no skipped tests.
pytest-args: "-k disconnect_with_blocked_stdin"
223 changes: 223 additions & 0 deletions ci/safari_terminal_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Real Safari smoke test for the interactive terminal (macOS only, #259).

Serves a compiled sketch with the shipped binary's headless server, then drives
Safari through safaridriver:

1. Four WebSocket clients open PTYs, make the foreground program stop reading
stdin, send more input than the terminal queue holds and disconnect. All four
terminal slots must come back well before that program (`sleep 30`) exits;
this is the #256 regression, observed from Safari's own WebSocket.
2. The page's terminal dialog connects, a typed command runs in the shell, and
its output reaches xterm's rendered rows.

Playwright WebKit on Linux covers the same engine in
`linux-x86-terminal-test.yml`; only this check proves Safari itself, which
CLAUDE.md makes a required target. A WebDriver screenshot of the terminal is
kept as an artifact.
"""

from __future__ import annotations

import argparse
import base64
import json
import sys
import time
from pathlib import Path

from safari_smoke import start_safaridriver, start_server, webdriver

MARKER_COMMAND = "printf 'SAFARI%s\\n' 240"
MARKER = "SAFARI240"
# The W3C WebDriver key code for Enter.
ENTER = "\ue007"

# WebDriver `execute/async` passes its completion callback as the last argument.
BLOCKED_WRITERS_SCRIPT = """
const done = arguments[arguments.length - 1];
(async () => {
const url = location.origin.replace('http:', 'ws:') + '/terminal/ws';
for (let i = 0; i < 4; i++) {
await new Promise((resolve, reject) => {
const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
const timer = setTimeout(() => { ws.close(); reject(new Error('PTY ready timeout')); }, 15000);
let output = '';
let blocked = false;
ws.onerror = () => { clearTimeout(timer); reject(new Error('upgrade rejected')); };
ws.onopen = () => ws.send(JSON.stringify({type: 'input', data:
"stty raw -echo; printf 'BLOCK%s\\\\n' READY240; sleep 30\\r"}));
ws.onmessage = event => {
if (!(event.data instanceof ArrayBuffer)) return;
ws.send(JSON.stringify({type: 'ack'}));
output += new TextDecoder().decode(event.data);
if (!blocked && output.includes('BLOCKREADY240')) {
blocked = true;
ws.send(JSON.stringify({type: 'input', data: 'x'.repeat(60000)}));
setTimeout(() => { clearTimeout(timer); ws.close(); resolve(true); }, 200);
}
};
});
}
const started = performance.now();
const deadline = started + 5000;
while (performance.now() < deadline) {
const sockets = [];
const opened = await Promise.all([0, 1, 2, 3].map(() => new Promise(resolve => {
const ws = new WebSocket(url);
sockets.push(ws);
ws.onopen = () => resolve(true);
ws.onerror = () => resolve(false);
})));
sockets.forEach(ws => ws.close());
if (opened.every(Boolean)) return {recoveredMs: performance.now() - started};
await new Promise(resolve => setTimeout(resolve, 100));
}
return {recoveredMs: null};
})().then(done, error => done({error: String(error)}));
"""

TERMINAL_STATE_SCRIPT = """
const status = document.getElementById('terminal-status');
const rows = document.querySelector('.xterm-rows');
return {
status: status ? status.textContent : null,
rows: rows ? rows.innerText : null,
userAgent: navigator.userAgent
};
"""


def find(session: str, selector: str) -> str:
value = webdriver(
"POST",
f"{session}/element",
{"using": "css selector", "value": selector},
)["value"]
return next(iter(value.values()))


def wait_for(session: str, predicate, timeout: float, what: str) -> dict:
deadline = time.monotonic() + timeout
state: dict = {}
while time.monotonic() < deadline:
state = webdriver(
"POST",
f"{session}/execute/sync",
{"script": TERMINAL_STATE_SCRIPT, "args": []},
)["value"]
if predicate(state):
return state
time.sleep(0.25)
raise RuntimeError(f"timed out waiting for {what}: {json.dumps(state)[-2000:]}")


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fastled", required=True)
parser.add_argument("--serve-dir", required=True)
parser.add_argument("--artifacts", required=True, type=Path)
args = parser.parse_args()
args.artifacts.mkdir(parents=True, exist_ok=True)

server, page_url = start_server(
args.fastled, args.serve_dir, args.artifacts / "safari-terminal-server.log"
)
driver, base = start_safaridriver()
session = None
try:
created = webdriver(
"POST",
f"{base}/session",
{"capabilities": {"alwaysMatch": {"browserName": "safari"}}},
)
session = f"{base}/session/{created['value']['sessionId']}"
webdriver("POST", f"{session}/timeouts", {"script": 90000})
webdriver("POST", f"{session}/url", {"url": page_url}, timeout=120)

# The blocked writers run before the dialog opens its own session, so
# all four slots are free for them.
blocked = webdriver(
"POST",
f"{session}/execute/async",
{"script": BLOCKED_WRITERS_SCRIPT, "args": []},
timeout=120,
)["value"]
print(f"Safari terminal: blocked writers -> {blocked}")
if blocked.get("error"):
print(f"Safari terminal setup failed: {blocked['error']}", file=sys.stderr)
return 1
if blocked.get("recoveredMs") is None:
print(
"Safari terminal: disconnected blocked writers leaked terminal slots",
file=sys.stderr,
)
return 1

# Give the check sockets' sessions a moment to release their slots.
time.sleep(1)
webdriver(
"POST", f"{session}/element/{find(session, '#terminal-open')}/click", {}
)
wait_for(
session,
lambda state: "Connected" in (state.get("status") or ""),
30,
"the terminal to connect",
)
# xterm's input textarea is deliberately off-screen, which WebDriver's
# element APIs can refuse as not interactable. Focus it and type as a user.
webdriver(
"POST",
f"{session}/execute/sync",
{
"script": "document.querySelector('.xterm-helper-textarea').focus();",
"args": [],
},
)
keys = []
for key in MARKER_COMMAND + ENTER:
keys += [{"type": "keyDown", "value": key}, {"type": "keyUp", "value": key}]
webdriver(
"POST",
f"{session}/actions",
{"actions": [{"type": "key", "id": "keyboard", "actions": keys}]},
)
webdriver("DELETE", f"{session}/actions")
state = wait_for(
session,
# The echoed command contains `SAFARI%s`; only the output line has
# the formatted marker.
lambda state: MARKER in (state.get("rows") or ""),
30,
f"{MARKER} in the rendered terminal",
)
png = base64.b64decode(webdriver("GET", f"{session}/screenshot")["value"])
(args.artifacts / "safari-terminal.png").write_bytes(png)
(args.artifacts / "safari-terminal-state.json").write_text(
json.dumps({"blocked": blocked, **state}, indent=2)
)
print(f"Safari terminal: {state['userAgent']}")
print(f"Safari terminal: rendered {MARKER}; status={state['status']!r}")
return 0
finally:
if session:
try:
png = base64.b64decode(
webdriver("GET", f"{session}/screenshot")["value"]
)
shot = args.artifacts / "safari-terminal.png"
if not shot.exists():
shot.write_bytes(png)
except (OSError, RuntimeError, ValueError, KeyError):
pass
try:
webdriver("DELETE", session, timeout=30)
except (OSError, RuntimeError):
pass
driver.terminate()
server.terminate()


if __name__ == "__main__":
sys.exit(main())
6 changes: 6 additions & 0 deletions ci/smoke_installed_wheel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,10 @@ if [ "${FASTLED_SAFARI_SMOKE:-0}" = "1" ]; then
--fastled "$SMOKE_ROOT/venv/bin/fastled" \
--serve-dir "$SMOKE_ROOT/sketch/fastled_js" \
--artifacts "$SMOKE_ROOT/artifacts"
# The same server binary and page, with the interactive terminal driven from
# real Safari (#259).
uv run --no-project python "$GITHUB_WORKSPACE/ci/safari_terminal_smoke.py" \
--fastled "$SMOKE_ROOT/venv/bin/fastled" \
--serve-dir "$SMOKE_ROOT/sketch/fastled_js" \
--artifacts "$SMOKE_ROOT/artifacts"
fi
Loading
Loading