diff --git a/.github/workflows/build-tauri.yml b/.github/workflows/build-tauri.yml index 41f9c532f..dbe499f28 100644 --- a/.github/workflows/build-tauri.yml +++ b/.github/workflows/build-tauri.yml @@ -126,6 +126,15 @@ jobs: python3 -m venv venv source venv/bin/activate || source venv/Scripts/activate poetry install + + echo "========================================" + echo "Running Doctor Check (fail-fast)" + echo "========================================" + make doctor + + echo "========================================" + echo "Starting Build" + echo "========================================" make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }} pip freeze @@ -143,7 +152,15 @@ jobs: run: | source venv/bin/activate || source venv/Scripts/activate poetry install - make package SKIP_SERVER_RUST=${{ matrix.skip_rust }} + + echo "========================================" + echo "Running package with STRICT mode (CI)" + echo "========================================" + echo "TAURI_BUILD=true PACKAGE_STRICT=true make package SKIP_SERVER_RUST=${{ matrix.skip_rust }}" + echo "" + + # Use PACKAGE_STRICT=true for CI - fails on any verification errors + TAURI_BUILD=true PACKAGE_STRICT=true make package SKIP_SERVER_RUST=${{ matrix.skip_rust }} - name: Package dmg if: runner.os == 'macOS' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 45078c1c3..438374eb0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -132,6 +132,15 @@ jobs: python3 -m venv venv source venv/bin/activate || source venv/Scripts/activate poetry install + + echo "========================================" + echo "Running Doctor Check (fail-fast)" + echo "========================================" + make doctor + + echo "========================================" + echo "Starting Build" + echo "========================================" make build SKIP_WEBUI=${{ matrix.skip_webui }} SKIP_SERVER_RUST=${{ matrix.skip_rust }} pip freeze # output Python packages, useful for debugging dependency versions @@ -151,7 +160,15 @@ jobs: run: | source venv/bin/activate || source venv/Scripts/activate poetry install # run again to ensure we have the correct version of PyInstaller - make package SKIP_SERVER_RUST=${{ matrix.skip_rust }} + + echo "========================================" + echo "Running package with STRICT mode (CI)" + echo "========================================" + echo "PACKAGE_STRICT=true make package SKIP_SERVER_RUST=${{ matrix.skip_rust }}" + echo "" + + # Use PACKAGE_STRICT=true for CI - fails on any verification errors + PACKAGE_STRICT=true make package SKIP_SERVER_RUST=${{ matrix.skip_rust }} - name: Package dmg if: runner.os == 'macOS' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8d771ad55..12e1ccda2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,25 @@ on: #pull_request: # branches: [ master ] workflow_dispatch: - + inputs: + skip_build: + description: "Skip build and use downloaded binary instead" + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' + version: + description: "Version to download when skip_build=true (e.g., v0.14.0)" + required: false + default: 'latest' + type: string + test_timeout: + description: "Global test timeout in seconds" + required: false + default: '300' + type: string jobs: @@ -173,3 +191,256 @@ jobs: echo "\n---\n" cat log-new.txt || true + + # Integration tests using make test-integration + integration: + name: Integration (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + python_version: ['3.12'] + + env: + TEST_TIMEOUT: ${{ inputs.test_timeout || '300' }} + AW_SERVER_TIMEOUT: '60' + AW_PYTEST_TIMEOUT: '120' + AW_LOG_LINES: '200' + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: 'recursive' + fetch-depth: 0 + + - name: Print environment info + run: | + echo "OS: ${{ matrix.os }}" + echo "Runner: $RUNNER_OS" + echo "Architecture: $(uname -m)" + echo "Python version: ${{ matrix.python_version }}" + echo "" + echo "Test Configuration:" + echo " Global timeout: ${TEST_TIMEOUT}s" + echo " Server startup timeout: ${AW_SERVER_TIMEOUT}s" + echo " Pytest timeout: ${AW_PYTEST_TIMEOUT}s" + echo " Log lines on failure: ${AW_LOG_LINES}" + echo " Skip build: ${{ inputs.skip_build }}" + echo " Version: ${{ inputs.version }}" + + - name: Install coreutils (macOS) + if: runner.os == 'macOS' + run: | + brew install coreutils + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python_version }} + + - name: Set up Rust + if: ${{ inputs.skip_build != 'true' }} + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Install Poetry + run: | + pip3 install poetry==1.4.2 + + - name: Create virtual environment + run: | + python3 -m venv venv + source venv/bin/activate + poetry install + + - name: Download ActivityWatch binary (skip_build=true) + if: ${{ inputs.skip_build == 'true' }} + shell: bash + run: | + set -e + mkdir -p dist + cd dist + + OS=${{ matrix.os }} + if [ "$OS" = "ubuntu-latest" ] || [ "$OS" = "Linux" ]; then + PLATFORM="linux" + elif [ "$OS" = "macos-latest" ] || [ "$OS" = "macOS" ]; then + PLATFORM="macos" + else + echo "ERROR: Unsupported OS: $OS" + exit 1 + fi + + ARCH=$(uname -m) + if [ "$ARCH" = "x86_64" ]; then + ARCH="x86_64" + elif [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then + ARCH="arm64" + fi + + VERSION="${{ inputs.version }}" + if [ "$VERSION" = "latest" ] || [ -z "$VERSION" ]; then + echo "Fetching latest release..." + LATEST_JSON=$(curl -s https://api.github.com/repos/ActivityWatch/activitywatch/releases/latest) + VERSION=$(echo "$LATEST_JSON" | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4) + echo "Latest version: $VERSION" + fi + + # Remove 'v' prefix for filename if present + VERSION_NO_V=$(echo "$VERSION" | sed -e 's/^v//') + + ZIP_FILE="activitywatch-${VERSION_NO_V}-${PLATFORM}-${ARCH}.zip" + DOWNLOAD_URL="https://github.com/ActivityWatch/activitywatch/releases/download/${VERSION}/${ZIP_FILE}" + + echo "Platform: $PLATFORM" + echo "Arch: $ARCH" + echo "Version: $VERSION" + echo "Version (no v): $VERSION_NO_V" + echo "Download URL: $DOWNLOAD_URL" + + echo "" + echo "Downloading $ZIP_FILE..." + curl -f -L -o "$ZIP_FILE" "$DOWNLOAD_URL" + + echo "" + echo "Unzipping..." + unzip -q "$ZIP_FILE" + ls -la activitywatch/ + + echo "" + echo "Done. Binary location: $(pwd)/activitywatch/" + + - name: Build ActivityWatch (skip_build=false) + if: ${{ inputs.skip_build != 'true' }} + run: | + source venv/bin/activate + make build SKIP_WEBUI=true SKIP_SERVER_RUST=false + ls -la dist/ + + - name: Find server binary + shell: bash + run: | + set -e + echo "Looking for server binary..." + echo "" + + SERVER_BIN="" + + # Try aw-server-rust first (most likely for Tauri build) + if [ -f "dist/activitywatch/aw-server-rust/aw-server" ]; then + SERVER_BIN="dist/activitywatch/aw-server-rust/aw-server" + elif [ -f "dist/activitywatch/aw-server-rust/aw-server.exe" ]; then + SERVER_BIN="dist/activitywatch/aw-server-rust/aw-server.exe" + # Try aw-server (Python) + elif [ -f "dist/activitywatch/aw-server/aw-server" ]; then + SERVER_BIN="dist/activitywatch/aw-server/aw-server" + elif [ -f "dist/activitywatch/aw-server/aw-server.exe" ]; then + SERVER_BIN="dist/activitywatch/aw-server/aw-server.exe" + fi + + if [ -z "$SERVER_BIN" ]; then + echo "ERROR: Could not find server binary" + echo "" + echo "Contents of dist/:" + ls -la dist/ 2>/dev/null || echo "(dist/ not found)" + echo "" + echo "Contents of dist/activitywatch/ (if exists):" + ls -la dist/activitywatch/ 2>/dev/null || echo "(dist/activitywatch/ not found)" + exit 1 + fi + + echo "Found server binary: $SERVER_BIN" + echo "AW_SERVER_BIN=${SERVER_BIN}" >> $GITHUB_ENV + + - name: Determine and output version + run: | + source venv/bin/activate + + TAG_VERSION=$(bash scripts/package/getversion.sh --tag) + DISPLAY_VERSION=$(bash scripts/package/getversion.sh --display) + + echo "TAG_VERSION=${TAG_VERSION}" >> $GITHUB_ENV + echo "DISPLAY_VERSION=${DISPLAY_VERSION}" >> $GITHUB_ENV + + echo "========================================" + echo "Build Version Information" + echo "========================================" + echo "GitHub ref: ${{ github.ref }}" + echo "GitHub ref_name: ${{ github.ref_name }}" + echo "TAG_VERSION: ${TAG_VERSION}" + echo "DISPLAY_VERSION: ${DISPLAY_VERSION}" + echo "========================================" + + - name: Run integration tests + id: integration_tests + shell: bash + timeout-minutes: 10 + run: | + source venv/bin/activate + + echo "========================================" + echo "Running Integration Tests" + echo "========================================" + echo "Server binary: $AW_SERVER_BIN" + echo "Server port: ${AW_SERVER_PORT:-5666}" + echo "Server timeout: ${AW_SERVER_TIMEOUT}s" + echo "Global test timeout: ${TEST_TIMEOUT}s" + echo "" + + export AW_SERVER_BIN="$AW_SERVER_BIN" + export AW_SERVER_TIMEOUT="$AW_SERVER_TIMEOUT" + export AW_LOG_LINES="$AW_LOG_LINES" + export AW_TEST_TIMEOUT="$TEST_TIMEOUT" + export AW_PYTEST_TIMEOUT="$AW_PYTEST_TIMEOUT" + + # Run tests with timeout protection + # Using timeout/gtimeout with SIGKILL as last resort + # Also using pytest-timeout as per-test protection + + if [ "$RUNNER_OS" = "Linux" ]; then + timeout --signal=SIGKILL "$TEST_TIMEOUT" \ + pytest scripts/tests/integration_tests.py -v \ + --timeout="$AW_PYTEST_TIMEOUT" \ + -o timeout_method=thread + elif [ "$RUNNER_OS" = "macOS" ]; then + gtimeout --signal=SIGKILL "$TEST_TIMEOUT" \ + pytest scripts/tests/integration_tests.py -v \ + --timeout="$AW_PYTEST_TIMEOUT" \ + -o timeout_method=thread + else + # Windows (though we're not testing Windows yet) + pytest scripts/tests/integration_tests.py -v \ + --timeout="$AW_PYTEST_TIMEOUT" \ + -o timeout_method=thread + fi + + - name: Output test result + if: always() + run: | + echo "========================================" + echo "Integration Tests Result" + echo "========================================" + echo "OS: ${{ matrix.os }}" + echo "Python: ${{ matrix.python_version }}" + echo "" + echo "Test Status (from step.outcome): ${{ steps.integration_tests.outcome }}" + echo "" + if [ "${{ steps.integration_tests.outcome }}" = "success" ]; then + echo "✅ Tests PASSED" + elif [ "${{ steps.integration_tests.outcome }}" = "failure" ]; then + echo "❌ Tests FAILED" + elif [ "${{ steps.integration_tests.outcome }}" = "cancelled" ]; then + echo "⏹️ Tests CANCELLED (possibly timeout)" + else + echo "⚠️ Tests: ${{ steps.integration_tests.outcome }}" + fi + echo "" + echo "Timeout protection in place:" + echo " - Global: ${TEST_TIMEOUT}s (via timeout/gtimeout)" + echo " - Per-test: ${AW_PYTEST_TIMEOUT}s (via pytest-timeout)" + echo "========================================" + diff --git a/Makefile b/Makefile index bb55df27f..2c1f662d6 100644 --- a/Makefile +++ b/Makefile @@ -7,12 +7,226 @@ # # We recommend creating and activating a Python virtualenv before building. # Instructions on how to do this can be found in the guide linked above. -.PHONY: build install test clean clean_all +.PHONY: build install test clean clean_all doctor venv-check SHELL := /usr/bin/env bash OS := $(shell uname -s) +# ===================================== +# Helper Functions & Checks +# ===================================== + +# Check if running in a Python virtual environment +# Returns: 0 if in venv, 1 otherwise +# Usage: $(call in_venv) +define in_venv +$(shell python3 -c "import sys; print(1 if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) else 0)" 2>/dev/null || echo 0) +endef + +# Get Python version (major.minor) +# Usage: $(call python_version) +define python_version +$(shell python3 --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1,2) +endef + +# Get setuptools version +# Usage: $(call setuptools_version) +define setuptools_version +$(shell python3 -c "import setuptools; print(setuptools.__version__)" 2>/dev/null || echo "0.0.0") +endef + +# Compare versions (returns 1 if v1 > v2, 0 otherwise) +# Usage: $(call version_gt,,) +define version_gt +$(shell python3 -c "from packaging.version import Version; print(1 if Version('$1') > Version('$2') else 0)" 2>/dev/null || python3 -c "import sys; print(1 if tuple(map(int, '$1'.split('.'))) > tuple(map(int, '$2'.split('.'))) else 0)" 2>/dev/null || echo 0) +endef + +# ===================================== +# Virtual Environment Check +# ===================================== + +VENVS := .venv venv +ACTIVE_VENV := $(firstword $(wildcard $(VENVS))) + +venv-check: + @echo "Checking virtual environment..." + @IN_VENV=$(call in_venv); \ + if [ "$$IN_VENV" != "1" ]; then \ + echo ""; \ + echo "==========================================================================="; \ + echo "[ERROR] Not running in a Python virtual environment!"; \ + echo "==========================================================================="; \ + echo ""; \ + echo "Running make build outside a venv can:"; \ + echo " - Pollute your global Python environment"; \ + echo " - Cause version conflicts with other packages"; \ + echo " - Require root/sudo privileges"; \ + echo ""; \ + echo "Recommended setup:"; \ + echo ""; \ + echo " # Create a virtual environment (do this once)"; \ + if [ -n "$(ACTIVE_VENV)" ]; then \ + echo " python3 -m venv $(ACTIVE_VENV) # <-- already exists"; \ + else \ + echo " python3 -m venv .venv"; \ + fi; \ + echo ""; \ + echo " # Activate the virtual environment (do this in every new shell)"; \ + if [ "$$(basename $$SHELL)" = "fish" ]; then \ + echo " source .venv/bin/activate.fish # for fish shell"; \ + else \ + echo " source .venv/bin/activate # for bash/zsh"; \ + fi; \ + echo ""; \ + echo " # Then run:"; \ + echo " make build"; \ + echo ""; \ + echo "==========================================================================="; \ + echo ""; \ + echo "If you KNOW what you're doing and want to skip this check:"; \ + echo " export SKIP_VENV_CHECK=1"; \ + echo " make build"; \ + echo ""; \ + echo "==========================================================================="; \ + exit 1; \ + else \ + echo " ✓ Running in virtual environment"; \ + echo " VIRTUAL_ENV: $$VIRTUAL_ENV"; \ + fi; \ + echo "" + +# ===================================== +# Doctor: Check all dependencies +# ===================================== + +doctor: venv-check + @echo "===========================================================================" + @echo "ActivityWatch Build Environment Doctor" + @echo "===========================================================================" + @echo "" + @echo "--- Python Environment ---" + @echo "" + @echo -n " Python: " + @if command -v python3 >/dev/null 2>&1; then \ + PY_VER=$$(python3 --version 2>&1); \ + echo "✓ $$PY_VER"; \ + else \ + echo "✗ python3 not found in PATH"; \ + ERRORS=1; \ + fi + + @echo -n " pip: " + @if python3 -m pip --version >/dev/null 2>&1; then \ + PIP_VER=$$(python3 -m pip --version 2>&1 | cut -d' ' -f2); \ + echo "✓ $$PIP_VER"; \ + else \ + echo "✗ pip not available"; \ + ERRORS=1; \ + fi + + @echo -n " poetry: " + @if command -v poetry >/dev/null 2>&1; then \ + POETRY_VER=$$(poetry --version 2>&1 | sed 's/.*version \([0-9.]*\).*/\1/'); \ + echo "✓ $$POETRY_VER"; \ + else \ + echo "✗ poetry not found in PATH"; \ + echo " Install with: pip3 install poetry==1.4.2"; \ + ERRORS=1; \ + fi + + @echo -n " setuptools: " + @SETUPTOOLS_VER=$(call setuptools_version); \ + if [ -n "$$SETUPTOOLS_VER" ] && [ "$$SETUPTOOLS_VER" != "0.0.0" ]; then \ + echo "✓ $$SETUPTOOLS_VER"; \ + NEEDS_UPDATE=$(call version_gt,49.1.1,$$SETUPTOOLS_VER); \ + if [ "$$NEEDS_UPDATE" = "1" ]; then \ + echo " ⚠ Version <= 49.1.1, may cause issues (see: pypa/setuptools#1963)"; \ + echo " Will be automatically updated during make build"; \ + fi; \ + else \ + echo "✗ Could not determine setuptools version"; \ + ERRORS=1; \ + fi + + @echo "" + @echo "--- Node.js Environment (for web UI) ---" + @echo "" + @echo -n " node: " + @if command -v node >/dev/null 2>&1; then \ + NODE_VER=$$(node --version 2>&1); \ + echo "✓ $$NODE_VER"; \ + else \ + echo "⚠ node not found in PATH (only needed for web UI build)"; \ + echo " SKIP_WEBUI=true can be used to skip web UI build"; \ + fi + + @echo -n " npm: " + @if command -v npm >/dev/null 2>&1; then \ + NPM_VER=$$(npm --version 2>&1); \ + echo "✓ $$NPM_VER"; \ + else \ + echo "⚠ npm not found (only needed for web UI build)"; \ + fi + + @echo "" + @echo "--- Rust Environment (for aw-server-rust) ---" + @echo "" + @echo -n " rustc: " + @if command -v rustc >/dev/null 2>&1; then \ + RUST_VER=$$(rustc --version 2>&1); \ + echo "✓ $$RUST_VER"; \ + else \ + echo "⚠ rustc not found in PATH (only needed for aw-server-rust)"; \ + echo " SKIP_SERVER_RUST=true can be used to skip Rust build"; \ + fi + + @echo -n " cargo: " + @if command -v cargo >/dev/null 2>&1; then \ + CARGO_VER=$$(cargo --version 2>&1 | cut -d' ' -f2); \ + echo "✓ $$CARGO_VER"; \ + else \ + echo "⚠ cargo not found (only needed for aw-server-rust)"; \ + fi + + @echo "" + @echo "--- Git Submodules ---" + @echo "" + @for module in aw-core aw-client aw-server; do \ + echo -n " $$module: "; \ + if [ -d "$$module/.git" ]; then \ + echo "✓ initialized"; \ + else \ + echo "✗ not initialized"; \ + echo " Run: git submodule update --init --recursive"; \ + ERRORS=1; \ + fi; \ + done + + @echo "" + @echo "===========================================================================" + @echo "Summary" + @echo "===========================================================================" + @echo "" + @echo "Virtual environment: ✓ Active" + @echo " Path: $$VIRTUAL_ENV" + @echo "" + @if [ -n "$$ERRORS" ]; then \ + echo "❌ Some issues found. Please fix them before building."; \ + exit 1; \ + else \ + echo "✅ All required dependencies look good!"; \ + echo ""; \ + echo " You can now run:"; \ + echo " make build"; \ + echo ""; \ + echo " Or with options:"; \ + echo " make build SKIP_WEBUI=true"; \ + echo " make build SKIP_SERVER_RUST=true"; \ + echo " make build RELEASE=true"; \ + echo ""; \ + fi + ifeq ($(TAURI_BUILD),true) SUBMODULES := aw-core aw-client aw-server aw-server-rust aw-watcher-afk aw-watcher-window aw-tauri # Include awatcher on Linux (Wayland-compatible window watcher) @@ -63,23 +277,65 @@ endif # What it does: # - Installs all the Python modules # - Builds the web UI and bundles it with aw-server -build: aw-core/.git -# needed due to https://github.com/pypa/setuptools/issues/1963 -# would ordinarily be specified in pyproject.toml, but is not respected due to https://github.com/pypa/setuptools/issues/1963 - pip install 'setuptools>49.1.1' - for module in $(SUBMODULES); do \ +build: build-pre-check aw-core/.git + @echo "===========================================================================" + @echo "Building ActivityWatch" + @echo "===========================================================================" + @echo "" + @echo "Configuration:" + @echo " RELEASE: $(RELEASE)" + @echo " TAURI_BUILD: $(TAURI_BUILD)" + @echo " SKIP_WEBUI: $(SKIP_WEBUI)" + @echo " SKIP_SERVER_RUST: $(SKIP_SERVER_RUST)" + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "Checking setuptools version..." + @SETUPTOOLS_VER=$(call setuptools_version); \ + NEEDS_UPDATE=$(call version_gt,49.1.1,$$SETUPTOOLS_VER); \ + if [ "$$NEEDS_UPDATE" = "1" ]; then \ + echo " ⚠ setuptools version $$SETUPTOOLS_VER is <= 49.1.1"; \ + echo " Updating to avoid issue: pypa/setuptools#1963"; \ + python3 -m pip install 'setuptools>49.1.1'; \ + else \ + echo " ✓ setuptools version $$SETUPTOOLS_VER is OK (> 49.1.1)"; \ + echo " Skipping setuptools update workaround"; \ + fi + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "Building submodules..." + @for module in $(SUBMODULES); do \ + echo ""; \ + echo "==========================================================================="; \ echo "Building $$module"; \ + echo "==========================================================================="; \ if [ "$$module" = "aw-server-rust" ] && [ "$(TAURI_BUILD)" = "true" ]; then \ make --directory=$$module aw-sync SKIP_WEBUI=$(SKIP_WEBUI) || { echo "Error in $$module aw-sync"; exit 2; }; \ else \ make --directory=$$module build SKIP_WEBUI=$(SKIP_WEBUI) || { echo "Error in $$module build"; exit 2; }; \ fi; \ done + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "Finalizing build..." # The below is needed due to: https://github.com/ActivityWatch/activitywatch/issues/173 make --directory=aw-client build make --directory=aw-core build # Needed to ensure that the server has the correct version set - python -c "import aw_server; print(aw_server.__version__)" + python3 -c "import aw_server; print('aw_server version:', aw_server.__version__)" + @echo "" + @echo "===========================================================================" + @echo "Build complete!" + @echo "===========================================================================" + +# build-pre-check: venv check, but allow skip via SKIP_VENV_CHECK +build-pre-check: + @if [ -z "$(SKIP_VENV_CHECK)" ] || [ "$(SKIP_VENV_CHECK)" = "0" ]; then \ + $(MAKE) venv-check; \ + else \ + echo "⚠ SKIP_VENV_CHECK=1 is set, skipping venv check"; \ + echo " This may pollute your global Python environment."; \ + echo ""; \ + fi # Install @@ -138,13 +394,103 @@ test: fi; \ done +.PHONY: test-integration test-integration-help + +AW_TEST_TIMEOUT ?= 180 +AW_PYTEST_TIMEOUT ?= 120 + +test-integration-help: + @echo "===========================================================================" + @echo "ActivityWatch Integration Tests" + @echo "===========================================================================" + @echo "" + @echo "Usage:" + @echo " make test-integration # Use aw-server from PATH" + @echo " AW_SERVER_BIN=./path/to/aw-server make test-integration # Use specific binary" + @echo "" + @echo "Environment Variables (all optional):" + @echo " AW_SERVER_BIN Path to aw-server binary (default: 'aw-server' from PATH)" + @echo " Examples: ./dist/activitywatch/aw-server-rust/aw-server" + @echo " ./dist/activitywatch/aw-server" + @echo " AW_SERVER_PORT Port to use (default: 5666 for testing)" + @echo " AW_SERVER_TIMEOUT Server startup timeout in seconds (default: 30)" + @echo " AW_SERVER_POLL Poll interval in seconds (default: 1.0)" + @echo " AW_LOG_LINES Number of log lines to show on failure (default: 100)" + @echo " AW_SERVER_ARGS Extra arguments (default: '--testing')" + @echo " AW_TEST_TIMEOUT Global test timeout in seconds (default: 180)" + @echo " AW_PYTEST_TIMEOUT Per-test pytest timeout in seconds (default: 120)" + @echo "" + @echo "Examples:" + @echo " # Run with aw-server from PATH" + @echo " make test-integration" + @echo "" + @echo " # Run with Tauri-built aw-server-rust" + @echo " AW_SERVER_BIN=./dist/activitywatch/aw-server-rust/aw-server make test-integration" + @echo "" + @echo " # Run with custom port and longer timeout" + @echo " AW_SERVER_PORT=5777 AW_SERVER_TIMEOUT=60 AW_TEST_TIMEOUT=300 make test-integration" + @echo "" + @echo "What it tests:" + @echo " 1. Server starts and responds to /api/0/info (no fixed sleep)" + @echo " 2. /api/0/info returns version and hostname" + @echo " 3. /api/0/buckets returns valid data" + @echo " 4. No ERROR/panic indicators in logs" + @echo "" + @echo "Timeout Protection (prevents hanging in CI):" + @echo " - Global timeout: $(AW_TEST_TIMEOUT)s (entire test run)" + @echo " - Per-test timeout: $(AW_PYTEST_TIMEOUT)s (individual test)" + @echo " - Server startup timeout: $(AW_SERVER_TIMEOUT)s (default)" + @echo "" + @echo "Error Classification (diagnosable):" + @echo " - LAUNCH_FAILED: Server binary not found or failed to start" + @echo " - EARLY_EXIT: Server started but exited with error" + @echo " - STARTUP_TIMEOUT: Server never became responsive" + @echo " - API_ERROR: API request returned non-200 status" + @echo " - ASSERTION_FAILED: API assertion failed (missing fields)" + @echo " - LOG_ERROR: Server logs contain ERROR/panic" + @echo "" + @echo "On failure/timeout:" + @echo " - Prints last N lines of stdout and stderr for diagnosis" + @echo " - Shows error type classification" + @echo " - Shows server PID, port, exit code" + @echo "===========================================================================" + test-integration: - # TODO: Move "integration tests" to aw-client - # FIXME: For whatever reason the script stalls on Appveyor - # Example: https://ci.appveyor.com/project/ErikBjare/activitywatch/build/1.0.167/job/k1ulexsc5ar5uv4v - # aw-server-python - @echo "== Integration testing aw-server ==" - @pytest ./scripts/tests/integration_tests.py ./aw-server/tests/ -v + @echo "===========================================================================" + @echo "Integration Testing ActivityWatch Server" + @echo "===========================================================================" + @echo "" + @echo "Environment:" + @echo " AW_SERVER_BIN: ${AW_SERVER_BIN:-aw-server (from PATH)}" + @echo " AW_SERVER_PORT: ${AW_SERVER_PORT:-5666}" + @echo " AW_SERVER_TIMEOUT: ${AW_SERVER_TIMEOUT:-30}s" + @echo " AW_TEST_TIMEOUT: $(AW_TEST_TIMEOUT)s (global)" + @echo " AW_PYTEST_TIMEOUT: $(AW_PYTEST_TIMEOUT)s (per-test)" + @echo "" + @echo "For help: make test-integration-help" + @echo "===========================================================================" + @echo "" + @if [ "$$(uname -s)" = "Darwin" ]; then \ + if command -v gtimeout >/dev/null 2>&1; then \ + echo "Using gtimeout for global timeout protection..."; \ + gtimeout --signal=SIGKILL $(AW_TEST_TIMEOUT) \ + pytest scripts/tests/integration_tests.py -v \ + --timeout=$(AW_PYTEST_TIMEOUT) \ + -o timeout_method=thread; \ + else \ + echo "Note: gtimeout not found (install with: brew install coreutils)"; \ + echo "Running without global timeout wrapper, relying on pytest-timeout..."; \ + pytest scripts/tests/integration_tests.py -v \ + --timeout=$(AW_PYTEST_TIMEOUT) \ + -o timeout_method=thread; \ + fi; \ + else \ + echo "Using timeout for global timeout protection..."; \ + timeout --signal=SIGKILL $(AW_TEST_TIMEOUT) \ + pytest scripts/tests/integration_tests.py -v \ + --timeout=$(AW_PYTEST_TIMEOUT) \ + -o timeout_method=thread; \ + fi %/.git: git submodule update --init --recursive @@ -186,36 +532,181 @@ dist/ActivityWatch.dmg: dist/ActivityWatch.app dist/notarize: ./scripts/notarize.sh -package: +package: package-pre-check + @echo "" + @echo "===========================================================================" + @echo "ActivityWatch Packaging" + @echo "===========================================================================" + @echo "" + @echo "Configuration:" + @echo " TAURI_BUILD: $(TAURI_BUILD)" + @echo " RELEASE: $(RELEASE)" + @echo " Target: $(targetdir)" + @echo " Packageables: $(PACKAGEABLES)" + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[CLEAN] Removing old dist directory..." + @echo " [ACTION] rm -rf dist" rm -rf dist + @echo " [OK] dist directory removed" + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[CREATE] Creating directory structure..." + @echo " [ACTION] mkdir -p dist/activitywatch" mkdir -p dist/activitywatch - for dir in $(PACKAGEABLES); do \ - make --directory=$$dir package; \ - cp -r $$dir/dist/$$dir dist/activitywatch; \ + @echo " [OK] dist/activitywatch created" + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[PACKAGE] Building submodules..." + @for dir in $(PACKAGEABLES); do \ + echo ""; \ + echo "==========================================================================="; \ + echo "[SUBMODULE] $$dir"; \ + echo "==========================================================================="; \ + echo " [ACTION] make --directory=$$dir package"; \ + if make --directory=$$dir package; then \ + echo " [OK] $$dir packaged successfully"; \ + else \ + echo " [ERROR] Failed to package $$dir"; \ + exit 2; \ + fi; \ + echo ""; \ + echo " [ACTION] cp -r $$dir/dist/$$dir dist/activitywatch"; \ + if cp -r $$dir/dist/$$dir dist/activitywatch; then \ + echo " [OK] Copied $$dir to dist/activitywatch"; \ + find dist/activitywatch/$$dir -type f -name "*" | head -10; \ + else \ + echo " [ERROR] Failed to copy $$dir"; \ + exit 2; \ + fi; \ done + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[POST-PROCESS] Additional steps..." + ifeq ($(TAURI_BUILD),true) -# Copy aw-sync binary for Tauri builds + @echo "" + @echo "===========================================================================" + @echo "[TAURI] Tauri-specific packaging" + @echo "===========================================================================" + @echo " [ACTION] mkdir -p dist/activitywatch/aw-server-rust" mkdir -p dist/activitywatch/aw-server-rust + @echo " [OK] dist/activitywatch/aw-server-rust created" + @echo "" + @echo " [ACTION] cp aw-server-rust/target/$(targetdir)/aw-sync dist/activitywatch/aw-server-rust/aw-sync" cp aw-server-rust/target/$(targetdir)/aw-sync dist/activitywatch/aw-server-rust/aw-sync + @echo " [OK] aw-sync copied from aw-server-rust/target/$(targetdir)/aw-sync" + @echo " [INFO] Source: aw-server-rust/target/$(targetdir)/aw-sync" + @echo " [INFO] Target: dist/activitywatch/aw-server-rust/aw-sync" else -# Move aw-qt to the root of the dist folder + @echo "" + @echo "===========================================================================" + @echo "[NON-TAURI] aw-qt rearrangement" + @echo "===========================================================================" + @echo " [ACTION] mv dist/activitywatch/aw-qt aw-qt-tmp" mv dist/activitywatch/aw-qt aw-qt-tmp + @echo " [OK] aw-qt moved to aw-qt-tmp" + @echo "" + @echo " [ACTION] mv aw-qt-tmp/* dist/activitywatch" mv aw-qt-tmp/* dist/activitywatch + @echo " [OK] aw-qt contents moved to dist/activitywatch" + @echo "" + @echo " [ACTION] rmdir aw-qt-tmp" rmdir aw-qt-tmp + @echo " [OK] aw-qt-tmp removed" endif -# Remove problem-causing binaries - rm -f dist/activitywatch/libdrm.so.2 # see: https://github.com/ActivityWatch/activitywatch/issues/161 - rm -f dist/activitywatch/libharfbuzz.so.0 # see: https://github.com/ActivityWatch/activitywatch/issues/660#issuecomment-959889230 -# These should be provided by the distro itself -# Had to be removed due to otherwise causing the error: -# aw-qt: symbol lookup error: /opt/activitywatch/libQt5XcbQpa.so.5: undefined symbol: FT_Get_Font_Format + + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[CLEANUP] Removing problem-causing files..." + @echo " [ACTION] Removing libraries that cause runtime issues..." + @echo "" + @echo " - libdrm.so.2 (see: https://github.com/ActivityWatch/activitywatch/issues/161)" + rm -f dist/activitywatch/libdrm.so.2 + @echo " [OK] libdrm.so.2 removed" + + @echo " - libharfbuzz.so.0 (see: https://github.com/ActivityWatch/activitywatch/issues/660)" + rm -f dist/activitywatch/libharfbuzz.so.0 + @echo " [OK] libharfbuzz.so.0 removed" + + @echo " - libfontconfig.so.1 (symbol lookup error)" rm -f dist/activitywatch/libfontconfig.so.1 + @echo " [OK] libfontconfig.so.1 removed" + + @echo " - libfreetype.so.6 (symbol lookup error)" rm -f dist/activitywatch/libfreetype.so.6 -# Remove unnecessary files + @echo " [OK] libfreetype.so.6 removed" + + @echo " - pytz (unnecessary)" rm -rf dist/activitywatch/pytz -# Builds zips and setups + @echo " [OK] pytz removed" + @echo "" + @echo " [OK] Problem-causing files cleaned up" + + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[PACKAGE] Building distribution artifacts (zip, installer)..." + @echo " [ACTION] bash scripts/package/package-all.sh" + @echo "" bash scripts/package/package-all.sh + @echo "" + @echo "---------------------------------------------------------------------------" + @echo "[VERIFY] Running package verification..." + @echo " [ACTION] bash scripts/package/verify-package.sh" + @echo "" + @if [ -n "$(PACKAGE_STRICT)" ] && [ "$(PACKAGE_STRICT)" = "true" ]; then \ + echo " [INFO] Running in STRICT mode (will exit on errors)"; \ + bash scripts/package/verify-package.sh --strict; \ + else \ + echo " [INFO] Running in report-only mode (use PACKAGE_STRICT=true for strict)"; \ + bash scripts/package/verify-package.sh; \ + fi + + @echo "" + @echo "===========================================================================" + @echo "[DONE] Packaging complete!" + @echo "===========================================================================" + @echo "" + @echo "Output location: $(PWD)/dist" + @ls -lh dist/*.zip dist/*.exe dist/*.dmg 2>/dev/null || echo " (No artifacts found in dist root)" + @echo "" + +package-pre-check: + @echo "===========================================================================" + @echo "Packaging Pre-Check" + @echo "===========================================================================" + @echo "" + @if [ -z "$(SKIP_VENV_CHECK)" ] || [ "$(SKIP_VENV_CHECK)" = "0" ]; then \ + IN_VENV=$$(python3 -c "import sys; print(1 if hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix) else 0)" 2>/dev/null || echo 0); \ + if [ "$$IN_VENV" != "1" ]; then \ + echo "[ERROR] Not running in a Python virtual environment!"; \ + echo ""; \ + echo "Run these commands first:"; \ + echo " python3 -m venv .venv"; \ + echo " source .venv/bin/activate"; \ + echo ""; \ + echo "Or skip this check: SKIP_VENV_CHECK=1 make package"; \ + exit 1; \ + else \ + echo " [OK] Running in virtual environment"; \ + fi; \ + else \ + echo " [SKIP] Virtual environment check skipped (SKIP_VENV_CHECK=1)"; \ + fi + @echo "" + @if [ ! -d "aw-core" ] || [ ! -f "aw-core/.git" ]; then \ + echo "[ERROR] Submodules not initialized!"; \ + echo ""; \ + echo "Run: git submodule update --init --recursive"; \ + exit 1; \ + else \ + echo " [OK] Submodules initialized"; \ + fi + @echo "" + @echo "Pre-check passed." + @echo "===========================================================================" + clean: rm -rf build dist diff --git a/pyproject.toml b/pyproject.toml index 683cbbf83..db89678cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ mypy = "*" pytest = "*" pytest-cov = "*" pytest-benchmark = "*" +pytest-timeout = "*" psutil = "*" pywin32-ctypes = {version = "*", platform = "win32"} pefile = {version = "*", platform = "win32"} diff --git a/scripts/package/package-all.sh b/scripts/package/package-all.sh index 52c19f2d1..e9c7e98b9 100755 --- a/scripts/package/package-all.sh +++ b/scripts/package/package-all.sh @@ -1,102 +1,237 @@ #!/bin/bash +# +# ActivityWatch Package All Script +# ================================= +# +# This script builds distribution artifacts (zip, installer) after the +# initial packaging steps in the Makefile. +# +# Environment Variables: +# TAURI_BUILD: Set to "true" for Tauri builds +# SKIP_WEBUI: Set to "true" to skip web UI +# +# Exit Codes: +# 0: Success +# 1: Error (e.g., missing dependencies) +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ===================================== +# Logging Functions +# ===================================== + +log_info() { + echo "[INFO] $@" +} -set -e +log_action() { + echo "[ACTION] $@" +} -echoerr() { echo "$@" 1>&2; } +log_ok() { + echo "[✓] $@" +} -function get_platform() { - # Will return "linux" for GNU/Linux - # I'd just like to interject for a moment... - # https://wiki.installgentoo.com/index.php/Interjection - # Will return "macos" for macOS/OS X - # Will return "windows" for Windows/MinGW/msys +log_warn() { + echo "[⚠] $@" +} - _platform=$(uname | tr '[:upper:]' '[:lower:]') - if [[ $_platform == "darwin" ]]; then - _platform="macos"; - elif [[ $_platform == "msys"* ]]; then - _platform="windows"; - elif [[ $_platform == "mingw"* ]]; then - _platform="windows"; - elif [[ $_platform == "linux" ]]; then - # Nothing to do - true; - else - echoerr "ERROR: $_platform is not a valid platform"; - exit 1; - fi +log_error() { + echo "[✗] $@" >&2 +} - echo $_platform; +log_header() { + echo "" + echo "========================================" + echo "$@" + echo "========================================" } -function get_version() { - $(dirname "$0")/getversion.sh; +log_section() { + echo "" + echo "---------------------------------------------------------------------------" + echo "$@" + echo "---------------------------------------------------------------------------" +} + +# ===================================== +# Helper Functions +# ===================================== + +function get_platform() { + local _platform + _platform=$(uname | tr '[:upper:]' '[:lower:]') + if [[ $_platform == "darwin" ]]; then + _platform="macos" + elif [[ $_platform == "msys"* ]] || [[ $_platform == "mingw"* ]]; then + _platform="windows" + elif [[ $_platform != "linux" ]]; then + log_error "Unknown platform: $_platform" + exit 1 + fi + echo "$_platform" } function get_arch() { + local _arch _arch="$(uname -m)" - echo $_arch; + echo "$_arch" } +# ===================================== +# Main +# ===================================== + +log_header "ActivityWatch Package All" + +# Load version from authority +log_section "Loading Version Information" +log_action "Sourcing version from authority: $SCRIPT_DIR/getversion.sh --env" + +# Use eval to get TAG_VERSION and DISPLAY_VERSION from the authority +eval "$("$SCRIPT_DIR/getversion.sh" --env)" + +log_ok "Version loaded successfully" +log_info " TAG_VERSION: $TAG_VERSION" +log_info " DISPLAY_VERSION: $DISPLAY_VERSION" + +# Detect platform and arch +log_section "Detecting Platform" platform=$(get_platform) -version=$(get_version) arch=$(get_arch) -# Suffix to distinguish Tauri builds from aw-qt builds in release assets + +# Build suffix for Tauri builds build_suffix="" -if [[ $TAURI_BUILD == "true" ]]; then +if [[ ${TAURI_BUILD:-false} == "true" ]]; then build_suffix="-tauri" fi -echo "Platform: $platform, arch: $arch, version: $version, tauri: ${TAURI_BUILD:-false}" + +log_ok "Platform detected" +log_info " Platform: $platform" +log_info " Architecture: $arch" +log_info " Tauri build: ${TAURI_BUILD:-false}" +log_info " Build suffix: $build_suffix" +log_info "" +log_info "Artifact naming:" +log_info " Zip: activitywatch${build_suffix}-${DISPLAY_VERSION}-${platform}-${arch}.zip" +log_info " Installer: activitywatch${build_suffix}-${DISPLAY_VERSION}-${platform}-${arch}-setup.exe" # For Tauri Linux builds, include helper scripts and README -if [[ $platform == "linux" && $TAURI_BUILD == "true" ]]; then - cp scripts/package/README.txt scripts/package/move-to-aw-modules.sh dist/activitywatch/ +if [[ $platform == "linux" ]] && [[ ${TAURI_BUILD:-false} == "true" ]]; then + log_section "Copying Tauri Linux Helper Scripts" + log_action "Copying scripts/package/README.txt → dist/activitywatch/" + cp "$SCRIPT_DIR/README.txt" dist/activitywatch/ + log_ok "README.txt copied" + + log_action "Copying scripts/package/move-to-aw-modules.sh → dist/activitywatch/" + cp "$SCRIPT_DIR/move-to-aw-modules.sh" dist/activitywatch/ + log_ok "move-to-aw-modules.sh copied" fi -function build_zip() { - echo "Zipping executables..." - pushd dist; - filename="activitywatch${build_suffix}-${version}-${platform}-${arch}.zip" - echo "Name of package will be: $filename" +# ===================================== +# Build Zip +# ===================================== +log_section "Building Zip Archive" - if [[ $platform == "windows"* ]]; then - 7z a $filename activitywatch; - else - zip -r $filename activitywatch; - fi - popd; - echo "Zip built!" -} +zip_filename="activitywatch${build_suffix}-${DISPLAY_VERSION}-${platform}-${arch}.zip" + +log_info "Zip filename: $zip_filename" +log_action "Entering dist directory" -function build_setup() { - filename="activitywatch${build_suffix}-${version}-${platform}-${arch}-setup.exe" - echo "Name of package will be: $filename" +pushd dist >/dev/null +log_action "Compressing activitywatch/ → $zip_filename" + +if [[ $platform == "windows" ]]; then + log_action "Using 7z for compression (Windows)" + 7z a "$zip_filename" activitywatch +else + log_action "Using zip for compression (Unix)" + zip -r "$zip_filename" activitywatch +fi + +log_ok "Zip built successfully" +log_info " File: $zip_filename" +log_info " Size: $(du -h "$zip_filename" | cut -f1)" + +popd >/dev/null + +# ===================================== +# Build Installer (Windows only) +# ===================================== +if [[ $platform == "windows" ]]; then + log_section "Building Windows Installer" + + installer_filename="activitywatch${build_suffix}-${DISPLAY_VERSION}-${platform}-${arch}-setup.exe" + log_info "Installer filename: $installer_filename" + innosetupdir="/c/Program Files (x86)/Inno Setup 6" - if [ ! -d "$innosetupdir" ]; then - echo "ERROR: Couldn't find innosetup which is needed to build the installer. We suggest you install it using chocolatey. Exiting." + + log_action "Checking for Inno Setup" + if [[ ! -d "$innosetupdir" ]]; then + log_error "Couldn't find Inno Setup in: $innosetupdir" + log_info "" + log_info "Inno Setup is required to build the Windows installer." + log_info "Install using chocolatey:" + log_info " choco install innosetup" exit 1 fi - - # Windows installer version should not include 'v' prefix, see: https://github.com/microsoft/winget-pkgs/pull/17564 - version_no_prefix="$(echo $version | sed -e 's/^v//')" - if [[ $TAURI_BUILD == "true" ]]; then - env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/aw-tauri.iss + log_ok "Inno Setup found: $innosetupdir" + + # Windows installer version should NOT include 'v' prefix + # DISPLAY_VERSION is already without 'v' prefix, so we use it directly + log_info "Installer version: $DISPLAY_VERSION (no 'v' prefix)" + + log_action "Running Inno Setup compiler" + if [[ ${TAURI_BUILD:-false} == "true" ]]; then + log_info " Using Tauri installer script: scripts/package/aw-tauri.iss" + env AW_VERSION="$DISPLAY_VERSION" "$innosetupdir/iscc.exe" "$SCRIPT_DIR/aw-tauri.iss" else - env AW_VERSION=$version_no_prefix "$innosetupdir/iscc.exe" scripts/package/activitywatch-setup.iss + log_info " Using standard installer script: scripts/package/activitywatch-setup.iss" + env AW_VERSION="$DISPLAY_VERSION" "$innosetupdir/iscc.exe" "$SCRIPT_DIR/activitywatch-setup.iss" fi - mv dist/activitywatch-setup.exe dist/$filename - echo "Setup built!" -} - -build_zip -if [[ $platform == "windows"* ]]; then - build_setup + log_ok "Inno Setup compilation complete" + + log_action "Renaming installer: activitywatch-setup.exe → $installer_filename" + mv dist/activitywatch-setup.exe "dist/$installer_filename" + log_ok "Installer renamed" + log_info " File: $installer_filename" + log_info " Size: $(du -h "dist/$installer_filename" | cut -f1)" fi -echo -echo "-------------------------------------" -echo "Contents of ./dist" -ls -l dist -echo "-------------------------------------" - +# ===================================== +# List Contents +# ===================================== +log_section "Package Contents" + +log_info "Listing dist/ directory contents:" +echo "" +ls -lh dist/*.zip dist/*.exe dist/*.dmg 2>/dev/null || log_warn "No artifacts found in dist root" +echo "" + +# ===================================== +# Summary +# ===================================== +log_header "Package All Complete" + +log_ok "Artifacts built successfully" +log_info "" +log_info "Summary:" +log_info " Platform: $platform" +log_info " Architecture: $arch" +log_info " Version: $DISPLAY_VERSION" +log_info " Tauri build: ${TAURI_BUILD:-false}" +log_info "" +log_info "Artifacts:" +if [[ -f "dist/$zip_filename" ]]; then + log_info " ✅ $zip_filename ($(du -h "dist/$zip_filename" | cut -f1))" +fi +if [[ $platform == "windows" ]] && [[ -f "dist/$installer_filename" ]]; then + log_info " ✅ $installer_filename ($(du -h "dist/$installer_filename" | cut -f1))" +fi +log_info "" +log_info "Note: Version consistency check will be performed by verify-package.sh" +log_info "" diff --git a/scripts/package/verify-package.sh b/scripts/package/verify-package.sh new file mode 100755 index 000000000..0c38b1a35 --- /dev/null +++ b/scripts/package/verify-package.sh @@ -0,0 +1,545 @@ +#!/bin/bash +# +# ActivityWatch Package Verification Script +# ========================================= +# +# This script verifies the package contents after `make package` completes. +# It checks for: +# - Critical executables (presence and executability) +# - Critical directories +# - Version consistency (across zip, installer, Info.plist) +# +# Usage: +# bash scripts/package/verify-package.sh # Print report, exit 0 on success +# bash scripts/package/verify-package.sh --strict # Exit non-zero on any issue (for CI) +# bash scripts/package/verify-package.sh --help # Show help +# +# Environment Variables: +# DIST_DIR: Path to dist directory (default: ./dist) +# EXPECTED_VERSION: Expected version (without v prefix), auto-detected from getversion.sh if not set +# VERBOSE: Set to 1 for more detailed output +# +# Exit Codes: +# 0: All checks passed (or --strict not set and issues found) +# 1: Critical error (e.g., dist directory not found) +# 2: Verification failed (only with --strict) +# + +set -euo pipefail + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DIST_DIR="${DIST_DIR:-./dist}" +EXPECTED_VERSION="${EXPECTED_VERSION:-}" +STRICT_MODE=false +VERBOSE="${VERBOSE:-0}" + +# Counters +TOTAL_CHECKS=0 +PASSED_CHECKS=0 +WARNINGS=() +ERRORS=() + +# ===================================== +# Logging Functions +# ===================================== + +log_info() { + echo "[INFO] $@" +} + +log_action() { + echo "[ACTION] $@" +} + +log_ok() { + echo "[✓] $@" +} + +log_warn() { + echo "[⚠] $@" + WARNINGS+=("$@") +} + +log_error() { + echo "[✗] $@" + ERRORS+=("$@") +} + +log_header() { + echo "" + echo "========================================" + echo "$@" + echo "========================================" +} + +show_help() { + cat << EOF +Usage: $(basename "$0") [OPTIONS] + +Verify ActivityWatch package contents after packaging. + +Options: + --strict Exit with non-zero code if any check fails (for CI) + --help, -h Show this help message + +Environment Variables: + DIST_DIR Path to dist directory (default: ./dist) + EXPECTED_VERSION Expected version (auto-detected if not set) + VERBOSE Set to 1 for detailed output + +Checks performed: + 1. Critical executables (presence + executability) + 2. Critical directories + 3. Version consistency (zip, installer, Info.plist) +EOF + exit 0 +} + +# ===================================== +# Argument Parsing +# ===================================== + +while [[ $# -gt 0 ]]; do + case "$1" in + --strict) + STRICT_MODE=true + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown argument: $1" + show_help + exit 1 + ;; + esac +done + +# ===================================== +# Helper Functions +# ===================================== + +increment_check() { + TOTAL_CHECKS=$((TOTAL_CHECKS + 1)) +} + +check_passed() { + PASSED_CHECKS=$((PASSED_CHECKS + 1)) +} + +file_exists() { + local path="$1" + increment_check + if [[ -e "$path" ]]; then + if [[ $VERBOSE == "1" ]]; then + log_ok "File exists: $path" + fi + check_passed + return 0 + else + log_error "File missing: $path" + return 1 + fi +} + +file_executable() { + local path="$1" + increment_check + if [[ -x "$path" ]]; then + if [[ $VERBOSE == "1" ]]; then + log_ok "File is executable: $path" + fi + check_passed + return 0 + elif [[ -f "$path" ]]; then + log_error "File exists but is not executable: $path" + return 1 + else + log_error "File missing: $path" + return 1 + fi +} + +dir_exists() { + local path="$1" + increment_check + if [[ -d "$path" ]]; then + if [[ $VERBOSE == "1" ]]; then + log_ok "Directory exists: $path" + fi + check_passed + return 0 + else + log_error "Directory missing: $path" + return 1 + fi +} + +# ===================================== +# Version Detection +# ===================================== + +detect_version() { + if [[ -z "$EXPECTED_VERSION" ]]; then + log_info "Auto-detecting version from authority: $SCRIPT_DIR/getversion.sh" + if [[ -f "$SCRIPT_DIR/getversion.sh" ]]; then + EXPECTED_VERSION="$("$SCRIPT_DIR/getversion.sh" --display)" + log_info " Detected DISPLAY_VERSION: $EXPECTED_VERSION" + else + log_error "Cannot detect version: $SCRIPT_DIR/getversion.sh not found" + exit 1 + fi + else + log_info "Using expected version from environment: $EXPECTED_VERSION" + fi +} + +# ===================================== +# Check Functions +# ===================================== + +check_critical_executables() { + log_header "Checking Critical Executables" + + local activitywatch_dir="$DIST_DIR/activitywatch" + + if [[ ! -d "$activitywatch_dir" ]]; then + log_error "ActivityWatch directory not found: $activitywatch_dir" + return 1 + fi + + # aw-server-rust (preferred) or aw-server + local found_server=false + if [[ -x "$activitywatch_dir/aw-server-rust/aw-server" ]]; then + file_executable "$activitywatch_dir/aw-server-rust/aw-server" + found_server=true + elif [[ -f "$activitywatch_dir/aw-server-rust/aw-server" ]]; then + log_error "aw-server-rust/aw-server exists but is not executable" + increment_check + elif [[ -x "$activitywatch_dir/aw-server/aw-server" ]]; then + file_executable "$activitywatch_dir/aw-server/aw-server" + found_server=true + elif [[ -f "$activitywatch_dir/aw-server/aw-server" ]]; then + log_error "aw-server/aw-server exists but is not executable" + increment_check + else + log_error "No server executable found (checked aw-server-rust/aw-server and aw-server/aw-server)" + increment_check + fi + + # aw-sync (for Tauri builds only - optional) + if [[ -f "$activitywatch_dir/aw-server-rust/aw-sync" ]] || [[ -f "$activitywatch_dir/aw-sync" ]]; then + if [[ -x "$activitywatch_dir/aw-server-rust/aw-sync" ]]; then + file_executable "$activitywatch_dir/aw-server-rust/aw-sync" + elif [[ -x "$activitywatch_dir/aw-sync" ]]; then + file_executable "$activitywatch_dir/aw-sync" + elif [[ -f "$activitywatch_dir/aw-server-rust/aw-sync" ]]; then + log_error "aw-server-rust/aw-sync exists but is not executable" + increment_check + fi + else + log_warn "aw-sync not found (expected only in Tauri builds)" + fi + + # aw-watcher-afk + if [[ -x "$activitywatch_dir/aw-watcher-afk/aw-watcher-afk" ]]; then + file_executable "$activitywatch_dir/aw-watcher-afk/aw-watcher-afk" + elif [[ -f "$activitywatch_dir/aw-watcher-afk/aw-watcher-afk" ]]; then + log_error "aw-watcher-afk exists but is not executable" + increment_check + else + log_warn "aw-watcher-afk not found (may be in development)" + fi + + # aw-watcher-window + if [[ -x "$activitywatch_dir/aw-watcher-window/aw-watcher-window" ]]; then + file_executable "$activitywatch_dir/aw-watcher-window/aw-watcher-window" + elif [[ -f "$activitywatch_dir/aw-watcher-window/aw-watcher-window" ]]; then + log_error "aw-watcher-window exists but is not executable" + increment_check + else + log_warn "aw-watcher-window not found (may be in development)" + fi + + # aw-qt or aw-tauri (launcher) + if [[ -x "$activitywatch_dir/aw-qt" ]]; then + file_executable "$activitywatch_dir/aw-qt" + elif [[ -f "$activitywatch_dir/aw-qt" ]]; then + log_error "aw-qt exists but is not executable" + increment_check + elif [[ -x "$activitywatch_dir/ActivityWatch.app/Contents/MacOS/ActivityWatch" ]]; then + # macOS Tauri build + file_executable "$activitywatch_dir/ActivityWatch.app/Contents/MacOS/ActivityWatch" + else + log_warn "No launcher found (aw-qt or ActivityWatch.app)" + fi +} + +check_critical_directories() { + log_header "Checking Critical Directories" + + local activitywatch_dir="$DIST_DIR/activitywatch" + + # Main directory + dir_exists "$activitywatch_dir" + + # Check for at least one server directory + if [[ -d "$activitywatch_dir/aw-server-rust" ]] || [[ -d "$activitywatch_dir/aw-server" ]]; then + increment_check + log_ok "Server directory exists (aw-server-rust or aw-server)" + check_passed + else + log_error "No server directory found (aw-server-rust or aw-server)" + fi +} + +check_artifacts() { + log_header "Checking Distribution Artifacts" + + local has_zip=false + local has_installer=false + local has_app=false + + # Check for zip files + local zip_files=() + while IFS= read -r -d '' file; do + zip_files+=("$file") + done < <(find "$DIST_DIR" -maxdepth 1 -name "activitywatch*.zip" -print0 2>/dev/null || true) + + if [[ ${#zip_files[@]} -gt 0 ]]; then + has_zip=true + increment_check + log_ok "Found ${#zip_files[@]} zip file(s) in $DIST_DIR" + check_passed + + for zip in "${zip_files[@]}"; do + log_info " - $(basename "$zip") ($(du -h "$zip" | cut -f1))" + done + else + log_warn "No zip files found in $DIST_DIR" + fi + + # Check for installer (Windows) + local installer_files=() + while IFS= read -r -d '' file; do + installer_files+=("$file") + done < <(find "$DIST_DIR" -maxdepth 1 -name "*-setup.exe" -print0 2>/dev/null || true) + + if [[ ${#installer_files[@]} -gt 0 ]]; then + has_installer=true + increment_check + log_ok "Found ${#installer_files[@]} installer(s) in $DIST_DIR" + check_passed + + for installer in "${installer_files[@]}"; do + log_info " - $(basename "$installer") ($(du -h "$installer" | cut -f1))" + done + fi + + # Check for .app bundle (macOS Tauri) + if [[ -d "$DIST_DIR/ActivityWatch.app" ]]; then + has_app=true + increment_check + log_ok "Found ActivityWatch.app bundle" + check_passed + fi + + # Check for dmg (macOS) + if [[ -f "$DIST_DIR/ActivityWatch.dmg" ]]; then + increment_check + log_ok "Found ActivityWatch.dmg ($(du -h "$DIST_DIR/ActivityWatch.dmg" | cut -f1))" + check_passed + fi + + # Summary + log_info "" + if [[ $has_zip == true ]] || [[ $has_installer == true ]] || [[ $has_app == true ]]; then + log_ok "At least one distribution artifact found" + else + log_warn "No distribution artifacts found (zip, installer, or .app)" + fi +} + +check_version_consistency() { + log_header "Checking Version Consistency" + + log_info "Expected version (DISPLAY_VERSION): $EXPECTED_VERSION" + log_info "" + + local mismatches=() + + # Check zip file versions + local zip_files=() + while IFS= read -r -d '' file; do + zip_files+=("$file") + done < <(find "$DIST_DIR" -maxdepth 1 -name "activitywatch*.zip" -print0 2>/dev/null || true) + + for zip in "${zip_files[@]}"; do + local zip_name + zip_name=$(basename "$zip") + increment_check + + # Expected format: activitywatch{,-tauri}---.zip + # Version should be WITHOUT 'v' prefix + if [[ "$zip_name" =~ activitywatch[^-]*-([^-]+)- ]]; then + local zip_version="${BASH_REMATCH[1]}" + if [[ "$zip_version" == "$EXPECTED_VERSION" ]]; then + log_ok "Zip version matches: $zip_name" + check_passed + elif [[ "$zip_version" == "v$EXPECTED_VERSION" ]]; then + log_error "Zip version has 'v' prefix: expected '$EXPECTED_VERSION', got '$zip_version'" + mismatches+=("zip '$zip_name' has 'v' prefix: '$zip_version'") + else + log_error "Zip version mismatch: expected '$EXPECTED_VERSION', got '$zip_version'" + mismatches+=("zip '$zip_name': '$zip_version'") + fi + else + log_warn "Could not extract version from zip: $zip_name" + fi + done + + # Check installer versions + local installer_files=() + while IFS= read -r -d '' file; do + installer_files+=("$file") + done < <(find "$DIST_DIR" -maxdepth 1 -name "*-setup.exe" -print0 2>/dev/null || true) + + for installer in "${installer_files[@]}"; do + local installer_name + installer_name=$(basename "$installer") + increment_check + + if [[ "$installer_name" =~ activitywatch[^-]*-([^-]+)- ]]; then + local installer_version="${BASH_REMATCH[1]}" + if [[ "$installer_version" == "$EXPECTED_VERSION" ]]; then + log_ok "Installer version matches: $installer_name" + check_passed + elif [[ "$installer_version" == "v$EXPECTED_VERSION" ]]; then + log_error "Installer version has 'v' prefix: expected '$EXPECTED_VERSION', got '$installer_version'" + mismatches+=("installer '$installer_name' has 'v' prefix: '$installer_version'") + else + log_error "Installer version mismatch: expected '$EXPECTED_VERSION', got '$installer_version'" + mismatches+=("installer '$installer_name': '$installer_version'") + fi + else + log_warn "Could not extract version from installer: $installer_name" + fi + done + + # Check Info.plist version (macOS) + local infoplist="$DIST_DIR/ActivityWatch.app/Contents/Info.plist" + if [[ -f "$infoplist" ]]; then + increment_check + local infoplist_version="" + + if command -v PlistBuddy &>/dev/null; then + infoplist_version=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$infoplist" 2>/dev/null || true) + elif command -v plutil &>/dev/null; then + infoplist_version=$(plutil -extract CFBundleShortVersionString xml1 -o - "$infoplist" 2>/dev/null | grep -o '[^<]*' | head -1 | sed 's/<[^>]*>//g' || true) + fi + + if [[ -n "$infoplist_version" ]]; then + if [[ "$infoplist_version" == "$EXPECTED_VERSION" ]]; then + log_ok "Info.plist version matches: $infoplist_version" + check_passed + elif [[ "$infoplist_version" == "v$EXPECTED_VERSION" ]]; then + log_error "Info.plist version has 'v' prefix: expected '$EXPECTED_VERSION', got '$infoplist_version'" + mismatches+=("Info.plist has 'v' prefix: '$infoplist_version'") + else + log_error "Info.plist version mismatch: expected '$EXPECTED_VERSION', got '$infoplist_version'" + mismatches+=("Info.plist: '$infoplist_version'") + fi + else + log_warn "Could not read Info.plist version" + fi + fi + + # Summary + log_info "" + if [[ ${#mismatches[@]} -gt 0 ]]; then + log_error "========================================" + log_error "VERSION MISMATCHES FOUND" + log_error "========================================" + log_error "Expected version: $EXPECTED_VERSION" + log_error "" + for mismatch in "${mismatches[@]}"; do + log_error " - $mismatch" + done + log_error "========================================" + else + log_ok "All version checks passed" + fi +} + +# ===================================== +# Main +# ===================================== + +main() { + log_header "ActivityWatch Package Verification" + + # Check dist directory + if [[ ! -d "$DIST_DIR" ]]; then + log_error "Dist directory not found: $DIST_DIR" + log_error "Run 'make package' first to create the dist directory." + exit 1 + fi + + log_info "Dist directory: $DIST_DIR" + log_info "Strict mode: $STRICT_MODE" + log_info "" + + # Detect version + detect_version + + # Run checks + check_critical_executables + check_critical_directories + check_artifacts + check_version_consistency + + # Print summary + log_header "Verification Summary" + log_info "Total checks: $TOTAL_CHECKS" + log_info "Passed: $PASSED_CHECKS" + log_info "Warnings: ${#WARNINGS[@]}" + log_info "Errors: ${#ERRORS[@]}" + log_info "" + + if [[ ${#ERRORS[@]} -gt 0 ]]; then + log_info "Errors encountered:" + for err in "${ERRORS[@]}"; do + log_info " - $err" + done + log_info "" + fi + + if [[ ${#WARNINGS[@]} -gt 0 ]]; then + log_info "Warnings encountered:" + for warn in "${WARNINGS[@]}"; do + log_info " - $warn" + done + log_info "" + fi + + # Determine exit code + if [[ $STRICT_MODE == true ]] && [[ ${#ERRORS[@]} -gt 0 ]]; then + log_header "FINAL RESULT: FAILED (--strict mode)" + log_error "Verification failed with ${#ERRORS[@]} error(s) and ${#WARNINGS[@]} warning(s)" + log_info "Use without --strict to see report only" + exit 2 + elif [[ ${#ERRORS[@]} -gt 0 ]]; then + log_header "FINAL RESULT: ISSUES FOUND (not --strict)" + log_warn "Verification found ${#ERRORS[@]} error(s) and ${#WARNINGS[@]} warning(s)" + log_info "Use --strict to exit with non-zero code" + exit 0 + else + log_header "FINAL RESULT: PASSED" + log_ok "All ${PASSED_CHECKS}/${TOTAL_CHECKS} checks passed (${#WARNINGS[@]} warnings)" + exit 0 + fi +} + +main "$@" diff --git a/scripts/package/verify-windows-artifacts.sh b/scripts/package/verify-windows-artifacts.sh new file mode 100755 index 000000000..d00f79bd4 --- /dev/null +++ b/scripts/package/verify-windows-artifacts.sh @@ -0,0 +1,504 @@ +#!/bin/bash +# +# ActivityWatch Windows Artifacts Verification Script +# ==================================================== +# +# This script verifies that the Windows installer (Inno Setup) contains +# the same files as the zip archive. +# +# It works by: +# 1. Generating a manifest of files in the source directory (dist/activitywatch/) +# 2. Generating a manifest of files in the zip archive +# 3. Comparing the two manifests to find differences +# +# Environment Variables: +# TAURI_BUILD: Set to "true" for Tauri builds +# DIST_DIR: Path to dist directory (default: ./dist) +# STRICT_MODE: Set to "true" to exit with non-zero code on differences +# VERBOSE: Set to "1" for more detailed output +# +# Exit Codes: +# 0: Success (no differences found or non-strict mode) +# 1: Critical error (e.g., missing files) +# 2: Differences found (only in strict mode) +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DIST_DIR="${DIST_DIR:-./dist}" +TAURI_BUILD="${TAURI_BUILD:-false}" +STRICT_MODE="${STRICT_MODE:-false}" +VERBOSE="${VERBOSE:-0}" + +# ===================================== +# Logging Functions +# ===================================== + +log_info() { + echo "[INFO] $@" +} + +log_action() { + echo "[ACTION] $@" +} + +log_ok() { + echo "[✓] $@" +} + +log_warn() { + echo "[⚠] $@" +} + +log_error() { + echo "[✗] $@" >&2 +} + +log_header() { + echo "" + echo "========================================" + echo "$@" + echo "========================================" +} + +log_section() { + echo "" + echo "---------------------------------------------------------------------------" + echo "$@" + echo "---------------------------------------------------------------------------" +} + +show_help() { + cat << EOF +Usage: $(basename "$0") [OPTIONS] + +Verify that Windows installer and zip archive contain the same files. + +Options: + --strict Exit with non-zero code on differences + --help, -h Show this help message + +Environment Variables: + TAURI_BUILD Set to "true" for Tauri builds + DIST_DIR Path to dist directory (default: ./dist) + VERBOSE Set to "1" for detailed output + +Checks performed: + 1. Compare source directory (dist/activitywatch/) with zip contents + 2. Report missing files (in source but not in zip) + 3. Report extra files (in zip but not in source) + 4. Report files with different sizes + 5. For Tauri builds: verify aw-tauri.exe is in correct location +EOF + exit 0 +} + +# ===================================== +# Argument Parsing +# ===================================== + +while [[ $# -gt 0 ]]; do + case "$1" in + --strict) + STRICT_MODE=true + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown argument: $1" + show_help + exit 1 + ;; + esac +done + +# ===================================== +# Helper Functions +# ===================================== + +# Generate manifest for a directory +# Usage: generate_dir_manifest +generate_dir_manifest() { + local dir="$1" + local output="$2" + local prefix="${3:-}" + + log_action "Generating manifest for directory: $dir" + + if [[ ! -d "$dir" ]]; then + log_error "Directory not found: $dir" + return 1 + fi + + # Create manifest with format: || + # Use relative paths from the directory root + pushd "$dir" >/dev/null + + if command -v find >/dev/null 2>&1; then + find . -type f -print0 | while IFS= read -r -d '' file; do + # Remove leading "./" + local rel_path="${file#./}" + + # Skip if empty (root directory) + if [[ -z "$rel_path" ]]; then + continue + fi + + # Apply prefix if provided + if [[ -n "$prefix" ]]; then + rel_path="$prefix/$rel_path" + fi + + # Get file size + local size + if command -v stat >/dev/null 2>&1; then + if [[ "$(uname -s)" == "Darwin" ]]; then + size=$(stat -f%z "$file" 2>/dev/null || echo "0") + else + size=$(stat -c%s "$file" 2>/dev/null || echo "0") + fi + else + size=$(wc -c < "$file" 2>/dev/null || echo "0") + fi + + # Output: path|size + echo "$rel_path|$size" + done | sort > "$output" + else + log_error "find command not available" + popd >/dev/null + return 1 + fi + + popd >/dev/null + + local count + count=$(wc -l < "$output" 2>/dev/null || echo "0") + log_ok "Generated manifest with $count files" + + if [[ $VERBOSE == "1" ]]; then + log_info "First 10 entries:" + head -10 "$output" + fi +} + +# Generate manifest for a zip file +# Usage: generate_zip_manifest +generate_zip_manifest() { + local zip_file="$1" + local output="$2" + + log_action "Generating manifest for zip: $zip_file" + + if [[ ! -f "$zip_file" ]]; then + log_error "Zip file not found: $zip_file" + return 1 + fi + + # Try to use unzip first, then 7z + if command -v unzip >/dev/null 2>&1; then + # unzip -Zv shows detailed info including size + # Format: " 2345 Defl:N 1234 47% 2024-01-01 12:00 12345678 filename" + unzip -Zv "$zip_file" 2>/dev/null | grep -v "^Archive\|^Length\|^---------\|^$" | while read -r line; do + # Extract size (first column) and filename (last column) + local size + local filename + size=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $NF}') + + # Skip if size is not a number or filename is empty + if ! [[ "$size" =~ ^[0-9]+$ ]] || [[ -z "$filename" ]]; then + continue + fi + + # Skip directories (end with /) + if [[ "$filename" == */ ]]; then + continue + fi + + # Output: path|size + echo "$filename|$size" + done | sort > "$output" + elif command -v 7z >/dev/null 2>&1; then + # 7z l -slt shows detailed info + local temp_file + temp_file=$(mktemp) + 7z l -slt "$zip_file" > "$temp_file" 2>/dev/null + + # Parse 7z output + local path="" + local size="" + while IFS= read -r line; do + if [[ "$line" == Path=* ]]; then + path="${line#Path=}" + elif [[ "$line" == Size=* ]]; then + size="${line#Size=}" + elif [[ "$line" == *"---"* ]]; then + # End of entry + if [[ -n "$path" ]] && [[ -n "$size" ]] && [[ "$path" != *"/" ]]; then + echo "$path|$size" + fi + path="" + size="" + fi + done < "$temp_file" | sort > "$output" + + rm -f "$temp_file" + else + log_error "Neither unzip nor 7z command is available" + return 1 + fi + + local count + count=$(wc -l < "$output" 2>/dev/null || echo "0") + log_ok "Generated zip manifest with $count files" + + if [[ $VERBOSE == "1" ]]; then + log_info "First 10 entries:" + head -10 "$output" + fi +} + +# Compare two manifests +# Usage: compare_manifests +compare_manifests() { + local manifest1="$1" + local manifest2="$2" + local label1="$3" + local label2="$4" + + log_header "Comparing Manifests" + log_info " $label1: $manifest1" + log_info " $label2: $manifest2" + + # Create temporary files for analysis + local missing_files + local extra_files + local different_sizes + missing_files=$(mktemp) + extra_files=$(mktemp) + different_sizes=$(mktemp) + + # Read manifests into associative arrays + declare -A map1 + declare -A map2 + + # Read manifest1 + while IFS='|' read -r path size; do + if [[ -n "$path" ]] && [[ -n "$size" ]]; then + map1["$path"]="$size" + fi + done < "$manifest1" + + # Read manifest2 + while IFS='|' read -r path size; do + if [[ -n "$path" ]] && [[ -n "$size" ]]; then + map2["$path"]="$size" + fi + done < "$manifest2" + + # Find missing files (in map1 but not in map2) + for path in "${!map1[@]}"; do + if [[ -z "${map2[$path]+x}" ]]; then + # File is in map1 but not in map2 + echo "$path|${map1[$path]}" >> "$missing_files" + elif [[ "${map1[$path]}" != "${map2[$path]}" ]]; then + # Same path, different size + echo "$path|${map1[$path]}|${map2[$path]}" >> "$different_sizes" + fi + done + + # Find extra files (in map2 but not in map1) + for path in "${!map2[@]}"; do + if [[ -z "${map1[$path]+x}" ]]; then + echo "$path|${map2[$path]}" >> "$extra_files" + fi + done + + # Count differences + local missing_count + local extra_count + local different_count + missing_count=$(wc -l < "$missing_files" 2>/dev/null || echo "0") + extra_count=$(wc -l < "$extra_files" 2>/dev/null || echo "0") + different_count=$(wc -l < "$different_sizes" 2>/dev/null || echo "0") + + local total_differences=$((missing_count + extra_count + different_count)) + + # Report results + log_info "" + log_info "Comparison Summary:" + log_info " Total files in $label1: ${#map1[@]}" + log_info " Total files in $label2: ${#map2[@]}" + log_info " Missing files: $missing_count" + log_info " Extra files: $extra_count" + log_info " Different sizes: $different_count" + log_info " Total differences: $total_differences" + + if [[ $total_differences -gt 0 ]]; then + log_warn "" + log_warn "========================================" + log_warn "DIFFERENCES FOUND!" + log_warn "========================================" + + if [[ $missing_count -gt 0 ]]; then + log_warn "" + log_warn "--- Missing Files (in $label1 but not in $label2) ---" + log_warn "" + while IFS='|' read -r path size; do + log_warn " [MISSING] $path ($size bytes)" + done < "$missing_files" + fi + + if [[ $extra_count -gt 0 ]]; then + log_warn "" + log_warn "--- Extra Files (in $label2 but not in $label1) ---" + log_warn "" + while IFS='|' read -r path size; do + log_warn " [EXTRA] $path ($size bytes)" + done < "$extra_files" + fi + + if [[ $different_count -gt 0 ]]; then + log_warn "" + log_warn "--- Different Sizes ---" + log_warn "" + while IFS='|' read -r path size1 size2; do + log_warn " [DIFF] $path: $size1 bytes vs $size2 bytes" + done < "$different_sizes" + fi + + log_warn "" + log_warn "========================================" + fi + + # Cleanup + rm -f "$missing_files" "$extra_files" "$different_sizes" + + # Return count of differences + return $total_differences +} + +# ===================================== +# Main +# ===================================== + +log_header "ActivityWatch Windows Artifacts Verification" +log_info "" +log_info "Configuration:" +log_info " TAURI_BUILD: $TAURI_BUILD" +log_info " DIST_DIR: $DIST_DIR" +log_info " STRICT_MODE: $STRICT_MODE" +log_info " VERBOSE: $VERBOSE" + +# Check if we're on Windows (or cross-compiling) +# For now, we check if dist/activitywatch directory exists +log_section "Validating Environment" + +ACTIVITYWATCH_DIR="$DIST_DIR/activitywatch" + +if [[ ! -d "$ACTIVITYWATCH_DIR" ]]; then + log_error "Source directory not found: $ACTIVITYWATCH_DIR" + log_info "Run 'make package' first to create the dist directory." + exit 1 +fi +log_ok "Source directory found: $ACTIVITYWATCH_DIR" + +# Find zip file +log_section "Finding Distribution Artifacts" + +ZIP_FILE="" +while IFS= read -r -d '' file; do + # Skip if not a real zip (e.g., temporary files) + if [[ "$file" == *"activitywatch"* ]]; then + ZIP_FILE="$file" + break + fi +done < <(find "$DIST_DIR" -maxdepth 1 -name "activitywatch*.zip" -print0 2>/dev/null || true) + +if [[ -z "$ZIP_FILE" ]] || [[ ! -f "$ZIP_FILE" ]]; then + log_error "Zip file not found in $DIST_DIR" + log_info "Run 'make package' first to create the zip archive." + exit 1 +fi +log_ok "Zip file found: $ZIP_FILE" + +# Check for Tauri-specific files +if [[ $TAURI_BUILD == "true" ]]; then + log_info "" + log_info "Tauri build detected." + + # Check for aw-tauri.exe + if [[ -f "$ACTIVITYWATCH_DIR/aw-tauri.exe" ]]; then + log_ok "Found aw-tauri.exe in source directory" + elif [[ -f "$ACTIVITYWATCH_DIR/aw-tauri/aw-tauri.exe" ]]; then + log_ok "Found aw-tauri.exe in aw-tauri/ subdirectory" + else + log_warn "aw-tauri.exe not found in source directory" + fi +else + log_info "" + log_info "Standard (aw-qt) build detected." + + # Check for aw-qt.exe + if [[ -f "$ACTIVITYWATCH_DIR/aw-qt.exe" ]]; then + log_ok "Found aw-qt.exe in source directory" + else + log_warn "aw-qt.exe not found in source directory" + fi +fi + +# Generate manifests +log_section "Generating Manifests" + +# Create temporary directory for manifests +MANIFEST_DIR=$(mktemp -d) +trap 'rm -rf "$MANIFEST_DIR"' EXIT + +SOURCE_MANIFEST="$MANIFEST_DIR/source-manifest.txt" +ZIP_MANIFEST="$MANIFEST_DIR/zip-manifest.txt" + +# Generate source manifest +if ! generate_dir_manifest "$ACTIVITYWATCH_DIR" "$SOURCE_MANIFEST"; then + log_error "Failed to generate source manifest" + exit 1 +fi + +# Generate zip manifest +if ! generate_zip_manifest "$ZIP_FILE" "$ZIP_MANIFEST"; then + log_error "Failed to generate zip manifest" + exit 1 +fi + +# Compare manifests +log_section "Comparing Source Directory vs Zip Archive" + +if compare_manifests "$SOURCE_MANIFEST" "$ZIP_MANIFEST" "Source Directory" "Zip Archive"; then + # No differences + log_header "RESULT: ALL CHECKS PASSED" + log_ok "Source directory and zip archive contain the same files." + log_ok "Windows release consistency verified!" + exit 0 +else + # Differences found + local diff_count=$? + + log_header "RESULT: DIFFERENCES FOUND" + log_warn "Found $diff_count difference(s) between source directory and zip archive." + log_warn "" + log_warn "This may indicate:" + log_warn " - Files were added/removed during zip creation" + log_warn " - Inno Setup is installing different files than zip" + log_warn "" + + if [[ $STRICT_MODE == "true" ]]; then + log_error "Exiting with non-zero code (strict mode)" + exit 2 + else + log_info "Use --strict or set STRICT_MODE=true to exit with non-zero code." + exit 0 + fi +fi diff --git a/scripts/tests/integration_tests.py b/scripts/tests/integration_tests.py index 335ac67a1..57e2be819 100644 --- a/scripts/tests/integration_tests.py +++ b/scripts/tests/integration_tests.py @@ -1,84 +1,600 @@ +""" +ActivityWatch Integration Tests +=============================== + +This module provides robust, reproducible, and diagnosable integration tests. + +Features: +- Health polling instead of fixed sleep +- Environment variable configuration +- Detailed log summary on failure/timeout +- Real API assertions (not just "did it start") +- Clean process cleanup +- Global timeout protection (prevents hanging) +- Clear error classification for diagnosis + +Error Classification: +- LAUNCH_FAILED: Server process failed to start (executable not found, etc.) +- EARLY_EXIT: Server started but exited prematurely with error code +- STARTUP_TIMEOUT: Server started but never became responsive within timeout +- API_ERROR: API request returned non-200 status +- ASSERTION_FAILED: API assertion failed (missing fields, wrong types) +- LOG_ERROR: Server logs contain ERROR/panic indicators + +Environment Variables: + AW_SERVER_BIN: Path to the aw-server binary (default: "aw-server" from PATH) + AW_SERVER_PORT: Port to use (default: 5666 for testing) + AW_SERVER_ARGS: Extra arguments to pass to server (default: "--testing") + AW_SERVER_TIMEOUT: Startup timeout in seconds (default: 30) + AW_SERVER_POLL: Poll interval in seconds (default: 1) + AW_LOG_LINES: Number of log lines to show on failure (default: 100) + AW_TEST_TIMEOUT: Global test timeout in seconds (default: 120) + +Local Usage: + # Run with default settings (requires aw-server in PATH) + pytest scripts/tests/integration_tests.py -v + + # Run with specific server binary + AW_SERVER_BIN=./dist/activitywatch/aw-server-rust/aw-server \ + pytest scripts/tests/integration_tests.py -v + + # Run with custom port and timeout + AW_SERVER_PORT=5777 AW_SERVER_TIMEOUT=60 AW_TEST_TIMEOUT=180 \ + pytest scripts/tests/integration_tests.py -v +""" + import os import platform import subprocess import tempfile -from time import sleep +import time +import shutil +import json +import signal +import atexit +import sys +from enum import Enum, auto +from typing import Optional, Tuple, Dict, Any, List import pytest -def _windows_kill_process(pid): - import ctypes +try: + import urllib.request + import urllib.error + HAS_URLLIB = True +except ImportError: + HAS_URLLIB = False + +try: + import psutil + HAS_PSUTIL = True +except ImportError: + HAS_PSUTIL = False + + +class ErrorType(Enum): + LAUNCH_FAILED = auto() + EARLY_EXIT = auto() + STARTUP_TIMEOUT = auto() + API_ERROR = auto() + ASSERTION_FAILED = auto() + LOG_ERROR = auto() + + +GLOBAL_SERVER_PROCESS: Optional['ServerProcess'] = None +GLOBAL_TEST_TIMEOUT: int = 120 + +def _windows_kill_process(pid: int): + import ctypes PROCESS_TERMINATE = 1 handle = ctypes.windll.kernel32.OpenProcess(PROCESS_TERMINATE, False, pid) ctypes.windll.kernel32.TerminateProcess(handle, -1) ctypes.windll.kernel32.CloseHandle(handle) -# NOTE: to run tests with a specific server binary, -# set the PATH such that it is the "aw-server" binary. -@pytest.fixture(scope="session") -def server_process(): - logfile_stdout = tempfile.NamedTemporaryFile(delete=False) - logfile_stderr = tempfile.NamedTemporaryFile(delete=False) +def _kill_process_tree(pid: int, timeout: int = 10): + if HAS_PSUTIL: + try: + parent = psutil.Process(pid) + children = parent.children(recursive=True) + + for child in children: + try: + child.kill() + except psutil.NoSuchProcess: + pass + + try: + parent.kill() + except psutil.NoSuchProcess: + pass + + _, still_alive = psutil.wait_procs([parent] + children, timeout=timeout) + + for p in still_alive: + try: + p.kill() + except psutil.NoSuchProcess: + pass + except psutil.NoSuchProcess: + pass + else: + if platform.system() == "Windows": + _windows_kill_process(pid) + else: + try: + os.killpg(os.getpgid(pid), signal.SIGKILL) + except ProcessLookupError: + pass + except OSError: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass - # find the path of the "aw-server" binary and log it - which_server = subprocess.check_output(["which", "aw-server"], text=True) - print(f"aw-server path: {which_server}") - # if aw-server-rust in PATH, assert that we're picking up the aw-server-rust binary - if "aw-server-rust" in os.environ["PATH"]: - assert "aw-server-rust" in which_server +def _read_tail(filepath: str, lines: int = 100) -> str: + try: + with open(filepath, "rb") as f: + f.seek(0, 2) + file_size = f.tell() + + if file_size == 0: + return "(empty)" + + block_size = 4096 + blocks = [] + current_pos = file_size + + while len(blocks) < lines and current_pos > 0: + read_size = min(block_size, current_pos) + current_pos -= read_size + f.seek(current_pos) + block = f.read(read_size) + blocks.append(block) + + content = b"".join(reversed(blocks)).decode("utf-8", errors="replace") + all_lines = content.splitlines() + + if len(all_lines) > lines: + return "\n".join(all_lines[-lines:]) + return content + except Exception as e: + return f"(error reading log: {e})" - server_proc = subprocess.Popen( - ["aw-server", "--testing"], stdout=logfile_stdout, stderr=logfile_stderr - ) - # Wait for server to start up properly - # TODO: Ping the server until it's alive to remove this sleep - sleep(5) +class TestFailure(Exception): + def __init__(self, error_type: ErrorType, message: str, details: Optional[Dict[str, Any]] = None): + self.error_type = error_type + self.details = details or {} + super().__init__(message) - yield server_proc - if platform.system() == "Windows": - # On Windows, for whatever reason, server_proc.kill() doesn't do the job. - _windows_kill_process(server_proc.pid) - else: - server_proc.kill() - server_proc.wait(5) - server_proc.communicate() +class ServerConfig: + def __init__(self): + self.server_bin = os.environ.get("AW_SERVER_BIN", "aw-server") + self.port = int(os.environ.get("AW_SERVER_PORT", "5666")) + self.timeout = int(os.environ.get("AW_SERVER_TIMEOUT", "30")) + self.poll_interval = float(os.environ.get("AW_SERVER_POLL", "1.0")) + self.log_lines = int(os.environ.get("AW_LOG_LINES", "100")) + self.test_timeout = int(os.environ.get("AW_TEST_TIMEOUT", "120")) + + extra_args = os.environ.get("AW_SERVER_ARGS", "") + if extra_args: + self.args = extra_args.split() + else: + self.args = ["--testing"] + + if "--port" not in " ".join(self.args) and "-p" not in " ".join(self.args): + self.args.extend(["--port", str(self.port)]) + + def base_url(self) -> str: + return f"http://localhost:{self.port}" + + def api_url(self, endpoint: str) -> str: + endpoint = endpoint.lstrip("/") + if endpoint.startswith("api/") or endpoint.startswith("0/"): + return f"{self.base_url()}/{endpoint}" + return f"{self.base_url()}/api/0/{endpoint}" + + +class ServerProcess: + def __init__(self, config: ServerConfig): + self.config = config + self.process: Optional[subprocess.Popen] = None + self.stdout_path: Optional[str] = None + self.stderr_path: Optional[str] = None + self._cleanup_called = False + self._start_time: Optional[float] = None + self._exit_code: Optional[int] = None + + def start(self): + global GLOBAL_SERVER_PROCESS + GLOBAL_SERVER_PROCESS = self + + print(f"\n{'='*80}") + print(f"[TEST SETUP] Starting server process") + print(f"{'='*80}") + + which_server = shutil.which(self.config.server_bin) + if which_server: + print(f"[INFO] Server binary: {which_server}") + resolved_bin = which_server + else: + print(f"[WARN] Server binary not found in PATH: {self.config.server_bin}") + resolved_bin = self.config.server_bin + + print(f"[INFO] Server port: {self.config.port}") + print(f"[INFO] Server args: {' '.join(self.config.args)}") + print(f"[INFO] Startup timeout: {self.config.timeout}s") + print(f"[INFO] Global test timeout: {self.config.test_timeout}s") + + stdout_file = tempfile.NamedTemporaryFile(delete=False, mode="w+", suffix=".stdout.log") + stderr_file = tempfile.NamedTemporaryFile(delete=False, mode="w+", suffix=".stderr.log") + self.stdout_path = stdout_file.name + self.stderr_path = stderr_file.name + + print(f"[INFO] stdout log: {self.stdout_path}") + print(f"[INFO] stderr log: {self.stderr_path}") + + cmd = [resolved_bin] + self.config.args + print(f"[INFO] Starting server: {' '.join(cmd)}") + print(f"{'='*80}\n") + + try: + self._start_time = time.time() + + if platform.system() == "Windows": + self.process = subprocess.Popen( + cmd, + stdout=stdout_file, + stderr=stderr_file, + bufsize=1, + text=True + ) + else: + self.process = subprocess.Popen( + cmd, + stdout=stdout_file, + stderr=stderr_file, + bufsize=1, + text=True, + preexec_fn=os.setsid + ) + except FileNotFoundError as e: + stdout_file.close() + stderr_file.close() + self._cleanup_logs() + raise TestFailure( + ErrorType.LAUNCH_FAILED, + f"Server binary not found: {self.config.server_bin}", + {"error": str(e), "binary": self.config.server_bin} + ) + except Exception as e: + stdout_file.close() + stderr_file.close() + self._cleanup_logs() + raise TestFailure( + ErrorType.LAUNCH_FAILED, + f"Failed to launch server: {e}", + {"error": str(e), "binary": self.config.server_bin} + ) + + stdout_file.close() + stderr_file.close() + + def _print_diagnostic_summary(self, error_type: ErrorType, context: str): + lines = self.config.log_lines + elapsed = "" + if self._start_time: + elapsed = f" (elapsed: {time.time() - self._start_time:.1f}s)" + + print(f"\n{'='*80}") + print(f"[DIAGNOSIS] {context}{elapsed}") + print(f"{'='*80}") + print(f"Error Type: {error_type.name}") + print(f"Server Binary: {self.config.server_bin}") + print(f"Server Port: {self.config.port}") + print(f"Server PID: {self.process.pid if self.process else 'N/A'}") + print(f"Exit Code: {self._exit_code if self._exit_code is not None else 'still running'}") + print(f"{'='*80}") + + print(f"\n--- STDOUT (last {lines} lines) ---") + if self.stdout_path: + print(_read_tail(self.stdout_path, lines)) + else: + print("(no stdout log)") + + print(f"\n--- STDERR (last {lines} lines) ---") + if self.stderr_path: + print(_read_tail(self.stderr_path, lines)) + else: + print("(no stderr log)") + + print(f"\n{'='*80}") + print(f"[END DIAGNOSIS]") + print(f"{'='*80}\n") + + def _make_request(self, endpoint: str, method: str = "GET") -> Tuple[int, Optional[Dict[str, Any]]]: + url = self.config.api_url(endpoint) + try: + req = urllib.request.Request(url, method=method) + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8") + try: + return resp.status, json.loads(body) + except json.JSONDecodeError: + return resp.status, {"_raw": body} + except urllib.error.HTTPError as e: + return e.code, None + except Exception: + return -1, None + + def is_alive(self) -> bool: + if not HAS_URLLIB: + return self.process.poll() is None + + status, data = self._make_request("info") + return status == 200 and data is not None + + def wait_for_ready(self) -> bool: + print(f"[INFO] Waiting for server to be ready (timeout: {self.config.timeout}s)...") + + start_time = time.time() + poll_count = 0 + last_exit_check = 0 + + while time.time() - start_time < self.config.timeout: + exit_code = self.process.poll() + if exit_code is not None: + self._exit_code = exit_code + if exit_code != 0: + self._print_diagnostic_summary( + ErrorType.EARLY_EXIT, + f"Server exited prematurely with code: {exit_code}" + ) + return False + else: + print(f"[INFO] Server exited with code 0, checking if it became responsive first...") + + if self.is_alive(): + elapsed = time.time() - start_time + print(f"[INFO] Server is ready after {elapsed:.1f}s ({poll_count} polls)") + return True + + poll_count += 1 + time.sleep(self.config.poll_interval) + + self._exit_code = self.process.poll() + self._print_diagnostic_summary( + ErrorType.STARTUP_TIMEOUT, + f"Server did not become ready within {self.config.timeout}s" + ) + return False + + def check_for_errors(self) -> Tuple[bool, List[str]]: + error_indicators = ["ERROR", "panic", "thread '", "stack backtrace", "fatal", "Error:"] + + found_errors = [] + + if self.stdout_path: + with open(self.stdout_path, "r", encoding="utf-8", errors="replace") as f: + stdout = f.read() + for indicator in error_indicators: + if indicator in stdout: + found_errors.append(f"stdout contains '{indicator}'") + + if self.stderr_path: + with open(self.stderr_path, "r", encoding="utf-8", errors="replace") as f: + stderr = f.read() + for indicator in error_indicators: + if indicator in stderr: + found_errors.append(f"stderr contains '{indicator}'") + + return len(found_errors) == 0, found_errors + + def get_api_info(self) -> Dict[str, Any]: + if not HAS_URLLIB: + pytest.skip("urllib not available") + + status, data = self._make_request("info") + + if status != 200 or data is None: + self._print_diagnostic_summary( + ErrorType.API_ERROR, + f"GET /api/0/info failed with status {status}" + ) + raise TestFailure( + ErrorType.API_ERROR, + f"GET /api/0/info failed with status {status}", + {"status": status, "endpoint": "/api/0/info"} + ) + + return data + + def get_api_buckets(self) -> Dict[str, Any]: + if not HAS_URLLIB: + pytest.skip("urllib not available") + + status, data = self._make_request("buckets") + + if status != 200: + self._print_diagnostic_summary( + ErrorType.API_ERROR, + f"GET /api/0/buckets failed with status {status}" + ) + raise TestFailure( + ErrorType.API_ERROR, + f"GET /api/0/buckets failed with status {status}", + {"status": status, "endpoint": "/api/0/buckets"} + ) + + return data + + def _cleanup_logs(self): + for path in [self.stdout_path, self.stderr_path]: + if path and os.path.exists(path): + try: + os.remove(path) + except Exception: + pass + + def cleanup(self): + if self._cleanup_called: + return + self._cleanup_called = True + + global GLOBAL_SERVER_PROCESS + if GLOBAL_SERVER_PROCESS is self: + GLOBAL_SERVER_PROCESS = None + + if self.process: + print(f"\n[INFO] Stopping server (PID: {self.process.pid})...") + + try: + if platform.system() == "Windows": + _kill_process_tree(self.process.pid) + else: + _kill_process_tree(self.process.pid) + + try: + self.process.wait(timeout=10) + self._exit_code = self.process.returncode + print(f"[INFO] Server stopped with code: {self._exit_code}") + except subprocess.TimeoutExpired: + print(f"[WARN] Server did not terminate gracefully, process may still be running") + except Exception as e: + print(f"[WARN] Error during cleanup: {e}") + + ok, errors = self.check_for_errors() + if not ok: + self._print_diagnostic_summary( + ErrorType.LOG_ERROR, + f"Error indicators found in logs: {errors}" + ) + + self._cleanup_logs() + + +def _global_cleanup(): + global GLOBAL_SERVER_PROCESS + if GLOBAL_SERVER_PROCESS is not None: + print(f"\n[GLOBAL CLEANUP] Forcefully stopping orphaned server process...") + GLOBAL_SERVER_PROCESS.cleanup() - error_indicators = ["ERROR"] - with open(logfile_stdout.name, "r+b") as f: - stdout = str(f.read(), "utf8") - if any(e in stdout for e in error_indicators): - pytest.fail(f"Found ERROR indicator in stdout from server: {stdout}") +atexit.register(_global_cleanup) - with open(logfile_stderr.name, "r+b") as f: - stderr = str(f.read(), "utf8") - # For some reason, this fails aw-server-rust, but not aw-server-python - # if not stderr: - # pytest.fail("No output to stderr from server") - # Will show in case pytest fails - print(stderr) +def get_pytest_timeout_marker(): + try: + import pytest_timeout + return pytest.mark.timeout + except ImportError: + return lambda *args, **kwargs: lambda x: x + + +@pytest.fixture(scope="session") +def server_config(): + return ServerConfig() + + +@pytest.fixture(scope="session") +def server_process(server_config): + server = ServerProcess(server_config) + + try: + server.start() + + if not server.wait_for_ready(): + server.cleanup() + pytest.fail("Server failed to start within timeout") + + yield server + + ok, errors = server.check_for_errors() + if not ok: + server._print_diagnostic_summary( + ErrorType.LOG_ERROR, + f"Error indicators found after tests: {errors}" + ) + pytest.fail(f"Error indicators found in server logs: {errors}") + + finally: + server.cleanup() + - for s in error_indicators: - if s in stderr: - pytest.fail(f"Found ERROR indicator in stderr from server: {s}") +timeout_marker = get_pytest_timeout_marker() - # NOTE: returncode was -9 for whatever reason - # if server_proc.returncode != 0: - # pytest.fail("Exit code was non-zero ({})".format(server_proc.returncode)) +class TestServerBasics: + + @timeout_marker(60) + def test_server_starts(self, server_process): + assert server_process.is_alive(), "Server should be alive after startup" + print(f"[PASS] Server is running and responsive") + + @timeout_marker(60) + def test_api_info_endpoint(self, server_process): + print(f"\n[TEST] Testing /api/0/info endpoint...") + info = server_process.get_api_info() + + print(f"[INFO] API response keys: {list(info.keys())}") + + if "version" not in info: + server_process._print_diagnostic_summary( + ErrorType.ASSERTION_FAILED, + "API /info response missing 'version' field" + ) + pytest.fail("API /info should contain 'version' field") + + print(f"[INFO] Server version from API: {info.get('version')}") + + if "hostname" not in info: + server_process._print_diagnostic_summary( + ErrorType.ASSERTION_FAILED, + "API /info response missing 'hostname' field" + ) + pytest.fail("API /info should contain 'hostname' field") + + print(f"[INFO] Server hostname: {info.get('hostname')}") + print(f"[PASS] /api/0/info endpoint is working correctly") + + @timeout_marker(60) + def test_api_buckets_endpoint(self, server_process): + print(f"\n[TEST] Testing /api/0/buckets endpoint...") + buckets = server_process.get_api_buckets() + + is_valid = isinstance(buckets, dict) or isinstance(buckets, list) + + if not is_valid: + server_process._print_diagnostic_summary( + ErrorType.ASSERTION_FAILED, + f"API /buckets returned unexpected type: {type(buckets)}" + ) + pytest.fail(f"API /buckets should return dict or list, got {type(buckets)}") + + if isinstance(buckets, dict): + print(f"[INFO] Found {len(buckets)} buckets (as dict)") + elif isinstance(buckets, list): + print(f"[INFO] Found {len(buckets)} buckets (as list)") + + print(f"[PASS] /api/0/buckets endpoint is working correctly") -# TODO: Use the fixture in the tests instead of this thing here -def test_integration(server_process): - # This is just here so that the server_process fixture is initialized - pass - # exit_code = pytest.main(["./aw-server/tests", "-v"]) - # if exit_code != 0: - # pytest.fail("Tests exited with non-zero code: " + str(exit_code)) +class TestServerHealth: + + @timeout_marker(60) + def test_no_error_indicators_in_logs(self, server_process): + ok, errors = server_process.check_for_errors() + + if not ok: + server_process._print_diagnostic_summary( + ErrorType.LOG_ERROR, + f"Error indicators found: {errors}" + ) + pytest.fail(f"Error indicators found in server logs: {errors}") + + print(f"[PASS] No error indicators found in logs")