diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..01dcfd1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,38 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-${{ matrix.ubuntu }}
+ strategy:
+ matrix:
+ ubuntu: ["22.04", "24.04"]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install test deps
+ run: |
+ pip install pytest
+ sudo apt-get install -y bats shellcheck inkscape
+
+ - name: Python unit tests
+ run: pytest tests/test_merge_prefs.py -v
+
+ - name: Shellcheck
+ run: |
+ shellcheck install.sh
+ shellcheck uninstall.sh
+ shellcheck scripts/detect_platform.sh
+
+ - name: Bats integration tests
+ run: bats tests/test_install.bats
diff --git a/tests/test_install.bats b/tests/test_install.bats
new file mode 100644
index 0000000..2522352
--- /dev/null
+++ b/tests/test_install.bats
@@ -0,0 +1,135 @@
+#!/usr/bin/env bats
+# Integration tests for install.sh and uninstall.sh
+# Requires: bats-core (https://github.com/bats-core/bats-core)
+
+REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
+
+setup() {
+ # Each test gets a fresh temp home directory
+ export TEST_HOME
+ TEST_HOME="$(mktemp -d)"
+ export HOME="$TEST_HOME"
+ export XDG_CONFIG_HOME="$TEST_HOME/.config"
+
+ # Inject a fake inkscape so check_deps passes without the real binary
+ local fake_bin="$TEST_HOME/bin"
+ mkdir -p "$fake_bin"
+ printf '#!/bin/sh\necho "Inkscape 1.3.2"\n' > "$fake_bin/inkscape"
+ chmod +x "$fake_bin/inkscape"
+ export PATH="$fake_bin:$PATH"
+}
+
+teardown() {
+ rm -rf "$TEST_HOME"
+}
+
+_run_install() {
+ bash "$REPO_ROOT/install.sh" --preset="${1:-cc}" --yes 2>&1
+}
+
+_config_root() {
+ echo "$TEST_HOME/.config/inkscape"
+}
+
+# ── Backup ────────────────────────────────────────────────────────────────────
+
+@test "backup is created on first install" {
+ mkdir -p "$(_config_root)"
+ echo '' > "$(_config_root)/preferences.xml"
+ _run_install cc
+ local backup
+ backup=$(ls -1d "$(_config_root)".bak-illuscape-* 2>/dev/null | head -1)
+ [ -n "$backup" ]
+ [ -f "$backup/preferences.xml" ]
+}
+
+@test "second install does not overwrite backup" {
+ mkdir -p "$(_config_root)"
+ echo '' > "$(_config_root)/preferences.xml"
+ _run_install cc
+ sleep 1
+ _run_install cc
+ local backup_count
+ backup_count=$(ls -1d "$(_config_root)".bak-illuscape-* 2>/dev/null | wc -l)
+ [ "$backup_count" -eq 1 ]
+}
+
+# ── Keys ──────────────────────────────────────────────────────────────────────
+
+@test "CC preset installs illustrator-cc.xml" {
+ _run_install cc
+ [ -f "$(_config_root)/keys/illustrator-cc.xml" ]
+}
+
+@test "CS6 preset installs illustrator-cs6.xml" {
+ _run_install cs6
+ [ -f "$(_config_root)/keys/illustrator-cs6.xml" ]
+}
+
+@test "CC preset does not install cs6 key file" {
+ _run_install cc
+ [ ! -f "$(_config_root)/keys/illustrator-cs6.xml" ]
+}
+
+# ── Palettes ──────────────────────────────────────────────────────────────────
+
+@test "all palette files are installed" {
+ _run_install cc
+ [ -f "$(_config_root)/palettes/Illustrator-Defaults.gpl" ]
+ [ -f "$(_config_root)/palettes/Illustrator-Grays.gpl" ]
+ [ -f "$(_config_root)/palettes/Illustrator-Earth.gpl" ]
+}
+
+# ── Templates ─────────────────────────────────────────────────────────────────
+
+@test "all template files are installed" {
+ _run_install cc
+ [ -f "$(_config_root)/templates/Letter.svg" ]
+ [ -f "$(_config_root)/templates/A4.svg" ]
+ [ -f "$(_config_root)/templates/Web-1920x1080.svg" ]
+ [ -f "$(_config_root)/templates/Web-1280x720.svg" ]
+ [ -f "$(_config_root)/templates/Print-CMYK-Letter.svg" ]
+ [ -f "$(_config_root)/templates/Print-CMYK-A4.svg" ]
+}
+
+# ── Preferences merge ─────────────────────────────────────────────────────────
+
+@test "preferences.xml is created when missing" {
+ _run_install cc
+ [ -f "$(_config_root)/preferences.xml" ]
+}
+
+@test "CC preset sets px units in preferences.xml" {
+ _run_install cc
+ grep -q 'doc="px"' "$(_config_root)/preferences.xml"
+}
+
+@test "CS6 preset sets pt units in preferences.xml" {
+ _run_install cs6
+ grep -q 'doc="pt"' "$(_config_root)/preferences.xml"
+}
+
+# ── Noninteractive ────────────────────────────────────────────────────────────
+
+@test "--preset flag skips interactive prompt" {
+ # If prompt were shown, it would hang — the test itself proves it didn't
+ run bash "$REPO_ROOT/install.sh" --preset=cc --yes
+ [ "$status" -eq 0 ]
+}
+
+# ── Uninstall ─────────────────────────────────────────────────────────────────
+
+@test "uninstall restores preferences.xml from backup" {
+ mkdir -p "$(_config_root)"
+ echo '' \
+ > "$(_config_root)/preferences.xml"
+ _run_install cc
+ bash "$REPO_ROOT/uninstall.sh"
+ grep -q 'id="original"' "$(_config_root)/preferences.xml"
+}
+
+@test "uninstall removes palette files" {
+ _run_install cc
+ bash "$REPO_ROOT/uninstall.sh"
+ [ ! -f "$(_config_root)/palettes/Illustrator-Defaults.gpl" ]
+}