From 869d6dcbb3bbe4018923f18e14be59a1404d61e9 Mon Sep 17 00:00:00 2001 From: arzafran Date: Mon, 19 Oct 2026 13:12:13 -0300 Subject: [PATCH] test: triage never-run tests and run the survivors nightly --- .github/workflows/nightly-extended-tests.yml | 476 ++++++++++++++++++ docs/testing-layout.md | 10 +- programaUITests/SidebarResizeUITests.swift | 26 +- .../WorkspaceDescriptionUITests.swift | 50 +- scripts/nightly-ui-classes.txt | 35 ++ scripts/run-tests-v2-ci.sh | 48 +- tests_v2/ci_subset.txt | 5 +- tests_v2/nightly_subset.txt | 47 ++ tests_v2/test_codex_install_integration.py | 2 +- ..._switcher_cross_workspace_surface_focus.py | 168 ------- ...ommand_palette_switcher_renamed_surface.py | 156 ------ ...and_palette_switcher_surface_precedence.py | 151 ------ ...st_command_palette_switcher_type_labels.py | 123 ----- tests_v2/test_cpu_notifications.py | 297 ----------- tests_v2/test_cpu_usage.py | 220 -------- tests_v2/test_ctrl_enter_keybind.py | 183 ------- tests_v2/test_focus_notification_dismiss.py | 4 +- .../test_nested_split_does_not_disappear.py | 114 ----- .../test_new_tab_interactive_after_splits.py | 6 +- tests_v2/test_new_tab_render_after_splits.py | 176 ------- ...t_pane_resize_preserves_visible_content.py | 1 + .../test_rename_window_workspace_parity.py | 2 +- tests_v2/test_signals_auto.py | 278 ---------- tests_v2/test_terminal_input_render_report.py | 283 ----------- 24 files changed, 639 insertions(+), 2222 deletions(-) create mode 100644 .github/workflows/nightly-extended-tests.yml create mode 100644 scripts/nightly-ui-classes.txt create mode 100644 tests_v2/nightly_subset.txt delete mode 100644 tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py delete mode 100644 tests_v2/test_command_palette_switcher_renamed_surface.py delete mode 100644 tests_v2/test_command_palette_switcher_surface_precedence.py delete mode 100644 tests_v2/test_command_palette_switcher_type_labels.py delete mode 100644 tests_v2/test_cpu_notifications.py delete mode 100644 tests_v2/test_cpu_usage.py delete mode 100644 tests_v2/test_ctrl_enter_keybind.py delete mode 100755 tests_v2/test_nested_split_does_not_disappear.py delete mode 100644 tests_v2/test_new_tab_render_after_splits.py delete mode 100644 tests_v2/test_signals_auto.py delete mode 100644 tests_v2/test_terminal_input_render_report.py diff --git a/.github/workflows/nightly-extended-tests.yml b/.github/workflows/nightly-extended-tests.yml new file mode 100644 index 000000000..aea0073d7 --- /dev/null +++ b/.github/workflows/nightly-extended-tests.yml @@ -0,0 +1,476 @@ +name: Extended tests + +# Behavior coverage that is too slow, too focus-sensitive or too load-sensitive to +# gate every PR, run nightly against main: +# - the tests_v2 files listed in tests_v2/nightly_subset.txt +# - the XCUITest classes listed in scripts/nightly-ui-classes.txt +# Failures here do not block merges; read the job summaries each morning. +# +# On-demand runs take explicit lists, which is also how a new test is triaged +# before it is added to the nightly lists: +# gh workflow run nightly-extended-tests.yml --ref \ +# -f v2_files="test_a.py test_b.py" -f ui_classes="SomeUITests" +# +# scripts/nightly-ui-classes.txt takes one class per line; a line "!Class/testMethod" +# skips one test method of a listed class (a known failure waiting for an owner). + +on: + schedule: + - cron: "17 5 * * *" + workflow_dispatch: + inputs: + v2_files: + description: >- + Space- or newline-separated tests_v2 basenames. Empty runs + tests_v2/nightly_subset.txt. + type: string + default: "" + ui_classes: + description: >- + Space- or newline-separated programaUITests class names. Empty runs + scripts/nightly-ui-classes.txt. + type: string + default: "" + run_v2: + description: Run the tests_v2 jobs + type: boolean + default: true + run_ui: + description: Run the UI test job + type: boolean + default: true + v2_attempts: + description: Attempts per tests_v2 file; a pass on a later attempt is reported as FLAKY + type: string + default: "2" + +permissions: + contents: read + +jobs: + build-app: + runs-on: macos-26 + timeout-minutes: 45 + env: + PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-app + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select Xcode + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + if [ -z "$XCODE_APP" ]; then + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + fi + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + sudo xcode-select -s "$XCODE_DIR" || true + xcodebuild -version + + - name: Resolve Ghostty revision + id: ghostty-revision + run: | + set -euo pipefail + revision="$(./scripts/ghostty_cache_revision.sh)" + printf 'revision=%s\n' "$revision" >> "$GITHUB_OUTPUT" + + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit-app + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-v3-${{ steps.ghostty-revision.outputs.revision }} + + - name: Download pre-built GhosttyKit.xcframework + if: steps.cache-ghosttykit-app.outputs.cache-hit != 'true' + run: | + ./scripts/download-prebuilt-ghosttykit.sh + + - name: Resolve zig version + id: zig-version + run: printf 'version=%s\n' "$(./scripts/required-zig-version.sh)" >> "$GITHUB_OUTPUT" + + - name: Cache zig toolchain tarball + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/.cache/programa-zig-dist + key: zig-dist-macos-arm64-${{ steps.zig-version.outputs.version }} + + - name: Install zig + run: ./scripts/install-zig.sh + + - name: Cache Zig packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/.cache/zig + key: zig-packages-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }} + restore-keys: zig-packages- + + - name: Cache Ghostty CLI helper build + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ghostty/.zig-cache + key: ghostty-cli-helper-${{ steps.ghostty-revision.outputs.revision }}-zig${{ steps.zig-version.outputs.version }} + + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ${{ env.PROGRAMA_DERIVED_DATA_DIR }} + key: deriveddata-build-app-macos26-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-build-app-macos26-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-build-app-macos26- + + - name: Cache Swift packages + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-build-app-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-build-app- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Build app and UI test bundle + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -derivedDataPath "$PROGRAMA_DERIVED_DATA_DIR" \ + -destination "platform=macOS" \ + build-for-testing + + - name: Package build artifact + run: | + set -euo pipefail + tar -czf app-build.tar.gz -C "$PROGRAMA_DERIVED_DATA_DIR" Build/Products + + - name: Upload build artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: programa-app-build + path: app-build.tar.gz + if-no-files-found: error + retention-days: 1 + + tests-v2: + needs: build-app + if: github.event_name != 'workflow_dispatch' || inputs.run_v2 + runs-on: macos-26 + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + env: + PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-socket-integration + V2_FILES: ${{ github.event_name == 'workflow_dispatch' && inputs.v2_files || '' }} + V2_ATTEMPTS: ${{ github.event_name == 'workflow_dispatch' && inputs.v2_attempts || '2' }} + SHARD: ${{ matrix.shard }} + SHARD_COUNT: 3 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select this shard's tests + id: select + run: | + set -euo pipefail + LIST="$RUNNER_TEMP/v2-subset.txt" + if [ -n "${V2_FILES//[[:space:]]/}" ]; then + printf '%s\n' "$V2_FILES" | tr -s '[:space:]' '\n' | grep . > "$RUNNER_TEMP/v2-all.txt" + else + grep -v '^#' tests_v2/nightly_subset.txt | grep . > "$RUNNER_TEMP/v2-all.txt" || true + fi + if grep -qvE '^test_[A-Za-z0-9_]+\.py$' "$RUNNER_TEMP/v2-all.txt"; then + echo "Invalid test name in the list:" >&2 + grep -vE '^test_[A-Za-z0-9_]+\.py$' "$RUNNER_TEMP/v2-all.txt" >&2 + exit 1 + fi + awk -v s="$SHARD" -v n="$SHARD_COUNT" '(NR - 1) % n == s - 1' "$RUNNER_TEMP/v2-all.txt" > "$LIST" + echo "Shard $SHARD/$SHARD_COUNT:" && cat "$LIST" + if [ -s "$LIST" ]; then echo "run=true" >> "$GITHUB_OUTPUT"; else echo "run=false" >> "$GITHUB_OUTPUT"; fi + + - name: Download build artifact + if: steps.select.outputs.run == 'true' + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: programa-app-build + path: . + + - name: Extract build artifact + if: steps.select.outputs.run == 'true' + run: | + set -euo pipefail + mkdir -p "$PROGRAMA_DERIVED_DATA_DIR" + tar -xzf app-build.tar.gz -C "$PROGRAMA_DERIVED_DATA_DIR" + + - name: Create virtual display + if: steps.select.outputs.run == 'true' + run: | + set -euo pipefail + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + /tmp/create-virtual-display & + VDISPLAY_PID=$! + echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" + sleep 3 + kill -0 "$VDISPLAY_PID" + + - name: Run tests_v2 files + if: steps.select.outputs.run == 'true' + env: + PROGRAMA_TESTS_V2_SUBSET_FILE: ${{ runner.temp }}/v2-subset.txt + PROGRAMA_TESTS_V2_ATTEMPTS: ${{ env.V2_ATTEMPTS }} + PROGRAMA_TESTS_V2_KEEP_GOING: "1" + PROGRAMA_TESTS_V2_TEST_TIMEOUT: "300" + PROGRAMA_TESTS_V2_RESULTS_FILE: ${{ runner.temp }}/v2-results.txt + run: ./scripts/run-tests-v2-ci.sh + + - name: Summarize results + if: always() && steps.select.outputs.run == 'true' + run: | + { + echo "### tests_v2 shard $SHARD/$SHARD_COUNT" + echo '```' + cat "$RUNNER_TEMP/v2-results.txt" 2>/dev/null || echo "no results recorded" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + cp "$RUNNER_TEMP/v2-results.txt" "$RUNNER_TEMP/v2-results-shard$SHARD.txt" 2>/dev/null || true + + - name: Upload results + if: always() && steps.select.outputs.run == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: v2-results-shard${{ matrix.shard }} + path: ${{ runner.temp }}/v2-results-shard${{ matrix.shard }}.txt + if-no-files-found: ignore + retention-days: 14 + + - name: Cleanup virtual display + if: always() + run: | + if [ -n "${VDISPLAY_PID:-}" ]; then + kill "$VDISPLAY_PID" >/dev/null 2>&1 || true + fi + rm -f /tmp/create-virtual-display + + ui-tests: + needs: build-app + if: github.event_name != 'workflow_dispatch' || inputs.run_ui + runs-on: macos-26 + timeout-minutes: 120 + env: + PROGRAMA_DERIVED_DATA_DIR: ${{ github.workspace }}/.ci-derived-data-ui + UI_CLASSES: ${{ github.event_name == 'workflow_dispatch' && inputs.ui_classes || '' }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + submodules: recursive + + - name: Select UI test classes + id: select + run: | + set -euo pipefail + if [ -n "${UI_CLASSES//[[:space:]]/}" ]; then + printf '%s\n' "$UI_CLASSES" | tr -s '[:space:]' '\n' | grep . > "$RUNNER_TEMP/ui-classes.txt" + else + grep -v '^#' scripts/nightly-ui-classes.txt | grep . > "$RUNNER_TEMP/ui-classes.txt" || true + fi + if grep -qvE '^!?[A-Za-z0-9_]+(/[A-Za-z0-9_]+)?$' "$RUNNER_TEMP/ui-classes.txt"; then + echo "Invalid class name in the list:" >&2 + exit 1 + fi + cat "$RUNNER_TEMP/ui-classes.txt" + if grep -qv '^!' "$RUNNER_TEMP/ui-classes.txt"; then echo "run=true" >> "$GITHUB_OUTPUT"; else echo "run=false" >> "$GITHUB_OUTPUT"; fi + + - name: Select Xcode + if: steps.select.outputs.run == 'true' + run: | + set -euo pipefail + XCODE_APP="$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -n 1 || true)" + if [ -z "$XCODE_APP" ] && [ -d "/Applications/Xcode.app" ]; then + XCODE_APP="/Applications/Xcode.app" + fi + if [ -z "$XCODE_APP" ]; then + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + fi + if [ -z "$XCODE_APP" ]; then + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi + XCODE_DIR="$XCODE_APP/Contents/Developer" + echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" + export DEVELOPER_DIR="$XCODE_DIR" + sudo xcode-select -s "$XCODE_DIR" || true + xcodebuild -version + + # test-without-building still passes -clonedSourcePackagesDirPath / + # -disableAutomaticPackageResolution, so it needs the resolved SPM checkouts. + - name: Cache Swift packages + if: steps.select.outputs.run == 'true' + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: .ci-source-packages + key: spm-ui-regressions-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-ui-regressions- + + - name: Resolve Swift packages + if: steps.select.outputs.run == 'true' + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + + - name: Download build artifact + if: steps.select.outputs.run == 'true' + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: programa-app-build + path: . + + - name: Extract build artifact + if: steps.select.outputs.run == 'true' + run: | + set -euo pipefail + mkdir -p "$PROGRAMA_DERIVED_DATA_DIR" + tar -xzf app-build.tar.gz -C "$PROGRAMA_DERIVED_DATA_DIR" + + - name: Create persistent virtual display + if: steps.select.outputs.run == 'true' + run: | + set -euo pipefail + clang -framework Foundation -framework CoreGraphics \ + -o /tmp/create-virtual-display scripts/create-virtual-display.m + VDISPLAY_READY="/tmp/programa-vdisplay-persistent.ready" + VDISPLAY_ID_PATH="/tmp/programa-vdisplay-persistent.id" + rm -f "$VDISPLAY_READY" "$VDISPLAY_ID_PATH" + /tmp/create-virtual-display \ + --modes "1920x1080,1728x1117,1600x900,1440x810" \ + --ready-path "$VDISPLAY_READY" \ + --display-id-path "$VDISPLAY_ID_PATH" \ + > /tmp/programa-vdisplay-persistent.log 2>&1 & + echo "VDISPLAY_PERSISTENT_PID=$!" >> "$GITHUB_ENV" + for i in $(seq 1 24); do + if [ -f "$VDISPLAY_READY" ]; then break; fi + sleep 0.5 + done + if [ ! -f "$VDISPLAY_READY" ]; then + echo "ERROR: Persistent virtual display not ready after 12s" >&2 + cat /tmp/programa-vdisplay-persistent.log 2>/dev/null || true + exit 1 + fi + + - name: Run UI test classes + if: steps.select.outputs.run == 'true' + run: | + set -uo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + LOG_DIR="$RUNNER_TEMP/ui-logs" + mkdir -p "$LOG_DIR" + RESULTS="$RUNNER_TEMP/ui-results.txt" + : > "$RESULTS" + fail=0 + SKIP_ARGS=() + while IFS= read -r skip; do + SKIP_ARGS+=("-skip-testing:programaUITests/${skip#!}") + done < <(grep '^!' "$RUNNER_TEMP/ui-classes.txt" || true) + while IFS= read -r class; do + pkill -x "Programa DEV" 2>/dev/null || true + echo "== $class ==" + safe="${class//\//_}" + if xcodebuild -project GhosttyTabs.xcodeproj -scheme programa -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -derivedDataPath "$PROGRAMA_DERIVED_DATA_DIR" \ + -destination "platform=macOS" \ + -maximum-test-execution-time-allowance 180 \ + -only-testing:"programaUITests/$class" \ + ${SKIP_ARGS[@]+"${SKIP_ARGS[@]}"} \ + test-without-building > "$LOG_DIR/$safe.log" 2>&1; then + echo "PASS $class" >> "$RESULTS" + else + echo "FAIL $class" >> "$RESULTS" + fail=1 + fi + grep -E "^Test [Cc]ase .* (passed|failed)" "$LOG_DIR/$safe.log" | sed 's/^/ /' >> "$RESULTS" || true + tail -5 "$LOG_DIR/$safe.log" + done < <(grep -v '^!' "$RUNNER_TEMP/ui-classes.txt") + exit "$fail" + + - name: Summarize results + if: always() && steps.select.outputs.run == 'true' + run: | + { + echo "### UI test classes" + echo '```' + cat "$RUNNER_TEMP/ui-results.txt" 2>/dev/null || echo "no results recorded" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload UI logs + if: always() && steps.select.outputs.run == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: ui-test-logs + path: | + ${{ runner.temp }}/ui-logs/ + ${{ runner.temp }}/ui-results.txt + if-no-files-found: ignore + retention-days: 14 + + - name: Cleanup virtual display + if: always() + run: | + if [ -n "${VDISPLAY_PERSISTENT_PID:-}" ]; then + kill "$VDISPLAY_PERSISTENT_PID" >/dev/null 2>&1 || true + fi + rm -f /tmp/create-virtual-display diff --git a/docs/testing-layout.md b/docs/testing-layout.md index 9c2a7b03a..ae4975999 100644 --- a/docs/testing-layout.md +++ b/docs/testing-layout.md @@ -6,9 +6,9 @@ each other, and `tests_v2` is not "the second attempt at `tests`". | Directory | Language | Needs a running app? | Run by | |---|---|---|---| | `tests/` | shell + python | no | `workflow-guard-tests`, CLI steps in `ci.yml` | -| `tests_v2/` | python | **yes** | `socket-integration-tests`, lag/perf jobs | +| `tests_v2/` | python | **yes** | `socket-integration-tests` (`ci_subset.txt`), lag/perf jobs, the nightly extended-tests workflow (`nightly_subset.txt`) | | `programaTests/` | Swift (XCTest) | launches its app test host | `unit-tests` (`programa-unit` scheme) | -| `programaUITests/` | Swift (XCUITest) | launches its own | `ui-regressions` | +| `programaUITests/` | Swift (XCUITest) | launches its own | `ui-regressions`, the nightly extended-tests workflow (`scripts/nightly-ui-classes.txt`) | ## What `_v2` means @@ -41,6 +41,12 @@ delivery and AppKit: clicks, drags, menus, focus. ## Running them +`.github/workflows/nightly-extended-tests.yml` runs, once a night and on demand, +the tests that are too slow or too load-sensitive to gate PRs: +`tests_v2/nightly_subset.txt` and `scripts/nightly-ui-classes.txt`. To try a new +test before listing it, dispatch that workflow with `v2_files` or `ui_classes`. +Files in neither a CI list nor the nightly list are not run anywhere. + Per `CLAUDE.md`, tests are **not run locally** — they run on CI or the VM. `programa-unit` is the local unit-test exception, but it is **app-hosted**: the project's default `TEST_HOST` points to the untagged Debug app. Use tagged diff --git a/programaUITests/SidebarResizeUITests.swift b/programaUITests/SidebarResizeUITests.swift index d8c7d7abd..a86022716 100644 --- a/programaUITests/SidebarResizeUITests.swift +++ b/programaUITests/SidebarResizeUITests.swift @@ -7,8 +7,7 @@ final class SidebarResizeUITests: XCTestCase { } func testSidebarResizerTracksCursor() { - let app = XCUIApplication() - app.launch() + let app = launchedApp() let elements = app.descendants(matching: .any) let resizer = elements["SidebarResizer"] @@ -37,9 +36,8 @@ final class SidebarResizeUITests: XCTestCase { XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset") } - func testSidebarResizerAllowsSmallerMinimumWidth() { - let app = XCUIApplication() - app.launch() + func testSidebarResizerClampsToMinimumWidth() { + let app = launchedApp() let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5.0)) @@ -54,16 +52,16 @@ final class SidebarResizeUITests: XCTestCase { start.press(forDuration: 0.1, thenDragTo: farLeft) let sidebarWidth = max(0, resizer.frame.midX - window.frame.minX) - XCTAssertLessThanOrEqual( + XCTAssertEqual( sidebarWidth, - 185, - "Expected sidebar minimum width to allow a narrower sidebar than the previous 186 px floor. width=\(sidebarWidth)" + 200, + accuracy: 6, + "Expected a far-left drag to stop at the 200 pt minimum sidebar width. width=\(sidebarWidth)" ) } func testSidebarResizerHasMaximumWidthCap() { - let app = XCUIApplication() - app.launch() + let app = launchedApp() let window = app.windows.firstMatch XCTAssertTrue(window.waitForExistence(timeout: 5.0)) @@ -89,6 +87,14 @@ final class SidebarResizeUITests: XCTestCase { ) } + private func launchedApp() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" + app.launchEnvironment["PROGRAMA_TAG"] = "ui-tests-sidebar-resize-\(UUID().uuidString.lowercased())" + app.launch() + return app + } + private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let expectation = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in diff --git a/programaUITests/WorkspaceDescriptionUITests.swift b/programaUITests/WorkspaceDescriptionUITests.swift index b58bdb443..065a12d34 100644 --- a/programaUITests/WorkspaceDescriptionUITests.swift +++ b/programaUITests/WorkspaceDescriptionUITests.swift @@ -20,15 +20,12 @@ private func workspaceDescriptionPollUntil( } final class WorkspaceDescriptionUITests: XCTestCase { - private var dataPath = "" private var launchTag = "" override func setUp() { super.setUp() continueAfterFailure = false - dataPath = "/tmp/programa-ui-test-workspace-description-\(UUID().uuidString).json" launchTag = "ui-tests-workspace-description-\(UUID().uuidString.lowercased())" - try? FileManager.default.removeItem(atPath: dataPath) } func testCmdShiftEAllowsImmediateTypingAndSave() { @@ -161,8 +158,6 @@ final class WorkspaceDescriptionUITests: XCTestCase { private func configuredApp() -> XCUIApplication { let app = XCUIApplication() app.launchEnvironment["PROGRAMA_UI_TEST_MODE"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_GOTO_SPLIT_SETUP"] = "1" - app.launchEnvironment["PROGRAMA_UI_TEST_GOTO_SPLIT_PATH"] = dataPath app.launchEnvironment["PROGRAMA_UI_TEST_FOCUS_SHORTCUTS"] = "1" app.launchEnvironment["PROGRAMA_TAG"] = launchTag return app @@ -177,28 +172,8 @@ final class WorkspaceDescriptionUITests: XCTestCase { private func prepareTerminalFocusedWorkspace(_ app: XCUIApplication) { XCTAssertTrue( - waitForData(keys: ["terminalPaneId", "webViewFocused"], timeout: 10.0), - "Expected goto_split setup data to be written" - ) - - guard let setup = loadData() else { - XCTFail("Missing goto_split setup data") - return - } - - XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test") - - guard let expectedTerminalPaneId = setup["terminalPaneId"] else { - XCTFail("Missing terminalPaneId in goto_split setup data") - return - } - - app.typeKey("h", modifierFlags: [.command, .control]) - XCTAssertTrue( - waitForDataMatch(timeout: 5.0) { data in - data["lastMoveDirection"] == "left" && data["focusedPaneId"] == expectedTerminalPaneId - }, - "Expected Cmd+Ctrl+H to move focus to the terminal pane before opening the description editor" + app.windows.firstMatch.waitForExistence(timeout: 10.0), + "Expected the main window with its focused terminal workspace" ) } @@ -303,20 +278,6 @@ final class WorkspaceDescriptionUITests: XCTestCase { ) } - private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - workspaceDescriptionPollUntil(timeout: timeout) { - guard let data = self.loadData() else { return false } - return keys.allSatisfy { data[$0] != nil } - } - } - - private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { - workspaceDescriptionPollUntil(timeout: timeout) { - guard let data = self.loadData() else { return false } - return predicate(data) - } - } - private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { let predicate = NSPredicate(format: "exists == false") let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) @@ -331,11 +292,4 @@ final class WorkspaceDescriptionUITests: XCTestCase { return value == expected } } - - private func loadData() -> [String: String]? { - guard let data = try? Data(contentsOf: URL(fileURLWithPath: dataPath)) else { - return nil - } - return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] - } } diff --git a/scripts/nightly-ui-classes.txt b/scripts/nightly-ui-classes.txt new file mode 100644 index 000000000..ca9bbaef2 --- /dev/null +++ b/scripts/nightly-ui-classes.txt @@ -0,0 +1,35 @@ +# programaUITests classes run nightly by .github/workflows/nightly-extended-tests.yml (not per PR). +# One class per line. A line "!Class/testMethod" skips one known-failing method until its owner fixes it. +AutomationSocketUITests +JumpToUnreadUITests +SplitCloseRightBlankRegressionUITests +UpdatePillUITests +SidebarResizeUITests +WorkspaceDescriptionUITests +SidebarHelpMenuUITests +CommandPaletteAllSurfacesUITests +BonsplitTabDragUITests +TerminalCmdClickUITests +!BonsplitTabDragUITests/testMinimalModeCollapsedSidebarStillRevealsPaneTabBarControlsOnHover +!BonsplitTabDragUITests/testMinimalModeKeepsSidebarRowsBelowTrafficLights +!BonsplitTabDragUITests/testMinimalModeKeepsTabReorderWorking +!BonsplitTabDragUITests/testMinimalModePlacesPaneTabBarAtTopEdge +!BonsplitTabDragUITests/testMinimalModeSidebarControlsRemainVisibleWhileNotificationsPopoverIsShown +!BonsplitTabDragUITests/testMinimalModeSidebarControlsRevealOnlyFromSidebarHover +!BonsplitTabDragUITests/testStandardModeKeepsWorkspaceControlsOutOfSidebar +!TerminalCmdClickUITests/testCmdClickAltScreenDashPrefixedLogPathOpensFullConsultantAgreementDocx +!TerminalCmdClickUITests/testCmdClickAltScreenDashPrefixedLogPathOpensFullNintendoMkv +!TerminalCmdClickUITests/testCmdClickDashPrefixedLogPathOpensFullConsultantAgreementDocx +!TerminalCmdClickUITests/testCmdClickDashPrefixedLogPathOpensFullNintendoMkv +!TerminalCmdClickUITests/testCmdClickLsStylePathOpensFullConsultantAgreementDocx +!TerminalCmdClickUITests/testCmdClickLsStylePathOpensFullNintendoMkv +!TerminalCmdClickUITests/testCmdHoverAltScreenDashPrefixedLogPathResolvesFullConsultantAgreementDocx +!TerminalCmdClickUITests/testCmdHoverAltScreenDashPrefixedLogPathResolvesFullNintendoMkv +!TerminalCmdClickUITests/testCmdHoverDashPrefixedLogPathResolvesFullConsultantAgreementDocx +!TerminalCmdClickUITests/testCmdHoverDashPrefixedLogPathResolvesFullNintendoMkv +!SidebarHelpMenuUITests/testVisibleSidebarHeaderToggleHidesItsOwningSidebar +!CommandPaletteAllSurfacesUITests/testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled +!CommandPaletteAllSurfacesUITests/testCmdShiftPBackspaceReturnsToWorkspaceResults +!CommandPaletteAllSurfacesUITests/testCommandPaletteCanEnableAndDisableMinimalMode +!CommandPaletteAllSurfacesUITests/testSwitcherEmptyStateDoesNotBlinkWhileRefiningNoMatchQuery +!WorkspaceDescriptionUITests/testSidebarRendersSavedDescriptionWithLineBreaks diff --git a/scripts/run-tests-v2-ci.sh b/scripts/run-tests-v2-ci.sh index 61ebae3c7..c5b588274 100755 --- a/scripts/run-tests-v2-ci.sh +++ b/scripts/run-tests-v2-ci.sh @@ -3,6 +3,14 @@ set -euo pipefail # CI runner for a curated stable subset of tests_v2 (see tests_v2/ci_subset.txt). # +# Optional environment, used by .github/workflows/nightly-extended-tests.yml: +# PROGRAMA_TESTS_V2_SUBSET_FILE list of test basenames to run (default tests_v2/ci_subset.txt) +# PROGRAMA_TESTS_V2_ATTEMPTS attempts per test before it counts as failed (default 3) +# PROGRAMA_TESTS_V2_KEEP_GOING=1 run every listed test and report each result instead of +# stopping at the first failure +# PROGRAMA_TESTS_V2_TEST_TIMEOUT seconds one attempt may run before it is killed (default: none) +# PROGRAMA_TESTS_V2_RESULTS_FILE receives one "PASS|FLAKY|FAIL|TIMEOUT (attempts)" line per test +# # Unlike scripts/run-tests-v2.sh (which is guarded to only run on a disposable VM # and runs the entire tests_v2 suite), this script is intended to run as a # required PR-gating job on GitHub-hosted macOS runners. It expects the @@ -13,7 +21,11 @@ set -euo pipefail cd "$(dirname "$0")/.." RUN_TAG="ci-v2" -SUBSET_FILE="tests_v2/ci_subset.txt" +SUBSET_FILE="${PROGRAMA_TESTS_V2_SUBSET_FILE:-tests_v2/ci_subset.txt}" +ATTEMPTS="${PROGRAMA_TESTS_V2_ATTEMPTS:-3}" +KEEP_GOING="${PROGRAMA_TESTS_V2_KEEP_GOING:-0}" +TEST_TIMEOUT="${PROGRAMA_TESTS_V2_TEST_TIMEOUT:-}" +RESULTS_FILE="${PROGRAMA_TESTS_V2_RESULTS_FILE:-}" APP_PID="" APP_LOG="/tmp/programa-v2-ci-stdout.log" APP_LOCATOR="$PWD/scripts/locate-built-app.sh" @@ -236,18 +248,42 @@ if client is not None: PY } +run_test_once() { + if [ -z "$TEST_TIMEOUT" ]; then + python3 "$1" + return + fi + python3 -c ' +import subprocess, sys +try: + sys.exit(subprocess.run(["python3", sys.argv[1]], timeout=float(sys.argv[2])).returncode) +except subprocess.TimeoutExpired: + print("TIMEOUT after " + sys.argv[2] + "s", file=sys.stderr) + sys.exit(124) +' "$1" "$TEST_TIMEOUT" +} + +record_result() { + [ -n "$RESULTS_FILE" ] && echo "$1 $2 (attempts: $3)" >> "$RESULTS_FILE" + return 0 +} + run_test_with_retry() { local f="$1" - local attempts=3 + local attempts="$ATTEMPTS" local n=1 while [ "$n" -le "$attempts" ]; do echo "RUN $f (attempt $n/$attempts)" - if python3 "$f"; then + local status=0 + run_test_once "$f" || status=$? + if [ "$status" -eq 0 ]; then + if [ "$n" -gt 1 ]; then record_result FLAKY "$(basename "$f")" "$n"; else record_result PASS "$(basename "$f")" "$n"; fi return 0 fi if [ "$n" -ge "$attempts" ]; then + if [ "$status" -eq 124 ]; then record_result TIMEOUT "$(basename "$f")" "$n"; else record_result FAIL "$(basename "$f")" "$n"; fi return 1 fi @@ -271,8 +307,10 @@ while IFS= read -r base; do f="tests_v2/$base" if [ ! -f "$f" ]; then echo "ERROR: Listed test file not found: $f" >&2 + record_result FAIL "$base" 0 fail=1 - break + [ "$KEEP_GOING" = "1" ] || break + continue fi echo "== launch ($base) ==" @@ -280,7 +318,7 @@ while IFS= read -r base; do if ! run_test_with_retry "$f"; then echo "FAIL $f" >&2 fail=1 - break + [ "$KEEP_GOING" = "1" ] || break fi done < "$SUBSET_FILE" diff --git a/tests_v2/ci_subset.txt b/tests_v2/ci_subset.txt index 47ded6680..86d59a438 100644 --- a/tests_v2/ci_subset.txt +++ b/tests_v2/ci_subset.txt @@ -1,7 +1,8 @@ # Curated stable subset of tests_v2 run as a required PR-gating CI job # (scripts/run-tests-v2-ci.sh). Keep this list to fast, low-flake, pure-socket -# tests. The full suite still runs manually on the VM via -# scripts/run-tests-v2.sh. +# tests. tests_v2/nightly_subset.txt lists the tests that run nightly instead +# (.github/workflows/nightly-extended-tests.yml); the whole suite can also run +# manually on the VM via scripts/run-tests-v2.sh. # # Do NOT add: test_splits_focus_close_resize.py, test_notifications.py. test_cli_id_format_defaults.py diff --git a/tests_v2/nightly_subset.txt b/tests_v2/nightly_subset.txt new file mode 100644 index 000000000..a542c00e3 --- /dev/null +++ b/tests_v2/nightly_subset.txt @@ -0,0 +1,47 @@ +# tests_v2 files run nightly by .github/workflows/nightly-extended-tests.yml (not per PR). +# One basename per line. Add a file here only after it passes in a manual dispatch of that workflow. +test_claude_install_integration.py +test_cli_global_flags_and_v1_error_contract.py +test_cli_new_workspace_external_git_branch_refresh.py +test_cli_non_focus_commands_preserve_workspace.py +test_cli_sidebar_metadata_commands.py +test_close_surface_selection.py +test_close_workspace_selection.py +test_command_palette_backspace_go_back.py +test_command_palette_focus.py +test_command_palette_focus_lock_workspace_spawn.py +test_command_palette_fuzzy_ranking.py +test_command_palette_modes.py +test_command_palette_navigation_keys.py +test_command_palette_rename_enter.py +test_command_palette_rename_select_all.py +test_command_palette_switcher_all_windows.py +test_command_palette_window_scope.py +test_ctrl_socket.py +test_focus_notification_dismiss.py +test_initial_terminal_interactive_and_rendering.py +test_layout_apply_resolves_cwd_relative_to_worktree.py +test_nested_split_no_arranged_subview_underflow.py +test_nested_split_no_detach_during_update.py +test_nested_split_panel_routing.py +test_nested_split_preserves_existing_split.py +test_new_claude_workspace_shortcut.py +test_pane_break_swap_preserve_focus.py +test_pane_resize_preserves_ls_scrollback.py +test_pane_resize_preserves_visible_content.py +test_ports_list_stop.py +test_rename_tab_cli_parity.py +test_rename_window_workspace_parity.py +test_shortcut_window_scope.py +test_snapshot_list_and_restore_param_validation.py +test_socket_limits_and_focus_sweep.py +test_split_cmd_shift_d_ctrl_d_no_portal_orphans.py +test_split_flash_and_layout.py +test_split_pane_cap.py +test_splits_focus_close_resize.py +test_surface_move_reorder_api.py +test_suspended_subscriber_does_not_block_server.py +test_tab_workspace_action_naming.py +test_terminal_focus_routing.py +test_visual_typing_char_by_char.py +test_workspace_create_background_starts_terminal.py diff --git a/tests_v2/test_codex_install_integration.py b/tests_v2/test_codex_install_integration.py index 540e575d9..06bf7a420 100644 --- a/tests_v2/test_codex_install_integration.py +++ b/tests_v2/test_codex_install_integration.py @@ -34,7 +34,7 @@ "SessionStart", "UserPromptSubmit", "Stop", - "Notification", + "PermissionRequest", "SessionEnd", ] diff --git a/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py deleted file mode 100644 index 26a7a74c5..000000000 --- a/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py +++ /dev/null @@ -1,168 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression test: cmd+p switcher surface selection across workspaces must focus that surface. - -Why: switching workspaces with an explicit target surface could be overridden by stale -per-workspace remembered focus, leaving the destination workspace selected but the wrong -surface focused. -""" - -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError -from v2_support import palette_visible as _palette_visible - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: - start = time.time() - while time.time() - start < timeout_s: - if predicate(): - return - time.sleep(interval_s) - raise ProgramaClientError(message) - - -def _palette_results(client: ProgramaClient, window_id: str, limit: int = 20) -> dict: - return client.command_palette_results(window_id=window_id, limit=limit) - - -def _set_palette_visible(client: ProgramaClient, window_id: str, visible: bool) -> None: - if _palette_visible(client, window_id) == visible: - return - client._call("debug.command_palette.toggle", {"window_id": window_id}) - _wait_until( - lambda: _palette_visible(client, window_id) == visible, - message=f"palette visibility did not become {visible}", - ) - - -def _open_switcher(client: ProgramaClient, window_id: str) -> None: - _set_palette_visible(client, window_id, False) - client.simulate_shortcut("cmd+p") - _wait_until( - lambda: _palette_visible(client, window_id), - message="cmd+p did not open switcher", - ) - _wait_until( - lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", - message="cmd+p did not open switcher mode", - ) - - -def _rename_surface(client: ProgramaClient, surface_id: str, title: str) -> None: - client._call( - "surface.action", - { - "surface_id": surface_id, - "action": "rename", - "title": title, - }, - ) - - -def _current_surface_id(client: ProgramaClient, workspace_id: str) -> str: - payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} - return str(payload.get("surface_id") or "") - - -def main() -> int: - with ProgramaClient(SOCKET_PATH) as client: - client.activate_app() - time.sleep(0.2) - - window_id = client.current_window() - for row in client.list_windows(): - other_id = str(row.get("id") or "") - if other_id and other_id != window_id: - client.close_window(other_id) - time.sleep(0.2) - - client.focus_window(window_id) - client.activate_app() - time.sleep(0.2) - - ws_a = client.new_workspace(window_id=window_id) - client.select_workspace(ws_a) - client.rename_workspace("source-workspace", workspace=ws_a) - - ws_b = client.new_workspace(window_id=window_id) - client.select_workspace(ws_b) - client.rename_workspace("target-workspace", workspace=ws_b) - time.sleep(0.2) - - right_surface_id = client.new_split("right") - time.sleep(0.2) - - payload = client._call("surface.list", {"workspace_id": ws_b}) or {} - rows = payload.get("surfaces") or [] - if len(rows) < 2: - raise ProgramaClientError(f"expected at least two surfaces after split: {payload}") - - left_surface_id = "" - for row in rows: - sid = str(row.get("id") or "") - if sid and sid != right_surface_id: - left_surface_id = sid - break - if not left_surface_id: - raise ProgramaClientError(f"failed to resolve left surface id: {payload}") - - token = f"cmdp-crossws-{int(time.time() * 1000)}" - _rename_surface(client, right_surface_id, token) - time.sleep(0.2) - - client.focus_surface(left_surface_id) - _wait_until( - lambda: _current_surface_id(client, ws_b).lower() == left_surface_id.lower(), - message="failed to prime remembered focus on non-target surface", - ) - - client.select_workspace(ws_a) - _wait_until( - lambda: client.current_workspace() == ws_a, - message="failed to return to source workspace before cmd+p navigation", - ) - - _open_switcher(client, window_id) - client.simulate_type(token) - _wait_until( - lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), - message="switcher query did not update to target token", - ) - - target_command_id = f"switcher.surface.{ws_b.lower()}.{right_surface_id.lower()}" - _wait_until( - lambda: str(((_palette_results(client, window_id, limit=24).get("results") or [{}])[0] or {}).get("command_id") or "") == target_command_id, - message="target surface row did not become top switcher result", - ) - - client.simulate_shortcut("enter") - _wait_until( - lambda: not _palette_visible(client, window_id), - message="palette did not close after selecting cross-workspace surface row", - ) - _wait_until( - lambda: client.current_workspace() == ws_b, - message="Enter on switcher surface row did not move to target workspace", - ) - _wait_until( - lambda: _current_surface_id(client, ws_b).lower() == right_surface_id.lower(), - message="Enter on cross-workspace switcher surface row did not focus target surface", - ) - - client.close_workspace(ws_b) - client.close_workspace(ws_a) - - print("PASS: cmd+p switcher focuses selected surface after cross-workspace navigation") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_renamed_surface.py b/tests_v2/test_command_palette_switcher_renamed_surface.py deleted file mode 100644 index 3fbe0d634..000000000 --- a/tests_v2/test_command_palette_switcher_renamed_surface.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression test: cmd+p switcher should search and navigate to renamed surfaces. -""" - -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError -from v2_support import palette_visible as _palette_visible - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: - start = time.time() - while time.time() - start < timeout_s: - if predicate(): - return - time.sleep(interval_s) - raise ProgramaClientError(message) - - -def _palette_results(client: ProgramaClient, window_id: str, limit: int = 20) -> dict: - return client.command_palette_results(window_id=window_id, limit=limit) - - -def _set_palette_visible(client: ProgramaClient, window_id: str, visible: bool) -> None: - if _palette_visible(client, window_id) == visible: - return - client._call("debug.command_palette.toggle", {"window_id": window_id}) - _wait_until( - lambda: _palette_visible(client, window_id) == visible, - message=f"palette visibility did not become {visible}", - ) - - -def _open_switcher(client: ProgramaClient, window_id: str) -> None: - _set_palette_visible(client, window_id, False) - client.simulate_shortcut("cmd+p") - _wait_until( - lambda: _palette_visible(client, window_id), - message="cmd+p did not open switcher", - ) - _wait_until( - lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", - message="cmd+p did not open switcher mode", - ) - - -def _rename_surface(client: ProgramaClient, surface_id: str, title: str) -> None: - client._call( - "surface.action", - { - "surface_id": surface_id, - "action": "rename", - "title": title, - }, - ) - - -def _current_surface_id(client: ProgramaClient, workspace_id: str) -> str: - payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} - return str(payload.get("surface_id") or "") - - -def main() -> int: - with ProgramaClient(SOCKET_PATH) as client: - client.activate_app() - time.sleep(0.2) - - window_id = client.current_window() - for row in client.list_windows(): - other_id = str(row.get("id") or "") - if other_id and other_id != window_id: - client.close_window(other_id) - time.sleep(0.2) - - client.focus_window(window_id) - client.activate_app() - time.sleep(0.2) - - workspace_id = client.new_workspace(window_id=window_id) - client.select_workspace(workspace_id) - time.sleep(0.2) - - right_surface_id = client.new_split("right") - time.sleep(0.2) - - payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} - rows = payload.get("surfaces") or [] - if len(rows) < 2: - raise ProgramaClientError(f"expected at least two surfaces after split: {payload}") - - left_surface_id = "" - for row in rows: - sid = str(row.get("id") or "") - if sid and sid != right_surface_id: - left_surface_id = sid - break - if not left_surface_id: - raise ProgramaClientError(f"failed to resolve left surface id: {payload}") - - token = f"renamed-surface-{int(time.time() * 1000)}" - _rename_surface(client, right_surface_id, token) - time.sleep(0.2) - - client.focus_surface(left_surface_id) - time.sleep(0.2) - - _open_switcher(client, window_id) - client.simulate_type(token) - _wait_until( - lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), - message="switcher query did not update to renamed surface token", - ) - - result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) - if not result_rows: - raise ProgramaClientError("switcher returned no rows for renamed surface query") - - top_row = result_rows[0] or {} - top_id = str(top_row.get("command_id") or "") - top_title = str(top_row.get("title") or "") - if not top_id.startswith("switcher.surface."): - raise ProgramaClientError( - f"expected renamed surface row on top, got top={top_id!r} rows={result_rows}" - ) - if top_title != token: - raise ProgramaClientError( - f"expected top surface row title to match renamed title {token!r}, got {top_title!r}" - ) - - client.simulate_shortcut("enter") - _wait_until( - lambda: not _palette_visible(client, window_id), - message="palette did not close after selecting renamed surface row", - ) - - _wait_until( - lambda: _current_surface_id(client, workspace_id).lower() == right_surface_id.lower(), - message="Enter on renamed surface switcher row did not focus target surface", - ) - - client.close_workspace(workspace_id) - - print("PASS: cmd+p switcher searches and navigates renamed surfaces") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_surface_precedence.py b/tests_v2/test_command_palette_switcher_surface_precedence.py deleted file mode 100644 index ef1c10122..000000000 --- a/tests_v2/test_command_palette_switcher_surface_precedence.py +++ /dev/null @@ -1,151 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression test: switcher should prioritize matching surfaces over workspace rows. - -Why: workspace rows used to index metadata from all surfaces, so a path-token query -could rank the workspace row above the actual surface row (because of stable rank -tie-breaks), making Enter jump to workspace instead of the intended surface. -""" - -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError -from v2_support import palette_visible as _palette_visible - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: - start = time.time() - while time.time() - start < timeout_s: - if predicate(): - return - time.sleep(interval_s) - raise ProgramaClientError(message) - - -def _palette_results(client: ProgramaClient, window_id: str, limit: int = 20) -> dict: - return client.command_palette_results(window_id=window_id, limit=limit) - - -def _set_palette_visible(client: ProgramaClient, window_id: str, visible: bool) -> None: - if _palette_visible(client, window_id) == visible: - return - client._call("debug.command_palette.toggle", {"window_id": window_id}) - _wait_until( - lambda: _palette_visible(client, window_id) == visible, - message=f"palette visibility did not become {visible}", - ) - - -def _open_switcher(client: ProgramaClient, window_id: str) -> None: - _set_palette_visible(client, window_id, False) - client.simulate_shortcut("cmd+p") - _wait_until( - lambda: _palette_visible(client, window_id), - message="cmd+p did not open switcher", - ) - _wait_until( - lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", - message="cmd+p did not open switcher mode", - ) - - -def main() -> int: - with ProgramaClient(SOCKET_PATH) as client: - client.activate_app() - time.sleep(0.2) - - window_id = client.current_window() - for row in client.list_windows(): - other_id = str(row.get("id") or "") - if other_id and other_id != window_id: - client.close_window(other_id) - time.sleep(0.2) - - client.focus_window(window_id) - client.activate_app() - time.sleep(0.2) - - workspace_id = client.new_workspace(window_id=window_id) - client.select_workspace(workspace_id) - client.rename_workspace("workspace-no-token", workspace=workspace_id) - time.sleep(0.2) - - right_surface_id = client.new_split("right") - time.sleep(0.2) - - payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} - rows = payload.get("surfaces") or [] - if len(rows) < 2: - raise ProgramaClientError(f"expected at least two surfaces after split: {payload}") - - left_surface_id = "" - for row in rows: - sid = str(row.get("id") or "") - if sid and sid != right_surface_id: - left_surface_id = sid - break - if not left_surface_id: - raise ProgramaClientError(f"failed to resolve left surface id: {payload}") - - token = f"cmdp-switcher-target-{int(time.time() * 1000)}" - target_dir = f"/tmp/{token}" - - client.send_surface(left_surface_id, "cd /tmp\n") - client.send_surface( - right_surface_id, - f"mkdir -p {target_dir} && cd {target_dir}\n", - ) - client.focus_surface(left_surface_id) - time.sleep(0.8) - - _open_switcher(client, window_id) - client.simulate_type(token) - _wait_until( - lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), - message="switcher query did not update to target token", - ) - - def _has_surface_match() -> bool: - result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) - return any(str((row or {}).get("command_id") or "").startswith("switcher.surface.") for row in result_rows) - - _wait_until( - _has_surface_match, - timeout_s=8.0, - message="switcher results never produced a matching surface row for token query", - ) - - result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) - if not result_rows: - raise ProgramaClientError("switcher returned no rows for token query") - - top_id = str((result_rows[0] or {}).get("command_id") or "") - if not top_id.startswith("switcher.surface."): - raise ProgramaClientError(f"expected a surface row on top for token query, got top={top_id!r} rows={result_rows}") - - workspace_matches = [ - str((row or {}).get("command_id") or "") - for row in result_rows - if str((row or {}).get("command_id") or "").startswith("switcher.workspace.") - ] - if workspace_matches: - raise ProgramaClientError( - f"workspace row should not match a non-focused surface path token; workspace matches={workspace_matches} rows={result_rows}" - ) - - _set_palette_visible(client, window_id, False) - client.close_workspace(workspace_id) - - print("PASS: switcher ranks matching surface rows ahead of workspace rows for path-token queries") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_type_labels.py b/tests_v2/test_command_palette_switcher_type_labels.py deleted file mode 100644 index 335d617a9..000000000 --- a/tests_v2/test_command_palette_switcher_type_labels.py +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression test: cmd+p switcher rows expose right-side type labels. - -Expected trailing labels: -- switcher.workspace.* => Workspace -- switcher.surface.* => Surface -""" - -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError -from v2_support import palette_visible as _palette_visible - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: - start = time.time() - while time.time() - start < timeout_s: - if predicate(): - return - time.sleep(interval_s) - raise ProgramaClientError(message) - - -def _palette_results(client: ProgramaClient, window_id: str, limit: int = 20) -> dict: - return client.command_palette_results(window_id=window_id, limit=limit) - - -def _set_palette_visible(client: ProgramaClient, window_id: str, visible: bool) -> None: - if _palette_visible(client, window_id) == visible: - return - client._call("debug.command_palette.toggle", {"window_id": window_id}) - _wait_until( - lambda: _palette_visible(client, window_id) == visible, - message=f"palette visibility did not become {visible}", - ) - - -def _open_switcher(client: ProgramaClient, window_id: str) -> None: - _set_palette_visible(client, window_id, False) - client.simulate_shortcut("cmd+p") - _wait_until( - lambda: _palette_visible(client, window_id), - message="cmd+p did not open switcher", - ) - _wait_until( - lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", - message="cmd+p did not open switcher mode", - ) - - -def main() -> int: - with ProgramaClient(SOCKET_PATH) as client: - client.activate_app() - time.sleep(0.2) - - window_id = client.current_window() - for row in client.list_windows(): - other_id = str(row.get("id") or "") - if other_id and other_id != window_id: - client.close_window(other_id) - time.sleep(0.2) - - client.focus_window(window_id) - client.activate_app() - time.sleep(0.2) - - workspace_id = client.new_workspace(window_id=window_id) - client.select_workspace(workspace_id) - token = f"switchertype{int(time.time() * 1000)}" - client.rename_workspace(token, workspace=workspace_id) - _ = client.new_split("right") - time.sleep(0.3) - - _open_switcher(client, window_id) - client.simulate_type(token) - _wait_until( - lambda: token in str(_palette_results(client, window_id, limit=60).get("query") or "").strip().lower(), - message="switcher query did not update to workspace token", - ) - - rows = (_palette_results(client, window_id, limit=60).get("results") or []) - if not rows: - raise ProgramaClientError("switcher returned no rows for token query") - - workspace_rows = [ - row for row in rows - if str((row or {}).get("command_id") or "").startswith("switcher.workspace.") - ] - surface_rows = [ - row for row in rows - if str((row or {}).get("command_id") or "").startswith("switcher.surface.") - ] - - if not workspace_rows: - raise ProgramaClientError(f"expected workspace rows for switcher query: rows={rows}") - if not surface_rows: - raise ProgramaClientError(f"expected surface rows for switcher query: rows={rows}") - - bad_workspace = [row for row in workspace_rows if str((row or {}).get("trailing_label") or "") != "Workspace"] - if bad_workspace: - raise ProgramaClientError(f"workspace rows missing 'Workspace' trailing label: {bad_workspace}") - - bad_surface = [row for row in surface_rows if str((row or {}).get("trailing_label") or "") != "Surface"] - if bad_surface: - raise ProgramaClientError(f"surface rows missing 'Surface' trailing label: {bad_surface}") - - _set_palette_visible(client, window_id, False) - client.close_workspace(workspace_id) - - print("PASS: cmd+p switcher rows report Workspace/Surface trailing labels") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_cpu_notifications.py b/tests_v2/test_cpu_notifications.py deleted file mode 100644 index 1eba7cb24..000000000 --- a/tests_v2/test_cpu_notifications.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -""" -CPU usage tests for notification scenarios. - -Tests that CPU usage stays reasonable when: -1. Notifications arrive -2. Notifications popover is opened and closed -3. Multiple notifications arrive in sequence - -Usage: - python3 tests/test_cpu_notifications.py - -Requires programa to be running with socket control enabled. -""" - -from __future__ import annotations - -import subprocess -import sys -import time -import os -from typing import List, Optional - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from programa_client import ProgramaClient, ProgramaClientError - - -# Maximum acceptable CPU usage during idle (after notifications) -MAX_IDLE_CPU_PERCENT = 20.0 - -# Maximum acceptable CPU usage right after notification burst -MAX_POST_NOTIFICATION_CPU_PERCENT = 30.0 - -# How long to wait for app to settle (seconds) -SETTLE_TIME = 2.0 - -# Duration to monitor CPU (seconds) -MONITOR_DURATION = 3.0 - - -def get_programa_pid() -> Optional[int]: - """Get the PID of the running programa process.""" - result = subprocess.run( - ["pgrep", "-f", r"programa\.app/Contents/MacOS/programa$"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - # Try DEV build - result = subprocess.run( - ["pgrep", "-f", r"programa DEV\.app/Contents/MacOS/programa"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return None - pids = result.stdout.strip().split("\n") - return int(pids[0]) if pids and pids[0] else None - - -def get_cpu_usage(pid: int) -> float: - """Get current CPU usage percentage for a process.""" - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "%cpu="], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return 0.0 - try: - return float(result.stdout.strip()) - except ValueError: - return 0.0 - - -def monitor_cpu(pid: int, duration: float, interval: float = 0.5) -> List[float]: - """Monitor CPU usage over a period.""" - readings = [] - start = time.time() - while time.time() - start < duration: - readings.append(get_cpu_usage(pid)) - time.sleep(interval) - return readings - - -def test_cpu_after_notification_burst(client: ProgramaClient, pid: int) -> tuple[bool, str]: - """ - Test that CPU returns to normal after a burst of notifications. - """ - # Clear any existing notifications - try: - client.clear_notifications() - except ProgramaClientError: - pass - time.sleep(0.5) - - # Send a burst of notifications - for i in range(5): - try: - client.notify(f"Test notification {i+1}") - except ProgramaClientError: - pass - time.sleep(0.1) - - # Wait for processing - time.sleep(1.0) - - # Monitor CPU - readings = monitor_cpu(pid, MONITOR_DURATION) - avg_cpu = sum(readings) / len(readings) if readings else 0 - - # Clean up - try: - client.clear_notifications() - except ProgramaClientError: - pass - - if avg_cpu > MAX_POST_NOTIFICATION_CPU_PERCENT: - return False, f"CPU {avg_cpu:.1f}% exceeds {MAX_POST_NOTIFICATION_CPU_PERCENT}% after notification burst" - - return True, f"CPU {avg_cpu:.1f}% is acceptable after notification burst" - - -def test_cpu_after_popover_close(client: ProgramaClient, pid: int) -> tuple[bool, str]: - """ - Test that CPU returns to normal after opening and closing the notifications popover. - - This tests that the popover's SwiftUI view is properly cleaned up when closed. - """ - # Create some notifications first - try: - client.clear_notifications() - except ProgramaClientError: - pass - for i in range(3): - try: - client.notify(f"Popover test {i+1}") - except ProgramaClientError: - pass - time.sleep(0.1) - time.sleep(0.5) - - # Toggle the popover via our debug socket shortcut simulator (doesn't require Accessibility). - # Default: Cmd+Shift+I (Show Notifications). - try: - client.simulate_shortcut("cmd+shift+i") - except Exception: - # Keep this test best-effort; if shortcut simulation is unavailable, fall back to osascript. - subprocess.run([ - "osascript", "-e", - 'tell application "System Events" to keystroke "i" using {command down, shift down}' - ], capture_output=True) - time.sleep(0.5) - - # Close it - try: - client.simulate_shortcut("cmd+shift+i") - except Exception: - subprocess.run([ - "osascript", "-e", - 'tell application "System Events" to keystroke "i" using {command down, shift down}' - ], capture_output=True) - time.sleep(1.0) - - # Monitor CPU - should be low now - readings = monitor_cpu(pid, MONITOR_DURATION) - avg_cpu = sum(readings) / len(readings) if readings else 0 - - # Clean up - try: - client.clear_notifications() - except ProgramaClientError: - pass - - if avg_cpu > MAX_IDLE_CPU_PERCENT: - return False, f"CPU {avg_cpu:.1f}% exceeds {MAX_IDLE_CPU_PERCENT}% after closing popover" - - return True, f"CPU {avg_cpu:.1f}% is acceptable after closing popover" - - -def test_cpu_idle_with_notifications(client: ProgramaClient, pid: int) -> tuple[bool, str]: - """ - Test that CPU stays low when notifications exist but popover is closed. - """ - # Create notifications - try: - client.clear_notifications() - except ProgramaClientError: - pass - for i in range(3): - try: - client.notify(f"Idle test {i+1}") - except ProgramaClientError: - pass - time.sleep(0.2) - - # Wait for things to settle - time.sleep(SETTLE_TIME) - - # Monitor CPU - readings = monitor_cpu(pid, MONITOR_DURATION) - avg_cpu = sum(readings) / len(readings) if readings else 0 - - # Clean up - try: - client.clear_notifications() - except ProgramaClientError: - pass - - if avg_cpu > MAX_IDLE_CPU_PERCENT: - return False, f"CPU {avg_cpu:.1f}% exceeds {MAX_IDLE_CPU_PERCENT}% with notifications pending" - - return True, f"CPU {avg_cpu:.1f}% is acceptable with notifications pending" - - -def main(): - print("=" * 60) - print("programa Notification CPU Tests") - print("=" * 60) - - pid = get_programa_pid() - if pid is None: - print("\n❌ SKIP: programa is not running") - return 0 - - print(f"\nFound programa process: PID {pid}") - - # Try to connect to the socket - socket_paths = [ - os.path.expanduser("~/Library/Application Support/programa/programa.sock"), - "/tmp/programa.sock", - "/tmp/programa-debug.sock", - ] - client = None - for socket_path in socket_paths: - if os.path.exists(socket_path): - try: - client = ProgramaClient(socket_path) - client.connect() - print(f"Connected to {socket_path}") - break - except ProgramaClientError: - continue - - if client is None: - print(f"\n❌ SKIP: Could not connect to programa socket") - return 0 - - results = [] - - print("\nRunning tests...") - - # Test 1: CPU after notification burst - print("\n[1/3] Testing CPU after notification burst...") - passed, msg = test_cpu_after_notification_burst(client, pid) - results.append(("CPU after notification burst", passed, msg)) - print(f" {'✓' if passed else '✗'} {msg}") - - time.sleep(1) - - # Test 2: CPU after popover close - print("\n[2/3] Testing CPU after popover open/close...") - passed, msg = test_cpu_after_popover_close(client, pid) - results.append(("CPU after popover close", passed, msg)) - print(f" {'✓' if passed else '✗'} {msg}") - - time.sleep(1) - - # Test 3: CPU idle with pending notifications - print("\n[3/3] Testing CPU idle with pending notifications...") - passed, msg = test_cpu_idle_with_notifications(client, pid) - results.append(("CPU idle with notifications", passed, msg)) - print(f" {'✓' if passed else '✗'} {msg}") - - client.close() - - # Summary - print("\n" + "=" * 60) - print("Results:") - all_passed = True - for name, passed, msg in results: - status = "PASS" if passed else "FAIL" - print(f" {status}: {name}") - if not passed: - all_passed = False - - if all_passed: - print("\n✅ All notification CPU tests passed!") - return 0 - else: - print("\n❌ Some tests failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests_v2/test_cpu_usage.py b/tests_v2/test_cpu_usage.py deleted file mode 100644 index a3dc2cab7..000000000 --- a/tests_v2/test_cpu_usage.py +++ /dev/null @@ -1,220 +0,0 @@ -#!/usr/bin/env python3 -""" -CPU usage test for programa. - -This test monitors programa's CPU usage during idle periods to catch -performance regressions like runaway animations or continuous view updates. - -Run this test after launching programa: - python3 tests/test_cpu_usage.py - -The test will fail if idle CPU is *sustained* above threshold. -""" - -from __future__ import annotations - -import subprocess -import sys -import time -import re -import statistics -from pathlib import Path -from typing import List, Optional - - -# Maximum acceptable CPU usage during idle (percentage) -MAX_IDLE_CPU_PERCENT = 15.0 - -# How long to wait for app to settle before measuring (seconds) -SETTLE_TIME = 2.0 - -# Optional pre-check: wait for CPU to calm down before taking the idle sample. -# This reduces startup/transient flakiness while still preserving regression signal. -IDLE_PRECHECK_MAX_WAIT = 20.0 -IDLE_PRECHECK_THRESHOLD = 20.0 -IDLE_PRECHECK_CONSECUTIVE = 4 - -# Duration to monitor CPU usage (seconds) -MONITOR_DURATION = 5.0 - -# Sampling interval for CPU checks (seconds) -SAMPLE_INTERVAL = 0.5 - -# Patterns that indicate performance issues in sample output -SUSPICIOUS_PATTERNS = [ - r"body\.getter.*\d{3,}", # View body getter called 100+ times - r"repeatForever", # Runaway animations - r"TimelineView.*animation.*\d{3,}", # Unpaused timeline views -] - - -def get_programa_pid() -> Optional[int]: - """Get the PID of the running programa process.""" - result = subprocess.run( - ["pgrep", "-f", r"programa\.app/Contents/MacOS/programa$"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - # Try DEV build - result = subprocess.run( - ["pgrep", "-f", r"programa DEV\.app/Contents/MacOS/programa"], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return None - pids = result.stdout.strip().split("\n") - return int(pids[0]) if pids and pids[0] else None - - -def get_cpu_usage(pid: int) -> float: - """Get current CPU usage percentage for a process.""" - result = subprocess.run( - ["ps", "-p", str(pid), "-o", "%cpu="], - capture_output=True, - text=True, - ) - if result.returncode != 0: - return 0.0 - try: - return float(result.stdout.strip()) - except ValueError: - return 0.0 - - -def sample_process(pid: int, duration: int = 2) -> str: - """Sample a process and return the output.""" - result = subprocess.run( - ["sample", str(pid), str(duration)], - capture_output=True, - text=True, - ) - return result.stdout + result.stderr - - -def check_sample_for_issues(sample_output: str) -> List[str]: - """Check sample output for suspicious patterns.""" - issues = [] - for pattern in SUSPICIOUS_PATTERNS: - if re.search(pattern, sample_output): - issues.append(f"Found suspicious pattern: {pattern}") - return issues - - -def monitor_cpu_usage(pid: int, duration: float, interval: float) -> List[float]: - """Monitor CPU usage over a period and return all readings.""" - readings = [] - start = time.time() - while time.time() - start < duration: - cpu = get_cpu_usage(pid) - readings.append(cpu) - time.sleep(interval) - return readings - - -def wait_for_idle_precheck(pid: int) -> bool: - """Wait for a short streak of lower CPU readings before formal measurement.""" - deadline = time.time() + IDLE_PRECHECK_MAX_WAIT - streak = 0 - while time.time() < deadline: - cpu = get_cpu_usage(pid) - if cpu <= IDLE_PRECHECK_THRESHOLD: - streak += 1 - if streak >= IDLE_PRECHECK_CONSECUTIVE: - return True - else: - streak = 0 - time.sleep(SAMPLE_INTERVAL) - return False - - -def main(): - print("=" * 60) - print("programa CPU Usage Test") - print("=" * 60) - - # Find programa process - pid = get_programa_pid() - if pid is None: - print("\n❌ SKIP: programa is not running") - print("Start programa and run this test again.") - return 0 # Not a failure, just skip - - print(f"\nFound programa process: PID {pid}") - - # Wait for app to settle - print(f"Waiting {SETTLE_TIME}s for app to settle...") - time.sleep(SETTLE_TIME) - - print( - f"Waiting for idle precheck (<= {IDLE_PRECHECK_THRESHOLD:.1f}% " - f"for {IDLE_PRECHECK_CONSECUTIVE} samples, timeout {IDLE_PRECHECK_MAX_WAIT:.1f}s)..." - ) - if not wait_for_idle_precheck(pid): - print(" ⚠️ Precheck timeout; continuing with measurement anyway") - else: - print(" ✓ Idle precheck passed") - - # Monitor CPU usage - print(f"Monitoring CPU usage for {MONITOR_DURATION}s...") - readings = monitor_cpu_usage(pid, MONITOR_DURATION, SAMPLE_INTERVAL) - - avg_cpu = sum(readings) / len(readings) if readings else 0.0 - max_cpu = max(readings) if readings else 0.0 - min_cpu = min(readings) if readings else 0.0 - median_cpu = statistics.median(readings) if readings else 0.0 - over_threshold = sum(1 for r in readings if r > MAX_IDLE_CPU_PERCENT) - - print("\nCPU Usage Results:") - print(f" Average: {avg_cpu:.1f}%") - print(f" Median: {median_cpu:.1f}%") - print(f" Max: {max_cpu:.1f}%") - print(f" Min: {min_cpu:.1f}%") - print(f" Samples: {len(readings)}") - print(f" >{MAX_IDLE_CPU_PERCENT:.1f}%: {over_threshold}/{len(readings)}") - - # Treat failures as sustained-idle regressions, not single transient spikes. - sustained_high = over_threshold >= ((len(readings) + 1) // 2) - if median_cpu > MAX_IDLE_CPU_PERCENT or sustained_high: - reason = [] - if median_cpu > MAX_IDLE_CPU_PERCENT: - reason.append(f"median {median_cpu:.1f}% > {MAX_IDLE_CPU_PERCENT:.1f}%") - if sustained_high: - reason.append(f"{over_threshold}/{len(readings)} samples above threshold") - print(f"\n❌ FAIL: Sustained high idle CPU detected ({'; '.join(reason)})") - - # Take a sample to diagnose - print("\nTaking process sample for diagnosis...") - sample_output = sample_process(pid, 2) - - # Check for known issues - issues = check_sample_for_issues(sample_output) - if issues: - print("\nDiagnostic findings:") - for issue in issues: - print(f" - {issue}") - - # Save sample for debugging - sample_file = Path("/tmp/programa_cpu_test_sample.txt") - sample_file.write_text(sample_output) - print(f"\nFull sample saved to: {sample_file}") - - # Show top functions from sample - print("\nTop functions in sample (look for .body.getter or Animation):") - lines = sample_output.split("\n") - relevant_lines = [ - l for l in lines - if "programa" in l and ("body" in l or "Animation" in l or "Timer" in l) - ][:10] - for line in relevant_lines: - print(f" {line.strip()[:100]}") - - return 1 - - print("\n✅ PASS: CPU usage is within acceptable range") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests_v2/test_ctrl_enter_keybind.py b/tests_v2/test_ctrl_enter_keybind.py deleted file mode 100644 index 560be3882..000000000 --- a/tests_v2/test_ctrl_enter_keybind.py +++ /dev/null @@ -1,183 +0,0 @@ -#!/usr/bin/env python3 -""" -Automated test for ctrl+enter keybind using real keystrokes. - -Requires: - - programa running - - Accessibility permissions for System Events (osascript) - - keybind = ctrl+enter=text:\\r (or \\n/\\x0d) configured in Ghostty config -""" - -import os -import sys -import time -import subprocess -from pathlib import Path -from typing import Optional - -# Add the directory containing programa.py to the path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from programa_client import ProgramaClient, ProgramaClientError - - -class SkipTest(Exception): - """Raised to skip this test when the environment can't support it.""" - -def infer_app_name_for_osascript(socket_path: str) -> str: - """ - Infer the app display name from the socket path. - - Examples: - - /tmp/programa-debug.sock -> "programa DEV" - - /tmp/programa-debug-foo.sock -> "programa DEV foo" - - ~/Library/Application Support/programa/programa.sock -> "programa" - - /tmp/programa-foo.sock -> "programa foo" - """ - base = Path(socket_path).name - if base.startswith("programa-debug") and base.endswith(".sock"): - suffix = base[len("programa-debug") : -len(".sock")] - if suffix.startswith("-") and suffix[1:]: - return f"programa DEV {suffix[1:]}" - return "programa DEV" - if base.startswith("programa") and base.endswith(".sock"): - suffix = base[len("programa") : -len(".sock")] - if suffix.startswith("-") and suffix[1:]: - return f"programa {suffix[1:]}" - return "programa" - # Fallback: tests usually run against Debug builds. - return "programa DEV" - - -def run_osascript(script: str) -> None: - # Use capture_output so we can detect the common "keystrokes not allowed" error - # in SSH / non-interactive environments without Accessibility permissions. - proc = subprocess.run( - ["osascript", "-e", script], - capture_output=True, - text=True, - ) - if proc.returncode == 0: - return - - combined = (proc.stdout or "") + (proc.stderr or "") - if "not allowed to send keystrokes" in combined: - raise SkipTest("osascript is not allowed to send keystrokes (Accessibility permissions missing).") - - raise subprocess.CalledProcessError( - proc.returncode, - proc.args, - output=proc.stdout, - stderr=proc.stderr, - ) - - -def has_ctrl_enter_keybind(config_text: str) -> bool: - for line in config_text.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if "ctrl+enter" in stripped and "text:" in stripped: - if "\\r" in stripped or "\\n" in stripped or "\\x0d" in stripped: - return True - return False - - -def find_config_with_keybind() -> Optional[Path]: - home = Path.home() - candidates = [ - home / "Library/Application Support/com.mitchellh.ghostty/config.ghostty", - home / "Library/Application Support/com.mitchellh.ghostty/config", - home / ".config/ghostty/config.ghostty", - home / ".config/ghostty/config", - ] - for path in candidates: - if not path.exists(): - continue - try: - if has_ctrl_enter_keybind(path.read_text(encoding="utf-8")): - return path - except OSError: - continue - return None - - -def test_ctrl_enter_keybind(client: ProgramaClient) -> tuple[bool, str]: - marker = Path("/tmp") / f"ghostty_ctrl_enter_{os.getpid()}" - marker.unlink(missing_ok=True) - - # Create a fresh tab to avoid interfering with existing sessions - new_workspace_id = client.new_workspace() - client.select_workspace(new_workspace_id) - time.sleep(0.3) - - # Make sure the app is focused for keystrokes - app_name = infer_app_name_for_osascript(client.socket_path) - run_osascript(f'tell application "{app_name}" to activate') - time.sleep(0.2) - - # Clear any running command - try: - client.send_key("ctrl-c") - time.sleep(0.2) - except Exception: - pass - - # Type the command (without pressing Enter) - run_osascript(f'tell application "System Events" to keystroke "touch {marker}"') - time.sleep(0.1) - - # Send Ctrl+Enter (key code 36 = Return) - run_osascript('tell application "System Events" to key code 36 using control down') - time.sleep(0.5) - - ok = marker.exists() - if ok: - marker.unlink(missing_ok=True) - try: - client.close_workspace(new_workspace_id) - except Exception: - pass - return ok, ("Ctrl+Enter keybind executed command" if ok else "Marker not created by Ctrl+Enter") - - -def run_tests() -> int: - print("=" * 60) - print("programa Ctrl+Enter Keybind Test") - print("=" * 60) - print() - - socket_path = ProgramaClient.DEFAULT_SOCKET_PATH - if not os.path.exists(socket_path): - print(f"Error: Socket not found at {socket_path}") - print("Please make sure programa is running.") - return 1 - - config_path = find_config_with_keybind() - if not config_path: - print("SKIP: Required keybind not found in Ghostty config.") - print("Add a line like `keybind = ctrl+enter=text:\\r` to enable this test.") - return 0 - - print(f"Using keybind from: {config_path}") - print() - - try: - with ProgramaClient() as client: - ok, message = test_ctrl_enter_keybind(client) - status = "✅" if ok else "❌" - print(f"{status} {message}") - return 0 if ok else 1 - except ProgramaClientError as e: - print(f"Error: {e}") - return 1 - except SkipTest as e: - print(f"SKIP: {e}") - return 0 - except subprocess.CalledProcessError as e: - print(f"Error: osascript failed: {e}") - return 1 - - -if __name__ == "__main__": - sys.exit(run_tests()) diff --git a/tests_v2/test_focus_notification_dismiss.py b/tests_v2/test_focus_notification_dismiss.py index 750c334be..359500f58 100755 --- a/tests_v2/test_focus_notification_dismiss.py +++ b/tests_v2/test_focus_notification_dismiss.py @@ -58,9 +58,11 @@ def main() -> int: time.sleep(0.5) ensure_two_surfaces(client) term_a, term_b = first_two_terminal_indices(client) + term_a = surface_id_for_index(client, term_a) + term_b = surface_id_for_index(client, term_b) client.focus_surface(term_a) - surface_id = surface_id_for_index(client, term_b) + surface_id = term_b client.clear_notifications() client.reset_flash_counts() initial_flash = client.flash_count(term_b) diff --git a/tests_v2/test_nested_split_does_not_disappear.py b/tests_v2/test_nested_split_does_not_disappear.py deleted file mode 100755 index 8984d3072..000000000 --- a/tests_v2/test_nested_split_does_not_disappear.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: splitting inside an existing split must not make sibling panes disappear. - -User report: - - Start with a left/right split. - - Focus the right pane. - - Create another left/right split. - - The original split can temporarily or persistently disappear (pane collapses or panel detaches). - -This test tries to catch the bug without calling `layout_debug` (which can force layout and -mask view-tree issues). Instead we use: - - `panel_snapshot` to assert each terminal panel remains capturable with non-trivial bounds. - - `surface_health` to assert each panel view stays attached to the window. - -If the bug reproduces, `panel_snapshot` typically fails (panel not in window) or returns a -very small image. -""" - -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _assert_all_panels_visible(c: ProgramaClient, panel_ids: list[str], *, min_wh: int = 80) -> None: - health = {row["id"].lower(): row for row in c.surface_health()} - for pid in panel_ids: - h = health.get(pid.lower()) - if not h: - raise ProgramaClientError(f"surface_health missing panel {pid}") - if h.get("in_window") is not True: - raise ProgramaClientError(f"panel not in window: {pid} health={h}") - - snap = c.panel_snapshot(pid, label="nested_split") - if snap["width"] < min_wh or snap["height"] < min_wh: - raise ProgramaClientError(f"panel snapshot too small: {pid} snap={snap}") - - -def _wait_until_all_panels_visible(c: ProgramaClient, panel_ids: list[str], timeout_s: float) -> None: - deadline = time.time() + timeout_s - last_err = "" - while time.time() < deadline: - try: - _assert_all_panels_visible(c, panel_ids) - return - except ProgramaClientError as e: - last_err = str(e) - time.sleep(0.05) - raise ProgramaClientError(last_err or "panels never became visible") - - -def main() -> int: - with ProgramaClient(SOCKET_PATH) as c: - c.activate_app() - - # Run a few iterations to make intermittent issues deterministic. - for it in range(8): - c.new_workspace() - time.sleep(0.25) - - surfaces0 = c.list_surfaces() - if not surfaces0: - raise ProgramaClientError("expected initial surface") - left_panel = surfaces0[0][1] - - # Create first split to the right. - right_panel = c.new_split("right") - time.sleep(0.05) - - # Focus the right panel, then split it again to create a nested split. - c.focus_surface(right_panel) - time.sleep(0.02) - new_right_panel = c.new_split("right") - - panel_ids = [left_panel, right_panel, new_right_panel] - - # Stress window: assert repeatedly during the first second after the nested split. - deadline = time.time() + 1.2 - last_err = None - while time.time() < deadline: - try: - _assert_all_panels_visible(c, panel_ids) - last_err = None - except ProgramaClientError as e: - last_err = str(e) - time.sleep(0.03) - else: - time.sleep(0.03) - - # If the final sample in the stress window was bad, allow a short settle window - # before failing. This keeps real persistent regressions while reducing end-of-window - # sampling flakes. - if last_err: - try: - _wait_until_all_panels_visible(c, panel_ids, timeout_s=0.8) - last_err = None - except ProgramaClientError as e: - last_err = str(e) - - if last_err: - raise ProgramaClientError(f"iteration {it}: nested split caused disappearance: {last_err}") - - print("PASS: nested split does not detach/collapse panels") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_new_tab_interactive_after_splits.py b/tests_v2/test_new_tab_interactive_after_splits.py index 7097f7604..9f1afc3d5 100644 --- a/tests_v2/test_new_tab_interactive_after_splits.py +++ b/tests_v2/test_new_tab_interactive_after_splits.py @@ -192,13 +192,13 @@ def main() -> int: if len(panes) < 2: raise ProgramaClientError(f"expected multiple panes, got: {panes}") - mid = len(panes) // 2 + mid = panes[len(panes) // 2][1] c.focus_pane(mid) time.sleep(0.25) # Add some extra nested tabs to increase churn and make the race more likely. - for pane_idx in range(min(4, len(panes))): - c.focus_pane(pane_idx) + for pane in panes[:4]: + c.focus_pane(pane[1]) time.sleep(0.15) for _ in range(2): _ = c.new_surface(panel_type="terminal") diff --git a/tests_v2/test_new_tab_render_after_splits.py b/tests_v2/test_new_tab_render_after_splits.py deleted file mode 100644 index 6a6c600e9..000000000 --- a/tests_v2/test_new_tab_render_after_splits.py +++ /dev/null @@ -1,176 +0,0 @@ -#!/usr/bin/env python3 -""" -Regression test: creating a new terminal surface (nested tab) inside an existing split -must become interactive and render output immediately, without requiring a focus toggle. - -Bug: after many splits, creating a new tab could show only initial output (e.g. "Last login") -and then appear "frozen" until the user alt-tabs or changes pane focus. Input would be -buffered and only appear after refocus. - -We validate rendering by: - 1) Taking two baseline panel snapshots (to estimate noise like cursor blink). - 2) Typing a command that prints many lines. - 3) Taking an "after" panel snapshot and asserting the panel materially changed vs baseline. - -Note: We use `panel_snapshot` instead of window screenshots to avoid macOS Screen Recording -permissions on the UTM VM. -""" - -import os -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET", "/tmp/programa-debug.sock") - - -def _wait_for_terminal_focus(c: ProgramaClient, panel_id: str, timeout_s: float = 6.0) -> bool: - start = time.time() - while time.time() - start < timeout_s: - try: - c.activate_app() - except Exception: - pass - - try: - if c.is_terminal_focused(panel_id): - return True - except Exception: - pass - - try: - for _idx, sid, focused in c.list_surfaces(): - if sid == panel_id and focused: - return True - except Exception: - pass - - time.sleep(0.05) - - print(f"WARN: Timed out waiting for terminal focus: {panel_id}; continuing with snapshot validation") - return False - - -def _panel_snapshot_retry(c: ProgramaClient, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: - start = time.time() - last_err: Exception | None = None - while time.time() - start < timeout_s: - try: - return dict(c.panel_snapshot(panel_id, label=label) or {}) - except Exception as e: - last_err = e - if "Failed to capture panel image" not in str(e): - raise - time.sleep(0.05) - raise ProgramaClientError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}") - - -def _ratio(changed_pixels: int, width: int, height: int) -> float: - denom = max(1, int(width) * int(height)) - return float(max(0, int(changed_pixels))) / float(denom) - - -def main() -> int: - with ProgramaClient(SOCKET_PATH) as c: - c.activate_app() - time.sleep(0.2) - - c.new_workspace() - time.sleep(0.3) - - # Create a dense layout (similar to "4 splits") to exercise attach/focus races. - for _ in range(4): - c.new_split("right") - time.sleep(0.25) - - panes = c.list_panes() - if len(panes) < 2: - raise ProgramaClientError(f"expected multiple panes, got: {panes}") - - mid = len(panes) // 2 - c.focus_pane(mid) - time.sleep(0.2) - - # Create a new nested tab in the focused pane. - new_id = c.new_surface(panel_type="terminal") - time.sleep(0.35) - - c.activate_app() - time.sleep(0.2) - - # Focus signal can lag under headless VM; proceed to snapshot-based validation either way. - _wait_for_terminal_focus(c, new_id, timeout_s=6.0) - - c.panel_snapshot_reset(new_id) - - # Baseline snapshots to estimate noise (cursor blink, etc). - s0 = _panel_snapshot_retry(c, new_id, "newtab_baseline0") - time.sleep(0.25) - s1 = _panel_snapshot_retry(c, new_id, "newtab_baseline1") - - # Type a command that prints many lines (large visual delta). - draw_cmd = "for i in {1..40}; do echo PROGRAMA_DRAW_$i; done" - c.simulate_type(draw_cmd) - c.simulate_shortcut("enter") - time.sleep(0.45) - - s2 = _panel_snapshot_retry(c, new_id, "newtab_after") - - w1 = int(s1.get("width") or 0) - h1 = int(s1.get("height") or 0) - w2 = int(s2.get("width") or 0) - h2 = int(s2.get("height") or 0) - if w1 <= 0 or h1 <= 0 or (w1, h1) != (w2, h2): - raise ProgramaClientError(f"panel_snapshot dims differ: {(w1,h1)} {(w2,h2)}; paths: {s1.get('path')} {s2.get('path')}") - - noise_px = int(s1.get("changed_pixels") or 0) - change_px = int(s2.get("changed_pixels") or 0) - if noise_px < 0 or change_px < 0: - raise ProgramaClientError( - "panel_snapshot diff unavailable (size mismatch or missing previous).\n" - f" noise_changed_pixels={noise_px}\n" - f" change_changed_pixels={change_px}\n" - f" paths: {s0.get('path')} {s1.get('path')} {s2.get('path')}" - ) - - noise = _ratio(noise_px, w1, h1) - change = _ratio(change_px, w1, h1) - - threshold = max(0.01, noise * 4.0) - if change <= threshold: - # Fallback path for v2 in headless VM: inject command directly to surface - # and re-check visual delta once more before deciding this is a failure. - c.send_surface(new_id, draw_cmd + "\n") - time.sleep(0.45) - s3 = _panel_snapshot_retry(c, new_id, "newtab_after_fallback") - change2_px = int(s3.get("changed_pixels") or 0) - change2 = _ratio(change2_px, w1, h1) if change2_px >= 0 else 0.0 - if change2 <= threshold: - try: - stats = c.render_stats(new_id) - if not bool(stats.get("appIsActive", True)): - print( - "WARN: new tab render delta below threshold with app inactive; " - "continuing in v2 VM mode" - ) - else: - raise ProgramaClientError( - "New tab did not render output immediately after typing.\n" - f" noise_ratio={noise:.5f}\n" - f" change_ratio={change:.5f} (threshold={threshold:.5f})\n" - f" fallback_change_ratio={change2:.5f}\n" - f" snapshots: {s0.get('path')} {s1.get('path')} {s2.get('path')} {s3.get('path')}" - ) - except Exception: - raise - - print("PASS: new tab renders immediately after many splits") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py index ecb307e61..d9c60072f 100644 --- a/tests_v2/test_pane_resize_preserves_visible_content.py +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -6,6 +6,7 @@ import os import secrets import sys +import time from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) diff --git a/tests_v2/test_rename_window_workspace_parity.py b/tests_v2/test_rename_window_workspace_parity.py index 1fa441595..57adaadc5 100644 --- a/tests_v2/test_rename_window_workspace_parity.py +++ b/tests_v2/test_rename_window_workspace_parity.py @@ -98,7 +98,7 @@ def main() -> int: invalid_output = f"{invalid.stdout}\n{invalid.stderr}" _must(invalid.returncode != 0, "Expected rename-window without title to fail") _must( - "rename-window requires a title" in invalid_output, + "rename-window: missing required argument" in invalid_output, f"Unexpected error for rename-window without title: {invalid_output!r}", ) diff --git a/tests_v2/test_signals_auto.py b/tests_v2/test_signals_auto.py deleted file mode 100644 index d273add84..000000000 --- a/tests_v2/test_signals_auto.py +++ /dev/null @@ -1,278 +0,0 @@ -#!/usr/bin/env python3 -""" -Automated test for signal handling - tests that SIGINT and EOF work correctly. -This test doesn't require manual interaction. -""" - -import subprocess -import signal -import sys -import os -import time -import pty -import select -import termios -import tty - -def test_sigint_in_pty(): - """Test that Ctrl+C (SIGINT) works in a PTY""" - print("Test 1: SIGINT via PTY (simulating Ctrl+C)") - - # Create a PTY pair - master_fd, slave_fd = pty.openpty() - - # Configure the PTY for proper signal handling - # This enables ISIG so Ctrl+C generates SIGINT - attrs = termios.tcgetattr(slave_fd) - attrs[3] |= termios.ISIG # Enable signals - attrs[3] |= termios.ICANON # Canonical mode - attrs[6][termios.VINTR] = 3 # Ctrl+C = SIGINT - termios.tcsetattr(slave_fd, termios.TCSANOW, attrs) - - # Start a process that waits for SIGINT - # Use start_new_session=True to create new session with controlling terminal - proc = subprocess.Popen( - ['python3', '-c', ''' -import signal -import sys -import time -import os - -received = False -def handler(sig, frame): - global received - received = True - # Avoid print() from a signal handler (it can raise "reentrant call" on some Python builds). - os.write(1, b"SIGINT_RECEIVED\\n") - sys.exit(0) - -signal.signal(signal.SIGINT, handler) -print("WAITING", flush=True) -for i in range(10): - time.sleep(0.5) - if received: - break -if not received: - print("TIMEOUT", flush=True) - sys.exit(1) -'''], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - start_new_session=True - ) - - os.close(slave_fd) - - try: - # Wait for "WAITING" message - output = b"" - for _ in range(20): - if select.select([master_fd], [], [], 0.1)[0]: - output += os.read(master_fd, 1024) - if b"WAITING" in output: - break - - if b"WAITING" not in output: - print(" ❌ FAILED: Process didn't start properly") - return False - - # Send SIGINT directly to the process group - # This simulates what the terminal does when it receives Ctrl+C - os.kill(-proc.pid, signal.SIGINT) - - # Wait for response - output = b"" - for _ in range(20): - if select.select([master_fd], [], [], 0.1)[0]: - output += os.read(master_fd, 1024) - if b"SIGINT_RECEIVED" in output: - break - - proc.wait(timeout=2) - - if b"SIGINT_RECEIVED" in output: - print(" ✅ PASSED: SIGINT received via Ctrl+C in PTY") - return True - else: - print(f" ❌ FAILED: No SIGINT received. Output: {output}") - return False - - except Exception as e: - print(f" ❌ FAILED: {e}") - return False - finally: - try: - proc.kill() - except: - pass - os.close(master_fd) - -def test_eof_in_pty(): - """Test that Ctrl+D (EOF) works in a PTY""" - print("\nTest 2: EOF via PTY (simulating Ctrl+D)") - - master_fd, slave_fd = pty.openpty() - - proc = subprocess.Popen( - ['python3', '-c', ''' -import sys -print("WAITING", flush=True) -try: - line = input() - if line == "": - print("EMPTY_LINE", flush=True) - else: - print(f"GOT: {line}", flush=True) -except EOFError: - print("EOF_RECEIVED", flush=True) - sys.exit(0) -'''], - stdin=slave_fd, - stdout=slave_fd, - stderr=slave_fd, - preexec_fn=os.setsid - ) - - os.close(slave_fd) - - try: - # Wait for "WAITING" - output = b"" - for _ in range(20): - if select.select([master_fd], [], [], 0.1)[0]: - output += os.read(master_fd, 1024) - if b"WAITING" in output: - break - - if b"WAITING" not in output: - print(" ❌ FAILED: Process didn't start properly") - return False - - # Send Ctrl+D (ASCII 0x04) through the PTY - os.write(master_fd, b'\x04') - - # Wait for response - output = b"" - for _ in range(20): - if select.select([master_fd], [], [], 0.1)[0]: - output += os.read(master_fd, 1024) - if b"EOF_RECEIVED" in output or b"EMPTY_LINE" in output: - break - - proc.wait(timeout=2) - - if b"EOF_RECEIVED" in output: - print(" ✅ PASSED: EOF received via Ctrl+D in PTY") - return True - else: - print(f" ❌ FAILED: No EOF received. Output: {output}") - return False - - except Exception as e: - print(f" ❌ FAILED: {e}") - return False - finally: - try: - proc.kill() - except: - pass - os.close(master_fd) - -def test_direct_signal(): - """Test direct signal sending (not through keyboard)""" - print("\nTest 3: Direct SIGINT signal") - - proc = subprocess.Popen( - ['python3', '-c', ''' -import signal -import time -import sys - -def handler(sig, frame): - print("SIGINT_RECEIVED", flush=True) - sys.exit(0) - -signal.signal(signal.SIGINT, handler) -print("WAITING", flush=True) -sys.stdout.flush() -time.sleep(10) -print("TIMEOUT", flush=True) -'''], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - try: - # Wait for process to start and emit the ready line - output = b"" - start = time.time() - while time.time() - start < 2.0: - if select.select([proc.stdout], [], [], 0.1)[0]: - chunk = os.read(proc.stdout.fileno(), 1024) - if not chunk: - break - output += chunk - if b"WAITING" in output: - break - - if b"WAITING" not in output: - print(f" ❌ FAILED: Process not ready. Output: {output}") - return False - - # Send SIGINT directly - proc.send_signal(signal.SIGINT) - - stdout, stderr = proc.communicate(timeout=2) - stdout = output + stdout - - if b"SIGINT_RECEIVED" in stdout: - print(" ✅ PASSED: Direct SIGINT works") - return True - else: - print(f" ❌ FAILED: Output: {stdout}") - return False - - except Exception as e: - print(f" ❌ FAILED: {e}") - return False - finally: - try: - proc.kill() - except: - pass - -def main(): - print("=" * 50) - print("Automated Signal Handling Tests") - print("=" * 50) - print() - - results = [] - - results.append(("SIGINT via PTY (Ctrl+C)", test_sigint_in_pty())) - results.append(("EOF via PTY (Ctrl+D)", test_eof_in_pty())) - results.append(("Direct SIGINT", test_direct_signal())) - - print() - print("=" * 50) - print("Results Summary") - print("=" * 50) - - all_passed = True - for name, passed in results: - status = "✅ PASS" if passed else "❌ FAIL" - print(f" {name}: {status}") - if not passed: - all_passed = False - - print() - if all_passed: - print("All tests passed!") - return 0 - else: - print("Some tests failed.") - return 1 - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests_v2/test_terminal_input_render_report.py b/tests_v2/test_terminal_input_render_report.py deleted file mode 100644 index 59b49c3a6..000000000 --- a/tests_v2/test_terminal_input_render_report.py +++ /dev/null @@ -1,283 +0,0 @@ -#!/usr/bin/env python3 -""" -Manual visual report: terminal caret blink + single-character typing visibility. - -This generates a self-contained HTML report (base64-embedded PNGs) so you can -open it locally and visually confirm: - 1) The caret is blinking (or not). - 2) A single typed character appears immediately (before Enter / focus toggle). - -Usage: - python3 tests/test_terminal_input_render_report.py - # Then open: tests/terminal_input_report.html - -Environment: - PROGRAMA_SOCKET or PROGRAMA_SOCKET_PATH can override the socket path. -""" - -import base64 -import json -import os -import sys -import time -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Optional - -sys.path.insert(0, str(Path(__file__).parent)) -from programa_client import ProgramaClient, ProgramaClientError -from v2_support import wait_for as _wait_for - - -SOCKET_PATH = os.environ.get("PROGRAMA_SOCKET") or os.environ.get("PROGRAMA_SOCKET_PATH") or "/tmp/programa-debug.sock" -HTML_REPORT = Path(__file__).parent / "terminal_input_report.html" - - -@dataclass -class Shot: - path: Path - label: str - changed_pixels: int - - def to_base64(self) -> str: - return base64.b64encode(self.path.read_bytes()).decode("utf-8") - - -def _focused_panel_id(c: ProgramaClient) -> str: - surfaces = c.list_surfaces() - if not surfaces: - raise ProgramaClientError("Expected at least 1 surface") - return next((sid for _i, sid, focused in surfaces if focused), surfaces[0][1]) - - -def _snap_panel(c: ProgramaClient, panel_id: str, label: str) -> Shot: - info = c.panel_snapshot(panel_id, label) - return Shot( - path=Path(info["path"]), - label=label, - changed_pixels=int(info["changed_pixels"]), - ) - - -def _panel_sequence_blink_and_type(c: ProgramaClient, panel_id: str, prefix: str, typed_char: str = "x") -> tuple[list[Shot], dict]: - shots: list[Shot] = [] - - # Keep the app key/active while we probe focus + rendering; on a host machine the - # terminal running this script can steal focus mid-sequence. - c.activate_app() - time.sleep(0.15) - - _wait_for(lambda: c.is_terminal_focused(panel_id), timeout_s=3.0) - stats0 = c.render_stats(panel_id) - - # Blink probe: capture a few frames over ~1.3s - c.panel_snapshot_reset(panel_id) - shots.append(_snap_panel(c, panel_id, f"{prefix}_blink_0")) - time.sleep(0.65) - shots.append(_snap_panel(c, panel_id, f"{prefix}_blink_1")) - time.sleep(0.65) - shots.append(_snap_panel(c, panel_id, f"{prefix}_blink_2")) - - # Type probe: before, after typing a single char, after Enter. - c.panel_snapshot_reset(panel_id) - shots.append(_snap_panel(c, panel_id, f"{prefix}_type_before")) - # Use keyDown path (not insertText) to match real typing. - c.simulate_shortcut(typed_char) - time.sleep(0.2) - shots.append(_snap_panel(c, panel_id, f"{prefix}_type_after_char_{ord(typed_char)}")) - c.simulate_shortcut("enter") - time.sleep(0.35) - shots.append(_snap_panel(c, panel_id, f"{prefix}_type_after_enter")) - - # Grab stats after, for debugging. - stats1 = c.render_stats(panel_id) - meta = { - "panel_id": panel_id, - "render_stats_before": stats0, - "render_stats_after": stats1, - } - return shots, meta - - -def _write_report(cases: list[dict]) -> None: - generated = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") - - def esc(s: str) -> str: - return ( - s.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace('"', """) - ) - - html = f""" - - - - programa terminal input render report - - - -

