diff --git a/.github/workflows/devcontainer-e2e.yml b/.github/workflows/devcontainer-e2e.yml new file mode 100644 index 0000000..76bd6b9 --- /dev/null +++ b/.github/workflows/devcontainer-e2e.yml @@ -0,0 +1,48 @@ +name: Devcontainer E2E + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +jobs: + devcontainer-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: 'true' + - name: Generate project via Copier + run: | + uvx copier copy --defaults --force --trust \ + --data author="Switchbox" \ + --data email="hello@switch.box" \ + --data author_github_handle="switchbox-data" \ + --data project_name="devcontainer-e2e" \ + --data project_description="E2E devcontainer test" \ + --data project_features="[python_package]" \ + --data use_github=false \ + --data open_source_license="MIT license" \ + --data aws=false \ + . tmp/devcontainer-e2e + - name: Build and test devcontainer + uses: devcontainers/ci@v0.3 + with: + subFolder: tmp/devcontainer-e2e + runCmd: | + zsh -lc 'zsh --version' + zsh -lc 'test -d $HOME/.oh-my-zsh && echo OK-ohmyzsh' + zsh -lc "grep -E 'plugins=.*zsh-autosuggestions' $HOME/.zshrc && echo OK-autosuggestions" + zsh -lc "grep -E 'plugins=.*zsh-completions' $HOME/.zshrc && echo OK-completions" + zsh -lc "grep -E 'plugins=.*zsh-syntax-highlighting' $HOME/.zshrc && echo OK-syntax" + zsh -lc "grep -E 'plugins=.*colored-man-pages' $HOME/.zshrc && echo OK-colored-man-pages" + zsh -lc "grep -E 'plugins=.*colorize' $HOME/.zshrc && echo OK-colorize" + zsh -lc "grep -E 'plugins=.*history' $HOME/.zshrc && echo OK-history" + zsh -lc 'alias ll && alias la && alias l && echo OK-aliases' diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index 41a1a58..01d4d29 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -2,7 +2,12 @@ "name": "{{ project_name }}", "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "features": { - "ghcr.io/guiyomh/features/just:0.1.0": { "version": "1.42.4" }{% if python %}, + "ghcr.io/guiyomh/features/just:0.1.0": { "version": "1.42.4" }, + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": true, + "installOhMyZsh": true, + "configureZshAsDefaultShell": true + }{% if python %}, "ghcr.io/devcontainers/features/python:1": { "version": "os-provided", "installTools": "false" diff --git a/template/.devcontainer/postCreateCommand.sh.jinja b/template/.devcontainer/postCreateCommand.sh.jinja index a40caff..c1b20e1 100644 --- a/template/.devcontainer/postCreateCommand.sh.jinja +++ b/template/.devcontainer/postCreateCommand.sh.jinja @@ -1,3 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Ensure Oh My Zsh is installed (feature should have installed it; this is a safe fallback) +export RUNZSH=no +export CHSH=no +export KEEP_ZSHRC=yes +if [ ! -d "$HOME/.oh-my-zsh" ]; then + if command -v curl >/dev/null 2>&1; then + sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" + elif command -v wget >/dev/null 2>&1; then + sh -c "$(wget -O- https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" + fi +fi + +# Install key Oh My Zsh plugins +ZSH_CUSTOM_DIR="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}" +mkdir -p "$ZSH_CUSTOM_DIR/plugins" + +if [ ! -d "$ZSH_CUSTOM_DIR/plugins/zsh-autosuggestions" ]; then + git clone https://github.com/zsh-users/zsh-autosuggestions "$ZSH_CUSTOM_DIR/plugins/zsh-autosuggestions" +fi +if [ ! -d "$ZSH_CUSTOM_DIR/plugins/zsh-completions" ]; then + git clone https://github.com/zsh-users/zsh-completions "$ZSH_CUSTOM_DIR/plugins/zsh-completions" +fi +if [ ! -d "$ZSH_CUSTOM_DIR/plugins/zsh-syntax-highlighting" ]; then + git clone https://github.com/zsh-users/zsh-syntax-highlighting "$ZSH_CUSTOM_DIR/plugins/zsh-syntax-highlighting" +fi + +# Ensure required plugins are enabled in ~/.zshrc +required_plugins=(git colored-man-pages colorize history zsh-autosuggestions zsh-completions zsh-syntax-highlighting) +if [ -f "$HOME/.zshrc" ]; then + # Replace plugins=() line if present, otherwise append a new line + if grep -qE '^plugins=\(' "$HOME/.zshrc"; then + sed -i 's/^plugins=.*/plugins=(git colored-man-pages colorize history zsh-autosuggestions zsh-completions zsh-syntax-highlighting)/' "$HOME/.zshrc" + else + printf '\nplugins=(git colored-man-pages colorize history zsh-autosuggestions zsh-completions zsh-syntax-highlighting)\n' >> "$HOME/.zshrc" + fi +else + printf 'plugins=(git colored-man-pages colorize history zsh-autosuggestions zsh-completions zsh-syntax-highlighting)\n' > "$HOME/.zshrc" +fi + +# Add common ls aliases if not already present +append_if_missing() { + local line="$1" + local file="$2" + grep -qxF "$line" "$file" || echo "$line" >> "$file" +} + +append_if_missing "alias ll='ls -alF'" "$HOME/.zshrc" +append_if_missing "alias la='ls -A'" "$HOME/.zshrc" +append_if_missing "alias l='ls -CF'" "$HOME/.zshrc" + +{% if python %} +# Python developer tooling +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh +fi +export PATH="$HOME/.local/bin:$PATH" +uv sync --group dev + +# Install pre-commit hooks (prek convenience wrapper) +if command -v prek >/dev/null 2>&1; then + prek install --install-hooks +else + # Fallback to pre-commit if prek is unavailable + if command -v pre-commit >/dev/null 2>&1; then + pre-commit install --install-hooks + fi +fi +{% endif %} + +echo "postCreateCommand completed." + #!/usr/bin/env bash # This script is for Linux/macOS and devcontainer usage diff --git a/tests/test_devcontainer_e2e.py b/tests/test_devcontainer_e2e.py new file mode 100644 index 0000000..0f31e02 --- /dev/null +++ b/tests/test_devcontainer_e2e.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + + +def run_copier(template_dir: Path, dest: Path, data: dict[str, str]) -> subprocess.CompletedProcess: + args = [ + "uvx", + "copier", + "copy", + "--defaults", + "--force", + "--trust", + ] + for k, v in data.items(): + args.extend(["--data", f"{k}={v}"]) + args.extend([str(template_dir), str(dest)]) + return subprocess.run(args, check=False, capture_output=True, text=True) + + +def _has_docker() -> bool: + return ( + shutil.which("docker") is not None and subprocess.run(["docker", "info"], capture_output=True).returncode == 0 + ) + + +def _has_devcontainer_cli() -> bool: + return shutil.which("devcontainer") is not None + + +def test_devcontainer_ohmyzsh_plugins(tmp_path: Path) -> None: + if not _has_docker() or not _has_devcontainer_cli(): + # Skip if local environment doesn't support container tests; covered in CI separately + import pytest + + pytest.skip("Docker or devcontainer CLI not available") + + dest = tmp_path / "e2e" + res = run_copier( + Path(__file__).parents[1], + dest, + { + "author": "Switchbox", + "email": "hello@switch.box", + "author_github_handle": "switchbox-data", + "project_name": "e2e-proj", + "project_description": "Devcontainer E2E", + "project_features": "[python_package]", + "use_github": False, + "open_source_license": "MIT license", + "aws": False, + }, + ) + assert res.returncode == 0, res.stderr + + # Build and start the devcontainer + up = subprocess.run( + [ + "devcontainer", + "up", + "--workspace-folder", + str(dest), + ], + capture_output=True, + text=True, + ) + assert up.returncode == 0, up.stderr + + # Verify zsh and plugins inside the container + checks = [ + "zsh --version", + "test -d $HOME/.oh-my-zsh", + "grep -E 'plugins=.*zsh-autosuggestions' $HOME/.zshrc", + "grep -E 'plugins=.*zsh-completions' $HOME/.zshrc", + "grep -E 'plugins=.*zsh-syntax-highlighting' $HOME/.zshrc", + "grep -E 'plugins=.*colored-man-pages' $HOME/.zshrc", + "grep -E 'plugins=.*colorize' $HOME/.zshrc", + "grep -E 'plugins=.*history' $HOME/.zshrc", + "alias ll", + "alias la", + "alias l", + ] + for cmd in checks: + r = subprocess.run( + [ + "devcontainer", + "exec", + "--workspace-folder", + str(dest), + "zsh", + "-lc", + cmd, + ], + capture_output=True, + text=True, + ) + assert r.returncode == 0, f"Command failed: {cmd}\nstdout: {r.stdout}\nstderr: {r.stderr}" diff --git a/tests/test_template.py b/tests/test_template.py index 5a4476b..954b425 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -153,6 +153,18 @@ def test_python_mkdocs_only(tmp_path: Path) -> None: assert_file_contains(dest, ".devcontainer/postCreateCommand.sh", "curl -LsSf https://astral.sh/uv/install.sh | sh") assert_file_contains(dest, ".devcontainer/postCreateCommand.sh", "uv sync --group dev") assert_file_contains(dest, ".devcontainer/postCreateCommand.sh", "prek install --install-hooks") + # Check that oh-my-zsh plugins are configured + for p in [ + "zsh-autosuggestions", + "zsh-completions", + "zsh-syntax-highlighting", + "colored-man-pages", + "colorize", + "history", + ]: + assert_file_contains(dest, ".devcontainer/postCreateCommand.sh", p) + # Verify common-utils feature installs zsh/oh-my-zsh + assert_file_contains(dest, ".devcontainer/devcontainer.json", "ghcr.io/devcontainers/features/common-utils") # Check that Justfile contains documentation commands (python_package boolean bug) assert_file_contains(dest, "Justfile", "# 📚 DOCUMENTATION") assert_file_contains(dest, "Justfile", "docs:")