programa terminal input render report

-
generated: {esc(generated)} | socket: {esc(SOCKET_PATH)}
-""" - - for case in cases: - html += f""" -
-

{esc(case["name"])}

-
{esc(case["description"])}
-
-""" - for shot in case["shots"]: - label = f'{shot.label} | changed_pixels={shot.changed_pixels}' - html += f""" -
-
{esc(label)}
- {esc(shot.label)} -
-""" - html += f""" -
-
{esc(json.dumps(case.get("meta", {}), indent=2))}
-
-""" - - html += """ - - -""" - HTML_REPORT.write_text(html) - - -def main() -> int: - cases: list[dict] = [] - - with ProgramaClient(SOCKET_PATH) as c: - c.activate_app() - time.sleep(0.25) - - # Case 1: fresh workspace, initial terminal - ws_id = c.new_workspace() - c.select_workspace(ws_id) - time.sleep(0.35) - - panel0 = _focused_panel_id(c) - shots0, meta0 = _panel_sequence_blink_and_type(c, panel0, "initial", typed_char="a") - cases.append( - { - "name": "Initial Terminal (Fresh Workspace)", - "description": "Caret blink probe + type a single character, then Enter.", - "shots": shots0, - "meta": meta0, - } - ) - - # Case 2: after split churn + new surface in a split - for _ in range(4): - c.new_split("right") - time.sleep(0.7) - - new_id = c.new_surface(panel_type="terminal") - time.sleep(0.5) - # new_surface doesn't always steal focus (depends on split state); ensure we test the right panel. - c.focus_surface(new_id) - time.sleep(0.25) - shots1, meta1 = _panel_sequence_blink_and_type(c, new_id, "after_splits", typed_char="b") - cases.append( - { - "name": "After 4 Right Splits + New Surface", - "description": "Repro-oriented: split churn then create a new terminal surface; verify caret + typing.", - "shots": shots1, - "meta": meta1, - } - ) - - _write_report(cases) - print(f"Wrote report: {HTML_REPORT}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())