From 4a351026a3ef562b50deabafa2db3d591c412d17 Mon Sep 17 00:00:00 2001 From: galz10 Date: Tue, 24 Mar 2026 18:25:44 -0700 Subject: [PATCH 01/13] chore(quality): add presubmit pipeline and pre-commit hook --- .githooks/pre-commit | 7 ++ .github/pull_request_template.md | 4 +- .github/workflows/presubmit.yml | 67 +++++++++++++++++++ .lychee.toml | 13 ++++ .markdownlint.json | 9 +++ .swiftformat | 6 ++ .swiftlint.yml | 14 ++++ README.md | 14 ++-- docs/contribution-guide.md | 9 ++- scripts/install-hooks.sh | 25 ++++++++ scripts/presubmit.sh | 106 +++++++++++++++++++++++++++++++ 11 files changed, 265 insertions(+), 9 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/presubmit.yml create mode 100644 .lychee.toml create mode 100644 .markdownlint.json create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100755 scripts/install-hooks.sh create mode 100755 scripts/presubmit.sh diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e738acd --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" + +./scripts/presubmit.sh fast diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 805429c..533e341 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -22,7 +22,9 @@ expected results, and edge cases. --> -- [ ] Builds successfully (`xcodebuild` / Xcode) +- [ ] `lint-docs` check is green +- [ ] `tests` check is green +- [ ] `maintainability` result reviewed (phase 1 report-only) - [ ] Added/updated tests (if needed) - [ ] Updated relevant documentation and README (if needed) - [ ] Noted breaking changes (if any) diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml new file mode 100644 index 0000000..e886ad3 --- /dev/null +++ b/.github/workflows/presubmit.yml @@ -0,0 +1,67 @@ +name: Presubmit + +on: + pull_request: + branches: + - main + push: + branches: + - main + +jobs: + lint-docs: + name: lint-docs + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install lint + docs tools + run: | + brew install swiftformat swiftlint lychee + npm install -g markdownlint-cli + + - name: Run lint checks + env: + PRESUBMIT_RUN_MAINTAINABILITY: '0' + run: ./scripts/presubmit.sh lint + + - name: Run docs checks + run: ./scripts/presubmit.sh docs + + tests: + name: tests + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install test prerequisites + run: | + brew install zig xcodegen + xcodebuild -downloadComponent MetalToolchain + + - name: Setup GhosttyKit + run: ./scripts/setup.sh + + - name: Generate Xcode project + run: xcodegen generate + + - name: Run test suite + run: ./scripts/presubmit.sh test + + maintainability: + name: maintainability + runs-on: macos-15 + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Run maintainability gate (phase 1 report-only) + run: ./scripts/maintainability-gate.sh diff --git a/.lychee.toml b/.lychee.toml new file mode 100644 index 0000000..a894778 --- /dev/null +++ b/.lychee.toml @@ -0,0 +1,13 @@ +accept = [200, 201, 202, 203, 204, 206, 301, 302, 303, 307, 308, 403, 429] +exclude_all_private = true +exclude_mail = true +max_retries = 2 +retry_wait_time = 2 +timeout = 20 +user_agent = "idx0-presubmit-link-checker" +exclude = [ + "^http://localhost", + "^https://localhost", + "^http://127\\.0\\.0\\.1", + "^https://127\\.0\\.0\\.1" +] diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..00830b6 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,9 @@ +{ + "default": true, + "MD013": false, + "MD033": false, + "MD041": false, + "MD024": { + "siblings_only": true + } +} diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..ced57af --- /dev/null +++ b/.swiftformat @@ -0,0 +1,6 @@ +--swiftversion 6.0 +--indent 2 +--linebreaks lf +--trimwhitespace always +--stripunusedargs closure-only +--exclude .build,.swiftpm,ghostty,GhosttyKit.xcframework,idx0.xcodeproj diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..0ba76f1 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,14 @@ +included: + - idx0 + - idx0Tests + - Sources + +excluded: + - .build + - .swiftpm + - ghostty + - GhosttyKit.xcframework + - idx0.xcodeproj + +disabled_rules: + - todo diff --git a/README.md b/README.md index 3e81d95..96da330 100644 --- a/README.md +++ b/README.md @@ -137,14 +137,16 @@ Protocol reference: ## Quality Gates ```bash -# Build + tests -xcodebuild -project idx0.xcodeproj -scheme idx0 -destination 'platform=macOS' test +# Install repo-managed hooks (one-time per clone) +./scripts/install-hooks.sh -# Maintainability policy gate -./scripts/maintainability-gate.sh +# Fast local checks (used by pre-commit) +./scripts/presubmit.sh fast -# Core coverage gate -./scripts/coverage-core.sh +# Full presubmit gates +./scripts/presubmit.sh lint +./scripts/presubmit.sh docs +./scripts/presubmit.sh test ``` ## Troubleshooting diff --git a/docs/contribution-guide.md b/docs/contribution-guide.md index 5b752ee..8d9e648 100644 --- a/docs/contribution-guide.md +++ b/docs/contribution-guide.md @@ -53,8 +53,11 @@ Suggested branch naming: 3. Implement in smallest viable slice. 4. Add/update tests in `idx0Tests/**`. 5. Run local gates: - - `./scripts/maintainability-gate.sh` - - `xcodebuild ... test` (or targeted suites during iteration) + - `./scripts/install-hooks.sh` (one-time per clone) + - `./scripts/presubmit.sh fast` for quick local iteration + - `./scripts/presubmit.sh lint` + - `./scripts/presubmit.sh docs` + - `./scripts/presubmit.sh test` (or targeted suites during iteration) 6. Update docs for behavioral, architectural, or protocol changes. 7. Submit PR with risk notes and verification commands. @@ -129,5 +132,7 @@ A change is done when: - [ ] Scope is focused and reversible. - [ ] Tests cover new behavior and regressions. - [ ] `docs/` updated where behavior/contracts changed. +- [ ] `lint-docs` and `tests` checks are green. +- [ ] `maintainability` report has been reviewed. - [ ] Commands used for verification listed in PR description. - [ ] Risks/follow-ups explicitly noted. diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..c3376b0 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$ROOT_DIR" + +HOOKS_DIR="$ROOT_DIR/.githooks" +PRE_COMMIT_HOOK="$HOOKS_DIR/pre-commit" + +if [[ ! -d "$HOOKS_DIR" ]]; then + echo "error: hooks directory not found: $HOOKS_DIR" >&2 + exit 1 +fi + +if [[ ! -f "$PRE_COMMIT_HOOK" ]]; then + echo "error: pre-commit hook not found: $PRE_COMMIT_HOOK" >&2 + exit 1 +fi + +chmod +x "$PRE_COMMIT_HOOK" +git config core.hooksPath .githooks + +echo "==> Installed repo hooks" +echo "==> core.hooksPath set to .githooks" diff --git a/scripts/presubmit.sh b/scripts/presubmit.sh new file mode 100755 index 0000000..73de0fb --- /dev/null +++ b/scripts/presubmit.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$ROOT_DIR" + +usage() { + cat <<'USAGE' +Run local quality gates for idx0. + +Usage: + ./scripts/presubmit.sh lint|docs|test|fast + +Subcommands: + lint SwiftFormat lint + SwiftLint + maintainability gate + docs Markdown lint + link check + test Full xcodebuild test suite + fast SwiftFormat lint + SwiftLint + markdown lint (no full tests) +USAGE +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "error: required command not found: $cmd" >&2 + exit 1 + fi +} + +run_step() { + local label="$1" + shift + echo "==> $label" + "$@" +} + +run_swift_checks() { + require_cmd swiftformat + require_cmd swiftlint + run_step "SwiftFormat lint" swiftformat --lint --config .swiftformat idx0 idx0Tests Sources + run_step "SwiftLint" swiftlint --config .swiftlint.yml +} + +run_markdown_lint() { + require_cmd markdownlint + run_step "Markdown lint" markdownlint --config .markdownlint.json README.md docs .github +} + +run_link_check() { + require_cmd lychee + + local md_files=() + while IFS= read -r -d '' file; do + md_files+=("$file") + done < <(find README.md docs .github -type f -name '*.md' -print0) + + if [[ "${#md_files[@]}" -eq 0 ]]; then + echo "error: no markdown files found for link checking" >&2 + exit 1 + fi + + run_step "Lychee link check" lychee --config .lychee.toml "${md_files[@]}" +} + +run_tests() { + run_step "xcodebuild tests" xcodebuild -project idx0.xcodeproj -scheme idx0 -destination 'platform=macOS' test +} + +run_maintainability() { + if [[ "${PRESUBMIT_RUN_MAINTAINABILITY:-1}" == "1" ]]; then + run_step "Maintainability gate" ./scripts/maintainability-gate.sh + else + echo "==> Skipping maintainability gate (PRESUBMIT_RUN_MAINTAINABILITY=0)" + fi +} + +COMMAND="${1:-}" + +case "$COMMAND" in + lint) + run_swift_checks + run_maintainability + ;; + docs) + run_markdown_lint + run_link_check + ;; + test) + run_tests + ;; + fast) + run_swift_checks + run_markdown_lint + ;; + -h|--help|help) + usage + exit 0 + ;; + *) + usage + exit 1 + ;; +esac + +echo "==> Presubmit '$COMMAND' completed successfully" From a5d5aa75580c1c47f310fdd2d3cfc6739efb7eb0 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 20:35:57 -0700 Subject: [PATCH 02/13] chore: harden presubmit security and CI reliability --- .github/CODEOWNERS | 10 ++++++ .github/workflows/presubmit.yml | 47 ++++++++++++++++++++++---- README.md | 6 ++-- docs/contribution-guide.md | 2 +- scripts/presubmit.sh | 60 ++++++++++++++++++++++++++++++++- scripts/setup.sh | 4 +-- 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b64356c..e3fe2c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,11 @@ +# Default owner for all repository files. * @galz10 + +# Security-sensitive quality gate surfaces. +/.github/CODEOWNERS @galz10 +/.github/workflows/* @galz10 +/.githooks/* @galz10 +/scripts/install-hooks.sh @galz10 +/scripts/maintainability-gate.sh @galz10 +/scripts/presubmit.sh @galz10 +/scripts/setup.sh @galz10 diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index e886ad3..01bb1e0 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -8,16 +8,24 @@ on: branches: - main +permissions: + contents: read + jobs: lint-docs: name: lint-docs + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: macos-15 + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: false - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: '20' @@ -26,8 +34,21 @@ jobs: brew install swiftformat swiftlint lychee npm install -g markdownlint-cli + - name: Resolve Swift lint diff range + id: diff-range + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "range=${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + elif [[ -n "${{ github.event.before }}" && "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then + echo "range=${{ github.event.before }}...${{ github.sha }}" >> "$GITHUB_OUTPUT" + else + echo "range=HEAD" >> "$GITHUB_OUTPUT" + fi + - name: Run lint checks env: + PRESUBMIT_SWIFT_CHANGED_ONLY: '1' + PRESUBMIT_DIFF_RANGE: ${{ steps.diff-range.outputs.range }} PRESUBMIT_RUN_MAINTAINABILITY: '0' run: ./scripts/presubmit.sh lint @@ -36,15 +57,18 @@ jobs: tests: name: tests + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: macos-15 + timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false - name: Install test prerequisites run: | brew install zig xcodegen - xcodebuild -downloadComponent MetalToolchain - name: Setup GhosttyKit run: ./scripts/setup.sh @@ -57,11 +81,20 @@ jobs: maintainability: name: maintainability + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: macos-15 - continue-on-error: true + timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + + - name: Install maintainability prerequisites + run: brew install ripgrep - name: Run maintainability gate (phase 1 report-only) - run: ./scripts/maintainability-gate.sh + run: | + if ! ./scripts/maintainability-gate.sh; then + echo "::warning::Maintainability gate reported issues (phase 1 report-only)." + fi diff --git a/README.md b/README.md index 96da330..49505cb 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ Workspace with an embedded browser tile: - macOS 14+ - Xcode (project generated for Xcode 26.3) - `xcodegen` -- Metal toolchain component (`xcodebuild -downloadComponent MetalToolchain`) +- Xcode first-launch components (`xcodebuild -runFirstLaunch`) - `zig` (only if you need to build `GhosttyKit.xcframework` from source) ## Quick Start (Source Build) @@ -77,7 +77,7 @@ Workspace with an embedded browser tile: ```bash brew install xcodegen -xcodebuild -downloadComponent MetalToolchain +xcodebuild -runFirstLaunch ``` 2. Set up GhosttyKit dependency: @@ -154,7 +154,7 @@ Protocol reference: - Missing GhosttyKit framework: - Run `./scripts/setup.sh` and confirm it ends with `==> Done`. - Build errors for `metal`: - - Run `xcodebuild -downloadComponent MetalToolchain`. + - Run `xcodebuild -runFirstLaunch`. - CLI tools not appearing: - Confirm the binaries are on your shell `PATH`. - If launching from Xcode, verify scheme `PATH` environment values. diff --git a/docs/contribution-guide.md b/docs/contribution-guide.md index 8d9e648..9f94ab3 100644 --- a/docs/contribution-guide.md +++ b/docs/contribution-guide.md @@ -16,7 +16,7 @@ Prerequisites: ```bash brew install zig xcodegen -xcodebuild -downloadComponent MetalToolchain +xcodebuild -runFirstLaunch ``` Project bootstrap: diff --git a/scripts/presubmit.sh b/scripts/presubmit.sh index 73de0fb..6d6102e 100755 --- a/scripts/presubmit.sh +++ b/scripts/presubmit.sh @@ -35,9 +35,67 @@ run_step() { "$@" } +run_swiftlint_for_changed_files() { + local swift_files=("$@") + local i + local status=0 + + export SCRIPT_INPUT_FILE_COUNT="${#swift_files[@]}" + for i in "${!swift_files[@]}"; do + export "SCRIPT_INPUT_FILE_$i=${swift_files[$i]}" + done + + if ! run_step "SwiftLint (changed files)" swiftlint lint --config .swiftlint.yml --use-script-input-files; then + status=$? + fi + + for i in "${!swift_files[@]}"; do + unset "SCRIPT_INPUT_FILE_$i" + done + unset SCRIPT_INPUT_FILE_COUNT + + return "$status" +} + run_swift_checks() { require_cmd swiftformat require_cmd swiftlint + + if [[ "${PRESUBMIT_SWIFT_CHANGED_ONLY:-0}" == "1" ]]; then + require_cmd git + + local diff_range="${PRESUBMIT_DIFF_RANGE:-}" + if [[ -z "$diff_range" ]]; then + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + diff_range="HEAD~1...HEAD" + else + diff_range="HEAD" + fi + fi + + local -a swift_files=() + while IFS= read -r -d '' file; do + [[ -z "$file" ]] && continue + case "$file" in + idx0/*|idx0Tests/*|Sources/*) + if [[ "$file" == -* ]]; then + file="./$file" + fi + swift_files+=("$file") + ;; + esac + done < <(git diff --name-only -z --diff-filter=ACMRTUXB "$diff_range" -- '*.swift') + + if [[ "${#swift_files[@]}" -eq 0 ]]; then + echo "==> No changed Swift files in '$diff_range'; skipping SwiftFormat/SwiftLint" + return + fi + + run_step "SwiftFormat lint (changed files)" swiftformat --lint --config .swiftformat "${swift_files[@]}" + run_swiftlint_for_changed_files "${swift_files[@]}" + return + fi + run_step "SwiftFormat lint" swiftformat --lint --config .swiftformat idx0 idx0Tests Sources run_step "SwiftLint" swiftlint --config .swiftlint.yml } @@ -90,7 +148,7 @@ case "$COMMAND" in run_tests ;; fast) - run_swift_checks + PRESUBMIT_SWIFT_CHANGED_ONLY="${PRESUBMIT_SWIFT_CHANGED_ONLY:-1}" run_swift_checks run_markdown_lint ;; -h|--help|help) diff --git a/scripts/setup.sh b/scripts/setup.sh index d28e687..7282b46 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -26,8 +26,8 @@ if ! command -v zig >/dev/null 2>&1; then fi if ! xcrun --sdk macosx metal -v >/dev/null 2>&1; then - echo "Error: Metal Toolchain is missing." - echo "Install via: xcodebuild -downloadComponent MetalToolchain" + echo "Error: Metal compiler tools are unavailable in the active Xcode toolchain." + echo "Install required Xcode components via: xcodebuild -runFirstLaunch" exit 1 fi From c07a8dd736270da82cc88a5a98b270981cd19d26 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 20:41:00 -0700 Subject: [PATCH 03/13] chore: scope docs lint to changed files in presubmit --- .github/workflows/presubmit.yml | 3 ++ README.md | 6 +-- docs/contribution-guide.md | 24 ++++++----- scripts/presubmit.sh | 71 ++++++++++++++++++++++++++++++++- 4 files changed, 89 insertions(+), 15 deletions(-) diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index 01bb1e0..9638adf 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -53,6 +53,9 @@ jobs: run: ./scripts/presubmit.sh lint - name: Run docs checks + env: + PRESUBMIT_MD_CHANGED_ONLY: '1' + PRESUBMIT_DIFF_RANGE: ${{ steps.diff-range.outputs.range }} run: ./scripts/presubmit.sh docs tests: diff --git a/README.md b/README.md index 49505cb..ed1ee51 100644 --- a/README.md +++ b/README.md @@ -80,19 +80,19 @@ brew install xcodegen xcodebuild -runFirstLaunch ``` -2. Set up GhosttyKit dependency: +1. Set up GhosttyKit dependency: ```bash ./scripts/setup.sh ``` -3. Generate the project: +1. Generate the project: ```bash xcodegen generate ``` -4. Open and run: +1. Open and run: ```bash open idx0.xcodeproj diff --git a/docs/contribution-guide.md b/docs/contribution-guide.md index 9f94ab3..25d932a 100644 --- a/docs/contribution-guide.md +++ b/docs/contribution-guide.md @@ -45,21 +45,23 @@ Suggested branch naming: - Architecture: `docs/architecture/*` - Style: `docs/style-guide.md` - Testing: `docs/testing-guide.md` -2. Confirm ownership boundary: +1. Confirm ownership boundary: - UI only: `idx0/UI/**` - Session/platform logic: `idx0/Services/Session/**` - Workflow/inbox/review logic: `idx0/Services/Workflow/**` - IPC/CLI contract: `idx0/App/IPCCommandRouter.swift`, `Sources/IPCShared/IPCContract.swift`, `Sources/idx0/idx0.swift` -3. Implement in smallest viable slice. -4. Add/update tests in `idx0Tests/**`. -5. Run local gates: - - `./scripts/install-hooks.sh` (one-time per clone) - - `./scripts/presubmit.sh fast` for quick local iteration - - `./scripts/presubmit.sh lint` - - `./scripts/presubmit.sh docs` - - `./scripts/presubmit.sh test` (or targeted suites during iteration) -6. Update docs for behavioral, architectural, or protocol changes. -7. Submit PR with risk notes and verification commands. +1. Implement in smallest viable slice. +1. Add/update tests in `idx0Tests/**`. +1. Run local gates: + + - `./scripts/install-hooks.sh` (one-time per clone) + - `./scripts/presubmit.sh fast` for quick local iteration + - `./scripts/presubmit.sh lint` + - `./scripts/presubmit.sh docs` + - `./scripts/presubmit.sh test` (or targeted suites during iteration) + +1. Update docs for behavioral, architectural, or protocol changes. +1. Submit PR with risk notes and verification commands. ## 5. Change Playbooks diff --git a/scripts/presubmit.sh b/scripts/presubmit.sh index 6d6102e..ea06415 100755 --- a/scripts/presubmit.sh +++ b/scripts/presubmit.sh @@ -100,14 +100,83 @@ run_swift_checks() { run_step "SwiftLint" swiftlint --config .swiftlint.yml } +resolve_diff_range() { + local diff_range="${PRESUBMIT_DIFF_RANGE:-}" + if [[ -z "$diff_range" ]]; then + if git rev-parse --verify HEAD~1 >/dev/null 2>&1; then + diff_range="HEAD~1...HEAD" + else + diff_range="HEAD" + fi + fi + + printf '%s\n' "$diff_range" +} + +collect_changed_markdown_files() { + require_cmd git + + local diff_range + diff_range="$(resolve_diff_range)" + + local -a md_files=() + while IFS= read -r -d '' file; do + [[ -z "$file" ]] && continue + case "$file" in + README.md|docs/*|.github/*) + if [[ "$file" == -* ]]; then + file="./$file" + fi + md_files+=("$file") + ;; + esac + done < <(git diff --name-only -z --diff-filter=ACMRTUXB "$diff_range" -- '*.md') + + if [[ "${#md_files[@]}" -eq 0 ]]; then + echo "==> No changed markdown files in '$diff_range'" + return + fi + + printf '%s\0' "${md_files[@]}" +} + run_markdown_lint() { require_cmd markdownlint + + if [[ "${PRESUBMIT_MD_CHANGED_ONLY:-0}" == "1" ]]; then + local -a md_files=() + while IFS= read -r -d '' file; do + md_files+=("$file") + done < <(collect_changed_markdown_files) + + if [[ "${#md_files[@]}" -eq 0 ]]; then + return + fi + + run_step "Markdown lint (changed files)" markdownlint --config .markdownlint.json "${md_files[@]}" + return + fi + run_step "Markdown lint" markdownlint --config .markdownlint.json README.md docs .github } run_link_check() { require_cmd lychee + if [[ "${PRESUBMIT_MD_CHANGED_ONLY:-0}" == "1" ]]; then + local -a md_files=() + while IFS= read -r -d '' file; do + md_files+=("$file") + done < <(collect_changed_markdown_files) + + if [[ "${#md_files[@]}" -eq 0 ]]; then + return + fi + + run_step "Lychee link check (changed files)" lychee --config .lychee.toml "${md_files[@]}" + return + fi + local md_files=() while IFS= read -r -d '' file; do md_files+=("$file") @@ -149,7 +218,7 @@ case "$COMMAND" in ;; fast) PRESUBMIT_SWIFT_CHANGED_ONLY="${PRESUBMIT_SWIFT_CHANGED_ONLY:-1}" run_swift_checks - run_markdown_lint + PRESUBMIT_MD_CHANGED_ONLY="${PRESUBMIT_MD_CHANGED_ONLY:-1}" run_markdown_lint ;; -h|--help|help) usage From 20d3ce714e3f8629a125c95b52cb1b18bcede18e Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 20:48:24 -0700 Subject: [PATCH 04/13] ci: cache GhosttyKit in tests presubmit job --- .github/workflows/presubmit.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index 9638adf..9dff13d 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -73,6 +73,32 @@ jobs: run: | brew install zig xcodegen + - name: Resolve Ghostty cache key + id: ghostty-cache + run: | + submodule_sha="$(git ls-tree HEAD ghostty | awk '{print $3}')" + if [[ "$submodule_sha" =~ ^[0-9a-f]{40}$ ]]; then + ghostty_sha="$submodule_sha" + else + ghostty_sha="$(git ls-remote https://github.com/manaflow-ai/ghostty.git refs/heads/main | awk '{print $1}')" + fi + + if [[ ! "$ghostty_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "error: failed to resolve Ghostty SHA for cache key" >&2 + exit 1 + fi + + echo "sha=$ghostty_sha" >> "$GITHUB_OUTPUT" + + - name: Restore GhosttyKit cache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/idx0/ghosttykit + key: ghosttykit-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-cache.outputs.sha }}-${{ hashFiles('scripts/setup.sh') }} + restore-keys: | + ghosttykit-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-cache.outputs.sha }}- + ghosttykit-${{ runner.os }}-${{ runner.arch }}- + - name: Setup GhosttyKit run: ./scripts/setup.sh From 1c822a4d594ab0c7766263b9841afb556c80142a Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 20:50:12 -0700 Subject: [PATCH 05/13] fix: avoid actor-isolation warning in workflow notifications --- idx0/Services/Workflow/WorkflowService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/idx0/Services/Workflow/WorkflowService.swift b/idx0/Services/Workflow/WorkflowService.swift index 1a24f6d..b558fcd 100644 --- a/idx0/Services/Workflow/WorkflowService.swift +++ b/idx0/Services/Workflow/WorkflowService.swift @@ -155,7 +155,6 @@ final class WorkflowService: ObservableObject { } } let persistenceDebouncer = Debouncer(delay: 0.2) - let notificationCenter = UNUserNotificationCenter.current() var cancellables: Set = [] var handledEventIDs: Set = [] @@ -598,6 +597,7 @@ final class WorkflowService: ObservableObject { func postApprovalNotificationIfNeeded(sessionID: UUID, title: String, summary: String) { guard !NSApp.isActive else { return } Task { @MainActor [sessionID, title, summary] in + let notificationCenter = UNUserNotificationCenter.current() let granted = (try? await notificationCenter.requestAuthorization(options: [.alert, .sound])) ?? false guard granted else { return } @@ -617,6 +617,7 @@ final class WorkflowService: ObservableObject { } func addNotificationRequest(_ request: UNNotificationRequest) async throws { + let notificationCenter = UNUserNotificationCenter.current() try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in notificationCenter.add(request) { error in if let error { From 0d49fe5be01652b3cb3386c6e63f64a93e45eeba Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 20:52:19 -0700 Subject: [PATCH 06/13] fix: update lychee config for current CLI --- .lychee.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.lychee.toml b/.lychee.toml index a894778..eefcff5 100644 --- a/.lychee.toml +++ b/.lychee.toml @@ -1,6 +1,6 @@ accept = [200, 201, 202, 203, 204, 206, 301, 302, 303, 307, 308, 403, 429] exclude_all_private = true -exclude_mail = true +include_mail = false max_retries = 2 retry_wait_time = 2 timeout = 20 From e2874daa85749f7a69ce3fdc8bd588ab05b2da5f Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 20:54:24 -0700 Subject: [PATCH 07/13] style: format WorkflowService for presubmit swiftformat --- idx0/Services/Workflow/WorkflowService.swift | 1137 +++++++++--------- 1 file changed, 573 insertions(+), 564 deletions(-) diff --git a/idx0/Services/Workflow/WorkflowService.swift b/idx0/Services/Workflow/WorkflowService.swift index b558fcd..9d96eaf 100644 --- a/idx0/Services/Workflow/WorkflowService.swift +++ b/idx0/Services/Workflow/WorkflowService.swift @@ -4,628 +4,637 @@ import Foundation import UserNotifications enum WorkflowServiceError: LocalizedError { - case sessionNotFound - case reviewNotFound - case approvalNotFound - case unsupportedSchemaVersion(Int) - case duplicateEvent - case unresolvedSession - - var errorDescription: String? { - switch self { - case .sessionNotFound: - return "Session not found." - case .reviewNotFound: - return "Review request not found." - case .approvalNotFound: - return "Approval request not found." - case .unsupportedSchemaVersion(let version): - return "Unsupported agent event schema version: \(version)" - case .duplicateEvent: - return "Duplicate event ignored." - case .unresolvedSession: - return "Unable to resolve target session." - } + case sessionNotFound + case reviewNotFound + case approvalNotFound + case unsupportedSchemaVersion(Int) + case duplicateEvent + case unresolvedSession + + var errorDescription: String? { + switch self { + case .sessionNotFound: + "Session not found." + case .reviewNotFound: + "Review request not found." + case .approvalNotFound: + "Approval request not found." + case let .unsupportedSchemaVersion(version): + "Unsupported agent event schema version: \(version)" + case .duplicateEvent: + "Duplicate event ignored." + case .unresolvedSession: + "Unable to resolve target session." } + } } enum CompareInput: Hashable { - case checkpoint(UUID) - case session(UUID) - case branches(repoPath: String, leftBranch: String, rightBranch: String) + case checkpoint(UUID) + case session(UUID) + case branches(repoPath: String, leftBranch: String, rightBranch: String) } enum HandoffTargetType: String, CaseIterable { - case selfSession - case otherSession - case reviewQueue - - var displayLabel: String { - switch self { - case .selfSession: - return "Self" - case .otherSession: - return "Other Session" - case .reviewQueue: - return "Review Queue" - } + case selfSession + case otherSession + case reviewQueue + + var displayLabel: String { + switch self { + case .selfSession: + "Self" + case .otherSession: + "Other Session" + case .reviewQueue: + "Review Queue" } + } } struct HandoffComposerDraft: Identifiable, Equatable { - let id: UUID - var sourceSessionID: UUID - var targetType: HandoffTargetType - var targetSessionID: UUID? - var checkpointID: UUID? - var title: String - var summary: String - var risksText: String - var nextActionsText: String - - init( - id: UUID = UUID(), - sourceSessionID: UUID, - targetType: HandoffTargetType = .selfSession, - targetSessionID: UUID? = nil, - checkpointID: UUID? = nil, - title: String = "Handoff", - summary: String = "", - risksText: String = "", - nextActionsText: String = "" - ) { - self.id = id - self.sourceSessionID = sourceSessionID - self.targetType = targetType - self.targetSessionID = targetSessionID - self.checkpointID = checkpointID - self.title = title - self.summary = summary - self.risksText = risksText - self.nextActionsText = nextActionsText - } + let id: UUID + var sourceSessionID: UUID + var targetType: HandoffTargetType + var targetSessionID: UUID? + var checkpointID: UUID? + var title: String + var summary: String + var risksText: String + var nextActionsText: String + + init( + id: UUID = UUID(), + sourceSessionID: UUID, + targetType: HandoffTargetType = .selfSession, + targetSessionID: UUID? = nil, + checkpointID: UUID? = nil, + title: String = "Handoff", + summary: String = "", + risksText: String = "", + nextActionsText: String = "" + ) { + self.id = id + self.sourceSessionID = sourceSessionID + self.targetType = targetType + self.targetSessionID = targetSessionID + self.checkpointID = checkpointID + self.title = title + self.summary = summary + self.risksText = risksText + self.nextActionsText = nextActionsText + } } struct CompareResult { - let leftTitle: String - let leftSummary: String - let leftSourceSessionID: UUID? - let rightTitle: String - let rightSummary: String - let rightSourceSessionID: UUID? - let leftFiles: [ChangedFileSummary] - let rightFiles: [ChangedFileSummary] - let leftDiffStat: DiffStat? - let rightDiffStat: DiffStat? - let leftTestSummary: TestSummary? - let rightTestSummary: TestSummary? - let overlapPaths: [String] - let leftOnlyPaths: [String] - let rightOnlyPaths: [String] + let leftTitle: String + let leftSummary: String + let leftSourceSessionID: UUID? + let rightTitle: String + let rightSummary: String + let rightSourceSessionID: UUID? + let leftFiles: [ChangedFileSummary] + let rightFiles: [ChangedFileSummary] + let leftDiffStat: DiffStat? + let rightDiffStat: DiffStat? + let leftTestSummary: TestSummary? + let rightTestSummary: TestSummary? + let overlapPaths: [String] + let leftOnlyPaths: [String] + let rightOnlyPaths: [String] } @MainActor final class WorkflowService: ObservableObject { - @Published var checkpoints: [Checkpoint] = [] - @Published var handoffs: [Handoff] = [] - @Published var reviews: [ReviewRequest] = [] - @Published var approvals: [ApprovalRequest] = [] - @Published var queueItems: [SupervisionQueueItem] = [] - @Published var timelineItems: [TimelineItem] = [] - @Published var layoutState: LayoutState - @Published var vibeTools: [VibeCLITool] = [] - @Published var selectedRailSurface: WorkflowRailSurface = .checkpoints - @Published var selectedCheckpointID: UUID? - @Published var selectedReviewID: UUID? - @Published var selectedHandoffID: UUID? - @Published var comparePresetLeft: CompareInput? - @Published var comparePresetRight: CompareInput? - @Published var activeHandoffComposer: HandoffComposerDraft? - - let checkpointStore: CheckpointStore - let handoffStore: HandoffStore - let reviewStore: ReviewStore - let approvalStore: ApprovalStore - let queueStore: QueueStore - let timelineStore: TimelineStore - let layoutStore: LayoutStore - let agentEventStore: AgentEventStore - let sessionService: SessionService - let queueService = SupervisionQueueService() - let timelineService = TimelineService() - var launchService = VibeCLILaunchService() - let discoveryService = VibeCLIDiscoveryService() - var shellPool: ShellPoolService? - var shellPoolToolsCancellable: AnyCancellable? - - func setShellPool(_ pool: ShellPoolService) { - launchService.shellPool = pool - shellPool = pool - - shellPoolToolsCancellable?.cancel() - shellPoolToolsCancellable = pool.$cachedTools - .sink { [weak self] tools in - self?.vibeTools = tools - } - - if pool.isWarmed { - vibeTools = pool.cachedTools - } else { - pool.refreshTools() - } + @Published var checkpoints: [Checkpoint] = [] + @Published var handoffs: [Handoff] = [] + @Published var reviews: [ReviewRequest] = [] + @Published var approvals: [ApprovalRequest] = [] + @Published var queueItems: [SupervisionQueueItem] = [] + @Published var timelineItems: [TimelineItem] = [] + @Published var layoutState: LayoutState + @Published var vibeTools: [VibeCLITool] = [] + @Published var selectedRailSurface: WorkflowRailSurface = .checkpoints + @Published var selectedCheckpointID: UUID? + @Published var selectedReviewID: UUID? + @Published var selectedHandoffID: UUID? + @Published var comparePresetLeft: CompareInput? + @Published var comparePresetRight: CompareInput? + @Published var activeHandoffComposer: HandoffComposerDraft? + + let checkpointStore: CheckpointStore + let handoffStore: HandoffStore + let reviewStore: ReviewStore + let approvalStore: ApprovalStore + let queueStore: QueueStore + let timelineStore: TimelineStore + let layoutStore: LayoutStore + let agentEventStore: AgentEventStore + let sessionService: SessionService + let queueService = SupervisionQueueService() + let timelineService = TimelineService() + var launchService = VibeCLILaunchService() + let discoveryService = VibeCLIDiscoveryService() + var shellPool: ShellPoolService? + var shellPoolToolsCancellable: AnyCancellable? + + func setShellPool(_ pool: ShellPoolService) { + launchService.shellPool = pool + shellPool = pool + + shellPoolToolsCancellable?.cancel() + shellPoolToolsCancellable = pool.$cachedTools + .sink { [weak self] tools in + self?.vibeTools = tools + } + + if pool.isWarmed { + vibeTools = pool.cachedTools + } else { + pool.refreshTools() } - let persistenceDebouncer = Debouncer(delay: 0.2) - var cancellables: Set = [] - - var handledEventIDs: Set = [] - - init( - sessionService: SessionService, - checkpointStore: CheckpointStore, - handoffStore: HandoffStore, - reviewStore: ReviewStore, - approvalStore: ApprovalStore, - queueStore: QueueStore, - timelineStore: TimelineStore, - layoutStore: LayoutStore, - agentEventStore: AgentEventStore, - legacyAttentionItems: [AttentionItem] - ) { - self.sessionService = sessionService - self.checkpointStore = checkpointStore - self.handoffStore = handoffStore - self.reviewStore = reviewStore - self.approvalStore = approvalStore - self.queueStore = queueStore - self.timelineStore = timelineStore - self.layoutStore = layoutStore - self.agentEventStore = agentEventStore - self.layoutState = LayoutState() - - loadAll(legacyAttentionItems: legacyAttentionItems) - bindSessionStreams() - synchronizeLayoutState(with: sessionService.sessions) + } + + let persistenceDebouncer = Debouncer(delay: 0.2) + var cancellables: Set = [] + + var handledEventIDs: Set = [] + + init( + sessionService: SessionService, + checkpointStore: CheckpointStore, + handoffStore: HandoffStore, + reviewStore: ReviewStore, + approvalStore: ApprovalStore, + queueStore: QueueStore, + timelineStore: TimelineStore, + layoutStore: LayoutStore, + agentEventStore: AgentEventStore, + legacyAttentionItems: [AttentionItem] + ) { + self.sessionService = sessionService + self.checkpointStore = checkpointStore + self.handoffStore = handoffStore + self.reviewStore = reviewStore + self.approvalStore = approvalStore + self.queueStore = queueStore + self.timelineStore = timelineStore + self.layoutStore = layoutStore + self.agentEventStore = agentEventStore + layoutState = LayoutState() + + loadAll(legacyAttentionItems: legacyAttentionItems) + bindSessionStreams() + synchronizeLayoutState(with: sessionService.sessions) + } + + var unresolvedQueueItems: [SupervisionQueueItem] { + if pruneExpiredInformationalQueueItemsIfNeeded() { + persistSoon() } - - var unresolvedQueueItems: [SupervisionQueueItem] { - if pruneExpiredInformationalQueueItemsIfNeeded() { - persistSoon() - } - return queueService.sortedUnresolvedItems(from: queueItems) + return queueService.sortedUnresolvedItems(from: queueItems) + } + + var sortedTimelineItems: [TimelineItem] { + timelineService.sortedLatestFirst(timelineItems) + } + + func loadAll(legacyAttentionItems: [AttentionItem]) { + checkpoints = (try? checkpointStore.load().checkpoints) ?? [] + handoffs = (try? handoffStore.load().handoffs) ?? [] + reviews = (try? reviewStore.load().reviews) ?? [] + approvals = (try? approvalStore.load().approvals) ?? [] + queueItems = (try? queueStore.load().items) ?? [] + timelineItems = (try? timelineStore.load().items) ?? [] + layoutState = (try? layoutStore.load().layoutState) ?? LayoutState() + handledEventIDs = Set((try? agentEventStore.load().handledEventIDs) ?? []) + if let selectedSessionID = sessionService.selectedSessionID, + let savedSurface = layoutState.lastRailSurfaceBySession[selectedSessionID] + { + selectedRailSurface = savedSurface + } else { + selectedRailSurface = .checkpoints } - var sortedTimelineItems: [TimelineItem] { - timelineService.sortedLatestFirst(timelineItems) + selectedCheckpointID = checkpoints.sorted(by: { $0.createdAt > $1.createdAt }).first?.id + selectedReviewID = reviews.sorted(by: { $0.createdAt > $1.createdAt }).first?.id + selectedHandoffID = handoffs.sorted(by: { $0.createdAt > $1.createdAt }).first?.id + + if queueItems.isEmpty, !legacyAttentionItems.isEmpty { + queueItems = legacyAttentionItems.map { legacy in + SupervisionQueueItem( + id: legacy.id, + sessionID: legacy.sessionID, + relatedObjectID: nil, + category: mapLegacyReason(legacy.reason), + title: legacy.reason.displayLabel, + subtitle: legacy.message, + createdAt: legacy.createdAt, + isResolved: legacy.isResolved, + isPinned: false + ) + } + persistSoon() } - - func loadAll(legacyAttentionItems: [AttentionItem]) { - checkpoints = (try? checkpointStore.load().checkpoints) ?? [] - handoffs = (try? handoffStore.load().handoffs) ?? [] - reviews = (try? reviewStore.load().reviews) ?? [] - approvals = (try? approvalStore.load().approvals) ?? [] - queueItems = (try? queueStore.load().items) ?? [] - timelineItems = (try? timelineStore.load().items) ?? [] - layoutState = (try? layoutStore.load().layoutState) ?? LayoutState() - handledEventIDs = Set((try? agentEventStore.load().handledEventIDs) ?? []) - if let selectedSessionID = sessionService.selectedSessionID, - let savedSurface = layoutState.lastRailSurfaceBySession[selectedSessionID] { - selectedRailSurface = savedSurface - } else { + } + + func mapLegacyReason(_ reason: AttentionReason) -> QueueItemCategory { + switch reason { + case .needsInput: + .blocked + case .completed: + .completed + case .error: + .error + case .notification: + .informational + } + } + + func bindSessionStreams() { + sessionService.$selectedSessionID + .sink { [weak self] sessionID in + Task { @MainActor [weak self] in + guard let self else { return } + layoutState.focusedSessionID = sessionID + if let sessionID, + let saved = layoutState.lastRailSurfaceBySession[sessionID] + { + selectedRailSurface = saved + } else if sessionID != nil { selectedRailSurface = .checkpoints + } + persistSoon() } + } + .store(in: &cancellables) - selectedCheckpointID = checkpoints.sorted(by: { $0.createdAt > $1.createdAt }).first?.id - selectedReviewID = reviews.sorted(by: { $0.createdAt > $1.createdAt }).first?.id - selectedHandoffID = handoffs.sorted(by: { $0.createdAt > $1.createdAt }).first?.id - - if queueItems.isEmpty, !legacyAttentionItems.isEmpty { - queueItems = legacyAttentionItems.map { legacy in - SupervisionQueueItem( - id: legacy.id, - sessionID: legacy.sessionID, - relatedObjectID: nil, - category: mapLegacyReason(legacy.reason), - title: legacy.reason.displayLabel, - subtitle: legacy.message, - createdAt: legacy.createdAt, - isResolved: legacy.isResolved, - isPinned: false - ) - } - persistSoon() + sessionService.$sessions + .sink { [weak self] sessions in + Task { @MainActor [weak self] in + self?.synchronizeLayoutState(with: sessions) } - } + } + .store(in: &cancellables) + } - func mapLegacyReason(_ reason: AttentionReason) -> QueueItemCategory { - switch reason { - case .needsInput: - return .blocked - case .completed: - return .completed - case .error: - return .error - case .notification: - return .informational - } - } + func synchronizeLayoutState(with sessions: [Session]) { + let sessionIDs = Set(sessions.map(\.id)) - func bindSessionStreams() { - sessionService.$selectedSessionID - .sink { [weak self] sessionID in - Task { @MainActor [weak self] in - guard let self else { return } - self.layoutState.focusedSessionID = sessionID - if let sessionID, - let saved = self.layoutState.lastRailSurfaceBySession[sessionID] { - self.selectedRailSurface = saved - } else if sessionID != nil { - self.selectedRailSurface = .checkpoints - } - self.persistSoon() - } - } - .store(in: &cancellables) - - sessionService.$sessions - .sink { [weak self] sessions in - Task { @MainActor [weak self] in - self?.synchronizeLayoutState(with: sessions) - } - } - .store(in: &cancellables) + let pinned = sessions.filter(\.isPinned).map(\.id) + if pinned != layoutState.pinnedSessionIDs { + layoutState.pinnedSessionIDs = pinned } - func synchronizeLayoutState(with sessions: [Session]) { - let sessionIDs = Set(sessions.map(\.id)) - - let pinned = sessions.filter(\.isPinned).map(\.id) - if pinned != layoutState.pinnedSessionIDs { - layoutState.pinnedSessionIDs = pinned - } - - layoutState.parkedSessionIDs.removeAll { !sessionIDs.contains($0) } - layoutState.pinnedSessionIDs.removeAll { !sessionIDs.contains($0) } - layoutState.lastVisibleSupportingSurfaceBySession = layoutState.lastVisibleSupportingSurfaceBySession.filter { sessionIDs.contains($0.key) } - layoutState.lastRailSurfaceBySession = layoutState.lastRailSurfaceBySession.filter { sessionIDs.contains($0.key) } - - var changedStacks = false - var nextStacks: [SessionStack] = [] - for var stack in layoutState.stacks { - let originalCount = stack.sessionIDs.count - stack.sessionIDs.removeAll { !sessionIDs.contains($0) } - if stack.sessionIDs.count != originalCount { - changedStacks = true - } - if let visible = stack.visibleSessionID, !stack.sessionIDs.contains(visible) { - stack.visibleSessionID = stack.sessionIDs.first - changedStacks = true - } - if !stack.sessionIDs.isEmpty { - nextStacks.append(stack) - } else { - changedStacks = true - } - } - if changedStacks { - layoutState.stacks = nextStacks - } - - if let focused = layoutState.focusedSessionID, !sessionIDs.contains(focused) { - layoutState.focusedSessionID = sessions.first?.id - } - - if let selectedSessionID = sessionService.selectedSessionID, - let savedSurface = layoutState.lastRailSurfaceBySession[selectedSessionID], - selectedRailSurface != savedSurface { - selectedRailSurface = savedSurface - } - - sanitizeSelections() - persistSoon() + layoutState.parkedSessionIDs.removeAll { !sessionIDs.contains($0) } + layoutState.pinnedSessionIDs.removeAll { !sessionIDs.contains($0) } + layoutState.lastVisibleSupportingSurfaceBySession = layoutState.lastVisibleSupportingSurfaceBySession.filter { sessionIDs.contains($0.key) } + layoutState.lastRailSurfaceBySession = layoutState.lastRailSurfaceBySession.filter { sessionIDs.contains($0.key) } + + var changedStacks = false + var nextStacks: [SessionStack] = [] + for var stack in layoutState.stacks { + let originalCount = stack.sessionIDs.count + stack.sessionIDs.removeAll { !sessionIDs.contains($0) } + if stack.sessionIDs.count != originalCount { + changedStacks = true + } + if let visible = stack.visibleSessionID, !stack.sessionIDs.contains(visible) { + stack.visibleSessionID = stack.sessionIDs.first + changedStacks = true + } + if !stack.sessionIDs.isEmpty { + nextStacks.append(stack) + } else { + changedStacks = true + } } - - func persistSoon() { - persistenceDebouncer.cancel() - persistenceDebouncer.schedule { [weak self] in - self?.persistNow() - } + if changedStacks { + layoutState.stacks = nextStacks } - func persistNow() { - persistenceDebouncer.cancel() - pruneExpiredInformationalQueueItemsIfNeeded() - do { - try checkpointStore.save(CheckpointFilePayload(checkpoints: checkpoints)) - try handoffStore.save(HandoffFilePayload(handoffs: handoffs)) - try reviewStore.save(ReviewFilePayload(reviews: reviews)) - try approvalStore.save(ApprovalFilePayload(approvals: approvals)) - try queueStore.save(QueueFilePayload(items: queueItems)) - try timelineStore.save(TimelineFilePayload(items: timelineItems)) - try layoutStore.save(LayoutFilePayload(layoutState: layoutState)) - try agentEventStore.save(AgentEventFilePayload(handledEventIDs: Array(handledEventIDs))) - } catch { - Logger.error("Workflow persistence failed: \(error.localizedDescription)") - } + if let focused = layoutState.focusedSessionID, !sessionIDs.contains(focused) { + layoutState.focusedSessionID = sessions.first?.id } - @discardableResult - func pruneExpiredInformationalQueueItemsIfNeeded() -> Bool { - let pruned = queueService.pruneExpiredInformational(queueItems) - if pruned != queueItems { - queueItems = pruned - return true - } - return false + if let selectedSessionID = sessionService.selectedSessionID, + let savedSurface = layoutState.lastRailSurfaceBySession[selectedSessionID], + selectedRailSurface != savedSurface + { + selectedRailSurface = savedSurface } - func addNotification( - sessionID: UUID, - category: QueueItemCategory, - title: String, - subtitle: String? - ) { - addQueueItem(sessionID: sessionID, category: category, title: title, subtitle: subtitle, relatedObjectID: nil) - addTimeline(sessionID: sessionID, type: .statusProgress, title: title, relatedObjectID: nil) - persistSoon() - } + sanitizeSelections() + persistSoon() + } - func addQueueItem( - sessionID: UUID, - category: QueueItemCategory, - title: String, - subtitle: String?, - relatedObjectID: UUID? - ) { - queueItems.append( - SupervisionQueueItem( - id: UUID(), - sessionID: sessionID, - relatedObjectID: relatedObjectID, - category: category, - title: title, - subtitle: subtitle, - createdAt: Date(), - isResolved: false, - isPinned: false - ) - ) - queueItems = queueService.pruneExpiredInformational(queueItems) + func persistSoon() { + persistenceDebouncer.cancel() + persistenceDebouncer.schedule { [weak self] in + self?.persistNow() } - - func ensureUnresolvedQueueItem( - sessionID: UUID, - category: QueueItemCategory, - title: String, - subtitle: String?, - relatedObjectID: UUID? - ) { - if queueItems.contains(where: { item in - !item.isResolved && item.relatedObjectID == relatedObjectID && item.category == category - }) { - return - } - addQueueItem( - sessionID: sessionID, - category: category, - title: title, - subtitle: subtitle, - relatedObjectID: relatedObjectID - ) + } + + func persistNow() { + persistenceDebouncer.cancel() + pruneExpiredInformationalQueueItemsIfNeeded() + do { + try checkpointStore.save(CheckpointFilePayload(checkpoints: checkpoints)) + try handoffStore.save(HandoffFilePayload(handoffs: handoffs)) + try reviewStore.save(ReviewFilePayload(reviews: reviews)) + try approvalStore.save(ApprovalFilePayload(approvals: approvals)) + try queueStore.save(QueueFilePayload(items: queueItems)) + try timelineStore.save(TimelineFilePayload(items: timelineItems)) + try layoutStore.save(LayoutFilePayload(layoutState: layoutState)) + try agentEventStore.save(AgentEventFilePayload(handledEventIDs: Array(handledEventIDs))) + } catch { + Logger.error("Workflow persistence failed: \(error.localizedDescription)") } - - func addTimeline( - sessionID: UUID, - type: TimelineItemType, - title: String, - relatedObjectID: UUID? - ) { - timelineItems = timelineService.append( - TimelineItem( - id: UUID(), - sessionID: sessionID, - createdAt: Date(), - type: type, - title: title, - relatedObjectID: relatedObjectID - ), - to: timelineItems - ) + } + + @discardableResult + func pruneExpiredInformationalQueueItemsIfNeeded() -> Bool { + let pruned = queueService.pruneExpiredInformational(queueItems) + if pruned != queueItems { + queueItems = pruned + return true } - - func makeGitSnapshot(for session: Session) async throws -> ( - branchName: String?, - commitSHA: String?, - changedFiles: [ChangedFileSummary], - diffStat: DiffStat? - ) { - let targetPath = session.worktreePath ?? session.repoPath - guard let targetPath else { - return (session.branchName, nil, [], nil) - } - let gitService = GitService() - - let branch = try? await gitService.currentBranch(repoPath: targetPath) - let commitSHA = try? await gitService.currentCommitSHA(repoPath: targetPath) - let changedFiles = (try? await gitService.diffNameStatus(path: targetPath)) ?? [] - let diffStat = (try? await gitService.diffStat(path: targetPath)) - - return (branch ?? session.branchName, commitSHA, changedFiles, diffStat) + return false + } + + func addNotification( + sessionID: UUID, + category: QueueItemCategory, + title: String, + subtitle: String? + ) { + addQueueItem(sessionID: sessionID, category: category, title: title, subtitle: subtitle, relatedObjectID: nil) + addTimeline(sessionID: sessionID, type: .statusProgress, title: title, relatedObjectID: nil) + persistSoon() + } + + func addQueueItem( + sessionID: UUID, + category: QueueItemCategory, + title: String, + subtitle: String?, + relatedObjectID: UUID? + ) { + queueItems.append( + SupervisionQueueItem( + id: UUID(), + sessionID: sessionID, + relatedObjectID: relatedObjectID, + category: category, + title: title, + subtitle: subtitle, + createdAt: Date(), + isResolved: false, + isPinned: false + ) + ) + queueItems = queueService.pruneExpiredInformational(queueItems) + } + + func ensureUnresolvedQueueItem( + sessionID: UUID, + category: QueueItemCategory, + title: String, + subtitle: String?, + relatedObjectID: UUID? + ) { + if queueItems.contains(where: { item in + !item.isResolved && item.relatedObjectID == relatedObjectID && item.category == category + }) { + return } - - func compareValue(for input: CompareInput) async -> ( - title: String, - summary: String, - sourceSessionID: UUID?, - changedFiles: [ChangedFileSummary], - diffStat: DiffStat?, - testSummary: TestSummary? - )? { - switch input { - case .checkpoint(let id): - guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { return nil } - return ( - title: checkpoint.title, - summary: checkpoint.summary, - sourceSessionID: checkpoint.sessionID, - changedFiles: checkpoint.changedFiles, - diffStat: checkpoint.diffStat, - testSummary: checkpoint.testSummary - ) - case .session(let sessionID): - guard let session = sessionService.sessions.first(where: { $0.id == sessionID }) else { return nil } - let repoPath = session.worktreePath ?? session.repoPath - guard let repoPath else { - return ( - title: session.title, - summary: session.statusText ?? session.subtitle, - sourceSessionID: session.id, - changedFiles: [], - diffStat: nil, - testSummary: nil - ) - } - - let gitService = GitService() - let files = (try? await gitService.diffNameStatus(path: repoPath)) ?? [] - let stat = try? await gitService.diffStat(path: repoPath) - return ( - title: session.title, - summary: session.statusText ?? session.subtitle, - sourceSessionID: session.id, - changedFiles: files, - diffStat: stat, - testSummary: nil - ) - case .branches(let repoPath, let leftBranch, let rightBranch): - let gitService = GitService() - let files = (try? await gitService.diffNameStatus(path: repoPath, between: leftBranch, and: rightBranch)) ?? [] - let stat = try? await gitService.diffStat(path: repoPath, between: leftBranch, and: rightBranch) - return ( - title: "\(leftBranch) ... \(rightBranch)", - summary: URL(fileURLWithPath: repoPath).lastPathComponent, - sourceSessionID: nil, - changedFiles: files, - diffStat: stat, - testSummary: nil - ) - } + addQueueItem( + sessionID: sessionID, + category: category, + title: title, + subtitle: subtitle, + relatedObjectID: relatedObjectID + ) + } + + func addTimeline( + sessionID: UUID, + type: TimelineItemType, + title: String, + relatedObjectID: UUID? + ) { + timelineItems = timelineService.append( + TimelineItem( + id: UUID(), + sessionID: sessionID, + createdAt: Date(), + type: type, + title: title, + relatedObjectID: relatedObjectID + ), + to: timelineItems + ) + } + + func makeGitSnapshot(for session: Session) async throws -> ( + branchName: String?, + commitSHA: String?, + changedFiles: [ChangedFileSummary], + diffStat: DiffStat? + ) { + let targetPath = session.worktreePath ?? session.repoPath + guard let targetPath else { + return (session.branchName, nil, [], nil) } - - func resolveSessionID(for envelope: AgentEventEnvelope) -> UUID? { - if let sessionID = envelope.sessionID, - sessionService.sessions.contains(where: { $0.id == sessionID }) { - return sessionID - } - - guard let sessionTitleHint = envelope.sessionTitleHint?.trimmingCharacters(in: .whitespacesAndNewlines), - !sessionTitleHint.isEmpty else { - return nil - } - - let activeProjectID = sessionService.selectedSession?.projectID - let candidates = sessionService.sessions.filter { session in - guard let activeProjectID else { return false } - guard session.projectID == activeProjectID else { return false } - return session.title == sessionTitleHint - } - guard candidates.count == 1 else { return nil } - return candidates[0].id + let gitService = GitService() + + let branch = try? await gitService.currentBranch(repoPath: targetPath) + let commitSHA = try? await gitService.currentCommitSHA(repoPath: targetPath) + let changedFiles = await (try? gitService.diffNameStatus(path: targetPath)) ?? [] + let diffStat = await (try? gitService.diffStat(path: targetPath)) + + return (branch ?? session.branchName, commitSHA, changedFiles, diffStat) + } + + func compareValue(for input: CompareInput) async -> ( + title: String, + summary: String, + sourceSessionID: UUID?, + changedFiles: [ChangedFileSummary], + diffStat: DiffStat?, + testSummary: TestSummary? + )? { + switch input { + case let .checkpoint(id): + guard let checkpoint = checkpoints.first(where: { $0.id == id }) else { return nil } + return ( + title: checkpoint.title, + summary: checkpoint.summary, + sourceSessionID: checkpoint.sessionID, + changedFiles: checkpoint.changedFiles, + diffStat: checkpoint.diffStat, + testSummary: checkpoint.testSummary + ) + case let .session(sessionID): + guard let session = sessionService.sessions.first(where: { $0.id == sessionID }) else { return nil } + let repoPath = session.worktreePath ?? session.repoPath + guard let repoPath else { + return ( + title: session.title, + summary: session.statusText ?? session.subtitle, + sourceSessionID: session.id, + changedFiles: [], + diffStat: nil, + testSummary: nil + ) + } + + let gitService = GitService() + let files = await (try? gitService.diffNameStatus(path: repoPath)) ?? [] + let stat = try? await gitService.diffStat(path: repoPath) + return ( + title: session.title, + summary: session.statusText ?? session.subtitle, + sourceSessionID: session.id, + changedFiles: files, + diffStat: stat, + testSummary: nil + ) + case let .branches(repoPath, leftBranch, rightBranch): + let gitService = GitService() + let files = await (try? gitService.diffNameStatus(path: repoPath, between: leftBranch, and: rightBranch)) ?? [] + let stat = try? await gitService.diffStat(path: repoPath, between: leftBranch, and: rightBranch) + return ( + title: "\(leftBranch) ... \(rightBranch)", + summary: URL(fileURLWithPath: repoPath).lastPathComponent, + sourceSessionID: nil, + changedFiles: files, + diffStat: stat, + testSummary: nil + ) } + } - func parseUUID(from value: JSONValue?) -> UUID? { - guard let raw = value?.stringValue else { return nil } - return UUID(uuidString: raw) + func resolveSessionID(for envelope: AgentEventEnvelope) -> UUID? { + if let sessionID = envelope.sessionID, + sessionService.sessions.contains(where: { $0.id == sessionID }) + { + return sessionID } - func parseStringArray(from value: JSONValue?) -> [String] { - guard let values = value?.arrayValue else { return [] } - return values.compactMap(\.stringValue) + guard let sessionTitleHint = envelope.sessionTitleHint?.trimmingCharacters(in: .whitespacesAndNewlines), + !sessionTitleHint.isEmpty + else { + return nil } - func parseListText(_ text: String) -> [String] { - text - .split(whereSeparator: { $0 == "," || $0 == "\n" }) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } + let activeProjectID = sessionService.selectedSession?.projectID + let candidates = sessionService.sessions.filter { session in + guard let activeProjectID else { return false } + guard session.projectID == activeProjectID else { return false } + return session.title == sessionTitleHint } - - func sanitizeSelections() { - if let selectedCheckpointID, - !checkpoints.contains(where: { $0.id == selectedCheckpointID }) { - self.selectedCheckpointID = checkpoints.sorted(by: { $0.createdAt > $1.createdAt }).first?.id - } - - if let selectedReviewID, - !reviews.contains(where: { $0.id == selectedReviewID }) { - self.selectedReviewID = reviews.sorted(by: { $0.createdAt > $1.createdAt }).first?.id - } - - if let selectedHandoffID, - !handoffs.contains(where: { $0.id == selectedHandoffID }) { - self.selectedHandoffID = handoffs.sorted(by: { $0.createdAt > $1.createdAt }).first?.id - } + guard candidates.count == 1 else { return nil } + return candidates[0].id + } + + func parseUUID(from value: JSONValue?) -> UUID? { + guard let raw = value?.stringValue else { return nil } + return UUID(uuidString: raw) + } + + func parseStringArray(from value: JSONValue?) -> [String] { + guard let values = value?.arrayValue else { return [] } + return values.compactMap(\.stringValue) + } + + func parseListText(_ text: String) -> [String] { + text + .split(whereSeparator: { $0 == "," || $0 == "\n" }) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + func sanitizeSelections() { + if let selectedCheckpointID, + !checkpoints.contains(where: { $0.id == selectedCheckpointID }) + { + self.selectedCheckpointID = checkpoints.sorted(by: { $0.createdAt > $1.createdAt }).first?.id } - func parseChangedFiles(from value: JSONValue?) -> [ChangedFileSummary] { - guard let array = value?.arrayValue else { return [] } - return array.compactMap { item in - guard let object = item.objectValue else { return nil } - guard let path = object["path"]?.stringValue else { return nil } - let additions = object["additions"]?.intValue - let deletions = object["deletions"]?.intValue - let status = object["status"]?.stringValue ?? "M" - return ChangedFileSummary(path: path, additions: additions, deletions: deletions, status: status) - } + if let selectedReviewID, + !reviews.contains(where: { $0.id == selectedReviewID }) + { + self.selectedReviewID = reviews.sorted(by: { $0.createdAt > $1.createdAt }).first?.id } - func parseDiffStat(from value: JSONValue?) -> DiffStat? { - guard let object = value?.objectValue else { return nil } - guard let filesChanged = object["filesChanged"]?.intValue else { return nil } - guard let additions = object["additions"]?.intValue else { return nil } - guard let deletions = object["deletions"]?.intValue else { return nil } - return DiffStat(filesChanged: filesChanged, additions: additions, deletions: deletions) + if let selectedHandoffID, + !handoffs.contains(where: { $0.id == selectedHandoffID }) + { + self.selectedHandoffID = handoffs.sorted(by: { $0.createdAt > $1.createdAt }).first?.id } - - func parseTestSummary(from value: JSONValue?) -> TestSummary? { - guard let object = value?.objectValue else { return nil } - let statusRaw = object["status"]?.stringValue ?? TestStatus.unknown.rawValue - let status = TestStatus(rawValue: statusRaw) ?? .unknown - let text = object["summaryText"]?.stringValue ?? "Unknown" - return TestSummary(status: status, summaryText: text) + } + + func parseChangedFiles(from value: JSONValue?) -> [ChangedFileSummary] { + guard let array = value?.arrayValue else { return [] } + return array.compactMap { item in + guard let object = item.objectValue else { return nil } + guard let path = object["path"]?.stringValue else { return nil } + let additions = object["additions"]?.intValue + let deletions = object["deletions"]?.intValue + let status = object["status"]?.stringValue ?? "M" + return ChangedFileSummary(path: path, additions: additions, deletions: deletions, status: status) } - - func postApprovalNotificationIfNeeded(sessionID: UUID, title: String, summary: String) { - guard !NSApp.isActive else { return } - Task { @MainActor [sessionID, title, summary] in - let notificationCenter = UNUserNotificationCenter.current() - let granted = (try? await notificationCenter.requestAuthorization(options: [.alert, .sound])) ?? false - guard granted else { return } - - let content = UNMutableNotificationContent() - content.title = title - content.body = summary - content.sound = .default - content.userInfo = ["sessionID": sessionID.uuidString] - - let request = UNNotificationRequest( - identifier: "idx0.approval.\(sessionID.uuidString).\(Date().timeIntervalSince1970)", - content: content, - trigger: nil - ) - _ = try? await addNotificationRequest(request) - } + } + + func parseDiffStat(from value: JSONValue?) -> DiffStat? { + guard let object = value?.objectValue else { return nil } + guard let filesChanged = object["filesChanged"]?.intValue else { return nil } + guard let additions = object["additions"]?.intValue else { return nil } + guard let deletions = object["deletions"]?.intValue else { return nil } + return DiffStat(filesChanged: filesChanged, additions: additions, deletions: deletions) + } + + func parseTestSummary(from value: JSONValue?) -> TestSummary? { + guard let object = value?.objectValue else { return nil } + let statusRaw = object["status"]?.stringValue ?? TestStatus.unknown.rawValue + let status = TestStatus(rawValue: statusRaw) ?? .unknown + let text = object["summaryText"]?.stringValue ?? "Unknown" + return TestSummary(status: status, summaryText: text) + } + + func postApprovalNotificationIfNeeded(sessionID: UUID, title: String, summary: String) { + guard !NSApp.isActive else { return } + Task { @MainActor [sessionID, title, summary] in + let notificationCenter = UNUserNotificationCenter.current() + let granted = await (try? notificationCenter.requestAuthorization(options: [.alert, .sound])) ?? false + guard granted else { return } + + let content = UNMutableNotificationContent() + content.title = title + content.body = summary + content.sound = .default + content.userInfo = ["sessionID": sessionID.uuidString] + + let request = UNNotificationRequest( + identifier: "idx0.approval.\(sessionID.uuidString).\(Date().timeIntervalSince1970)", + content: content, + trigger: nil + ) + _ = try? await addNotificationRequest(request) } - - func addNotificationRequest(_ request: UNNotificationRequest) async throws { - let notificationCenter = UNUserNotificationCenter.current() - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - notificationCenter.add(request) { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: ()) - } - } + } + + func addNotificationRequest(_ request: UNNotificationRequest) async throws { + let notificationCenter = UNUserNotificationCenter.current() + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + notificationCenter.add(request) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) } + } } + } } From 962de3e7463c0a30d522d2e431b23ba76cbc3d05 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 21:09:24 -0700 Subject: [PATCH 08/13] fix: make closure captures explicit in workflow stream binding --- idx0/Services/Workflow/WorkflowService.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/idx0/Services/Workflow/WorkflowService.swift b/idx0/Services/Workflow/WorkflowService.swift index 9d96eaf..0af377f 100644 --- a/idx0/Services/Workflow/WorkflowService.swift +++ b/idx0/Services/Workflow/WorkflowService.swift @@ -255,16 +255,16 @@ final class WorkflowService: ObservableObject { sessionService.$selectedSessionID .sink { [weak self] sessionID in Task { @MainActor [weak self] in - guard let self else { return } - layoutState.focusedSessionID = sessionID + guard let service = self else { return } + service.layoutState.focusedSessionID = sessionID if let sessionID, - let saved = layoutState.lastRailSurfaceBySession[sessionID] + let saved = service.layoutState.lastRailSurfaceBySession[sessionID] { - selectedRailSurface = saved + service.selectedRailSurface = saved } else if sessionID != nil { - selectedRailSurface = .checkpoints + service.selectedRailSurface = .checkpoints } - persistSoon() + service.persistSoon() } } .store(in: &cancellables) From 3259616aa4c2e98479eaa712485219598e471348 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 21:12:38 -0700 Subject: [PATCH 09/13] ci: restore and save GhosttyKit cache explicitly --- .github/workflows/presubmit.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/presubmit.yml b/.github/workflows/presubmit.yml index 9dff13d..3cadb43 100644 --- a/.github/workflows/presubmit.yml +++ b/.github/workflows/presubmit.yml @@ -91,17 +91,25 @@ jobs: echo "sha=$ghostty_sha" >> "$GITHUB_OUTPUT" - name: Restore GhosttyKit cache - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + id: ghosttykit-cache-restore + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 with: path: ~/.cache/idx0/ghosttykit - key: ghosttykit-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-cache.outputs.sha }}-${{ hashFiles('scripts/setup.sh') }} + key: ghosttykit-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-cache.outputs.sha }} restore-keys: | - ghosttykit-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-cache.outputs.sha }}- ghosttykit-${{ runner.os }}-${{ runner.arch }}- - name: Setup GhosttyKit + id: setup-ghosttykit run: ./scripts/setup.sh + - name: Save GhosttyKit cache + if: steps.setup-ghosttykit.conclusion == 'success' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ~/.cache/idx0/ghosttykit + key: ghosttykit-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-cache.outputs.sha }} + - name: Generate Xcode project run: xcodegen generate From 01f8803d87bab5d7501d66b0c6b926badc353599 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 21:28:45 -0700 Subject: [PATCH 10/13] tests: align Niri resize expectations with ratio sizing --- idx0Tests/SessionServiceTests+Niri.swift | 42 +++++++++++++----------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/idx0Tests/SessionServiceTests+Niri.swift b/idx0Tests/SessionServiceTests+Niri.swift index cf5c7c5..0be7723 100644 --- a/idx0Tests/SessionServiceTests+Niri.swift +++ b/idx0Tests/SessionServiceTests+Niri.swift @@ -527,15 +527,17 @@ extension SessionServiceTests { sessionID: session.id, workspaceID: workspace.id, leftColumnID: left.id, - leftWidth: 640, + leftWidth: 1.6, rightColumnID: right.id, - rightWidth: 420 + rightWidth: 0.9 ) layout = service.niriLayout(for: session.id) let resizedWorkspace = layout.workspaces[workspaceIndex] - XCTAssertEqual(resizedWorkspace.columns[0].preferredWidth, 640) - XCTAssertEqual(resizedWorkspace.columns[1].preferredWidth, 420) + let leftWidth = try XCTUnwrap(resizedWorkspace.columns[0].preferredWidth) + let rightWidth = try XCTUnwrap(resizedWorkspace.columns[1].preferredWidth) + XCTAssertEqual(leftWidth, 1.6, accuracy: 0.001) + XCTAssertEqual(rightWidth, 0.9, accuracy: 0.001) } func testNiriItemResizePersistsPreferredHeights() async throws { @@ -568,15 +570,17 @@ extension SessionServiceTests { workspaceID: workspace.id, columnID: column.id, upperItemID: upper.id, - upperHeight: 300, + upperHeight: 1.2, lowerItemID: lower.id, - lowerHeight: 220 + lowerHeight: 0.7 ) layout = service.niriLayout(for: session.id) let resizedColumn = layout.workspaces[workspaceIndex].columns[columnIndex] - XCTAssertEqual(resizedColumn.items[0].preferredHeight, 300) - XCTAssertEqual(resizedColumn.items[1].preferredHeight, 220) + let upperHeight = try XCTUnwrap(resizedColumn.items[0].preferredHeight) + let lowerHeight = try XCTUnwrap(resizedColumn.items[1].preferredHeight) + XCTAssertEqual(upperHeight, 1.2, accuracy: 0.001) + XCTAssertEqual(lowerHeight, 0.7, accuracy: 0.001) } func testNiriMoveItemAcrossWorkspacesUpdatesCameraAndFocus() async throws { @@ -915,8 +919,8 @@ extension SessionServiceTests { service.ensureNiriLayoutState(for: session.id) service.saveSettings { settings in - settings.niri.defaultNewColumnWidth = 900 - settings.niri.defaultNewTileHeight = 520 + settings.niri.defaultNewColumnWidth = 0.9 + settings.niri.defaultNewTileHeight = 0.52 } guard let firstItemID = service.niriAddTerminalRight(in: session.id) else { @@ -931,12 +935,12 @@ extension SessionServiceTests { } let firstWidth = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].preferredWidth) let firstHeight = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].items[firstPath.itemIndex].preferredHeight) - XCTAssertEqual(firstWidth, 900, accuracy: 0.001) - XCTAssertEqual(firstHeight, 520, accuracy: 0.001) + XCTAssertEqual(firstWidth, 0.9, accuracy: 0.001) + XCTAssertEqual(firstHeight, 0.52, accuracy: 0.001) service.saveSettings { settings in - settings.niri.defaultNewColumnWidth = 1100 - settings.niri.defaultNewTileHeight = 640 + settings.niri.defaultNewColumnWidth = 1.1 + settings.niri.defaultNewTileHeight = 0.64 } guard let secondItemID = service.niriAddTaskBelow(in: session.id), @@ -959,11 +963,11 @@ extension SessionServiceTests { let thirdWidth = try XCTUnwrap(layout.workspaces[thirdPath.workspaceIndex].columns[thirdPath.columnIndex].preferredWidth) let thirdHeight = try XCTUnwrap(layout.workspaces[thirdPath.workspaceIndex].columns[thirdPath.columnIndex].items[thirdPath.itemIndex].preferredHeight) - XCTAssertEqual(unchangedFirstWidth, 900, accuracy: 0.001) - XCTAssertEqual(unchangedFirstHeight, 520, accuracy: 0.001) - XCTAssertEqual(secondHeight, 640, accuracy: 0.001) - XCTAssertEqual(thirdWidth, 1100, accuracy: 0.001) - XCTAssertEqual(thirdHeight, 640, accuracy: 0.001) + XCTAssertEqual(unchangedFirstWidth, 0.9, accuracy: 0.001) + XCTAssertEqual(unchangedFirstHeight, 0.52, accuracy: 0.001) + XCTAssertEqual(secondHeight, 0.64, accuracy: 0.001) + XCTAssertEqual(thirdWidth, 1.1, accuracy: 0.001) + XCTAssertEqual(thirdHeight, 0.64, accuracy: 0.001) } func testNiriGenericAppSelectionEnsuresControllerViaDescriptorFactory() async throws { From e2b3992af16641720ff596640f251235b734cf64 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 21:31:50 -0700 Subject: [PATCH 11/13] style: format SessionServiceTests+Niri.swift --- idx0Tests/SessionServiceTests+Niri.swift | 2162 +++++++++++----------- 1 file changed, 1083 insertions(+), 1079 deletions(-) diff --git a/idx0Tests/SessionServiceTests+Niri.swift b/idx0Tests/SessionServiceTests+Niri.swift index 0be7723..948e54b 100644 --- a/idx0Tests/SessionServiceTests+Niri.swift +++ b/idx0Tests/SessionServiceTests+Niri.swift @@ -1,1193 +1,1197 @@ -import XCTest @testable import idx0 +import XCTest extension SessionServiceTests { - func testNiriSelectingTerminalPrimesMissingController() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Prime Controller")).session - service.ensureNiriLayoutState(for: session.id) - - let layout = service.niriLayout(for: session.id) - guard let itemID = layout.camera.focusedItemID else { - XCTFail("Expected focused niri item") - return - } - guard let tabID = service.selectedTabID(for: session.id), - let tab = service.tabState(sessionID: session.id, tabID: tabID) else { - XCTFail("Expected selected tab") - return - } + func testNiriSelectingTerminalPrimesMissingController() async throws { + let fixture = try Fixture() + let service = fixture.service - let controllerID = tab.activeControllerID - service.runtimeControllers.removeValue(forKey: controllerID) - service.ownerSessionIDByControllerID.removeValue(forKey: controllerID) - XCTAssertNil(service.paneController(for: controllerID)) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Prime Controller")).session + service.ensureNiriLayoutState(for: session.id) - service.niriSelectItem(sessionID: session.id, itemID: itemID) - XCTAssertNotNil(service.paneController(for: controllerID)) + let layout = service.niriLayout(for: session.id) + guard let itemID = layout.camera.focusedItemID else { + XCTFail("Expected focused niri item") + return } - - func testNiriLayoutMaintainsSingleTrailingEmptyWorkspace() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Invariant")).session - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddTerminalRight(in: session.id) - _ = service.niriAddTaskBelow(in: session.id) - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - service.moveNiriColumnToWorkspaceUp(sessionID: session.id) - - let layout = service.niriLayout(for: session.id) - assertHasSingleTrailingEmptyWorkspace(layout) + guard let tabID = service.selectedTabID(for: session.id), + let tab = service.tabState(sessionID: session.id, tabID: tabID) + else { + XCTFail("Expected selected tab") + return } - func testNiriToggleColumnTabbedDisplayPreservesItemsAndFocus() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Tabs")).session - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddTaskBelow(in: session.id) + let controllerID = tab.activeControllerID + service.runtimeControllers.removeValue(forKey: controllerID) + service.ownerSessionIDByControllerID.removeValue(forKey: controllerID) + XCTAssertNil(service.paneController(for: controllerID)) + + service.niriSelectItem(sessionID: session.id, itemID: itemID) + XCTAssertNotNil(service.paneController(for: controllerID)) + } + + func testNiriLayoutMaintainsSingleTrailingEmptyWorkspace() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Invariant")).session + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddTerminalRight(in: session.id) + _ = service.niriAddTaskBelow(in: session.id) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + service.moveNiriColumnToWorkspaceUp(sessionID: session.id) + + let layout = service.niriLayout(for: session.id) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testNiriToggleColumnTabbedDisplayPreservesItemsAndFocus() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Tabs")).session + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddTaskBelow(in: session.id) + + var layout = service.niriLayout(for: session.id) + guard let workspaceIndex = niriActiveWorkspaceIndex(layout), + let columnIndex = niriActiveColumnIndex(layout, workspaceIndex: workspaceIndex) + else { + XCTFail("Expected active workspace and column") + return + } - var layout = service.niriLayout(for: session.id) - guard let workspaceIndex = niriActiveWorkspaceIndex(layout), - let columnIndex = niriActiveColumnIndex(layout, workspaceIndex: workspaceIndex) - else { - XCTFail("Expected active workspace and column") - return - } + let beforeItemIDs = layout.workspaces[workspaceIndex].columns[columnIndex].items.map(\.id) + let beforeFocused = layout.workspaces[workspaceIndex].columns[columnIndex].focusedItemID + let beforeMode = layout.workspaces[workspaceIndex].columns[columnIndex].displayMode - let beforeItemIDs = layout.workspaces[workspaceIndex].columns[columnIndex].items.map(\.id) - let beforeFocused = layout.workspaces[workspaceIndex].columns[columnIndex].focusedItemID - let beforeMode = layout.workspaces[workspaceIndex].columns[columnIndex].displayMode + service.toggleNiriColumnTabbedDisplay(sessionID: session.id) + layout = service.niriLayout(for: session.id) - service.toggleNiriColumnTabbedDisplay(sessionID: session.id) - layout = service.niriLayout(for: session.id) + guard let newWorkspaceIndex = niriActiveWorkspaceIndex(layout), + let newColumnIndex = niriActiveColumnIndex(layout, workspaceIndex: newWorkspaceIndex) + else { + XCTFail("Expected active workspace and column after toggle") + return + } - guard let newWorkspaceIndex = niriActiveWorkspaceIndex(layout), - let newColumnIndex = niriActiveColumnIndex(layout, workspaceIndex: newWorkspaceIndex) - else { - XCTFail("Expected active workspace and column after toggle") - return - } + let afterColumn = layout.workspaces[newWorkspaceIndex].columns[newColumnIndex] + XCTAssertEqual(afterColumn.items.map(\.id), beforeItemIDs) + XCTAssertEqual(afterColumn.focusedItemID, beforeFocused) + XCTAssertNotEqual(afterColumn.displayMode, beforeMode) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testNiriOverviewHorizontalNavigationUsesRowAlignedTargets() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Overview Row Align")).session + service.ensureNiriLayoutState(for: session.id) + + guard let tile2 = service.niriAddTerminalRight(in: session.id), + let tile3 = service.niriAddTerminalRight(in: session.id) + else { + XCTFail("Expected right-side tiles") + return + } + service.niriSelectItem(sessionID: session.id, itemID: tile2) + guard let tile4 = service.niriAddTaskBelow(in: session.id) else { + XCTFail("Expected stacked tile in middle column") + return + } - let afterColumn = layout.workspaces[newWorkspaceIndex].columns[newColumnIndex] - XCTAssertEqual(afterColumn.items.map(\.id), beforeItemIDs) - XCTAssertEqual(afterColumn.focusedItemID, beforeFocused) - XCTAssertNotEqual(afterColumn.displayMode, beforeMode) - assertHasSingleTrailingEmptyWorkspace(layout) + service.toggleNiriOverview(sessionID: session.id) + var layout = service.niriLayout(for: session.id) + guard let workspaceIndex = niriActiveWorkspaceIndex(layout), + layout.workspaces[workspaceIndex].columns.count >= 3, + let tile1 = layout.workspaces[workspaceIndex].columns[0].items.first?.id + else { + XCTFail("Expected three columns with a leading tile") + return } - func testNiriOverviewHorizontalNavigationUsesRowAlignedTargets() async throws { - let fixture = try Fixture() - let service = fixture.service + service.niriSelectItem(sessionID: session.id, itemID: tile1) + service.niriFocusNeighbor(sessionID: session.id, horizontal: 1) + layout = service.niriLayout(for: session.id) + XCTAssertEqual(layout.camera.focusedItemID, tile2) + + service.niriSelectItem(sessionID: session.id, itemID: tile4) + service.niriFocusNeighbor(sessionID: session.id, horizontal: 1) + layout = service.niriLayout(for: session.id) + XCTAssertEqual(layout.camera.focusedItemID, tile3) + + service.niriSelectItem(sessionID: session.id, itemID: tile4) + service.niriFocusNeighbor(sessionID: session.id, horizontal: -1) + layout = service.niriLayout(for: session.id) + XCTAssertEqual(layout.camera.focusedItemID, tile1) + } + + func testNiriAddBrowserRightCreatesNewTileEachTime() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser Tiles")).session + service.ensureNiriLayoutState(for: session.id) + + guard let firstBrowserItemID = service.niriAddBrowserRight(in: session.id), + let secondBrowserItemID = service.niriAddBrowserRight(in: session.id) + else { + XCTFail("Expected browser items") + return + } - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Overview Row Align")).session - service.ensureNiriLayoutState(for: session.id) + XCTAssertNotEqual(firstBrowserItemID, secondBrowserItemID) - guard let tile2 = service.niriAddTerminalRight(in: session.id), - let tile3 = service.niriAddTerminalRight(in: session.id) else { - XCTFail("Expected right-side tiles") - return - } - service.niriSelectItem(sessionID: session.id, itemID: tile2) - guard let tile4 = service.niriAddTaskBelow(in: session.id) else { - XCTFail("Expected stacked tile in middle column") - return + let layout = service.niriLayout(for: session.id) + let browserItems = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .filter { item in + if case .browser = item.ref { + return true } + return false + } - service.toggleNiriOverview(sessionID: session.id) - var layout = service.niriLayout(for: session.id) - guard let workspaceIndex = niriActiveWorkspaceIndex(layout), - layout.workspaces[workspaceIndex].columns.count >= 3, - let tile1 = layout.workspaces[workspaceIndex].columns[0].items.first?.id else { - XCTFail("Expected three columns with a leading tile") - return - } + XCTAssertEqual(browserItems.count, 2) + } - service.niriSelectItem(sessionID: session.id, itemID: tile1) - service.niriFocusNeighbor(sessionID: session.id, horizontal: 1) - layout = service.niriLayout(for: session.id) - XCTAssertEqual(layout.camera.focusedItemID, tile2) + func testNiriBrowserControllersArePerTile() async throws { + let fixture = try Fixture() + let service = fixture.service - service.niriSelectItem(sessionID: session.id, itemID: tile4) - service.niriFocusNeighbor(sessionID: session.id, horizontal: 1) - layout = service.niriLayout(for: session.id) - XCTAssertEqual(layout.camera.focusedItemID, tile3) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser Controllers")).session + service.ensureNiriLayoutState(for: session.id) - service.niriSelectItem(sessionID: session.id, itemID: tile4) - service.niriFocusNeighbor(sessionID: session.id, horizontal: -1) - layout = service.niriLayout(for: session.id) - XCTAssertEqual(layout.camera.focusedItemID, tile1) + guard let firstBrowserItemID = service.niriAddBrowserRight(in: session.id), + let secondBrowserItemID = service.niriAddBrowserRight(in: session.id) + else { + XCTFail("Expected browser items") + return } - func testNiriAddBrowserRightCreatesNewTileEachTime() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser Tiles")).session - service.ensureNiriLayoutState(for: session.id) + let firstController = service.niriBrowserController(for: session.id, itemID: firstBrowserItemID) + let secondController = service.niriBrowserController(for: session.id, itemID: secondBrowserItemID) - guard let firstBrowserItemID = service.niriAddBrowserRight(in: session.id), - let secondBrowserItemID = service.niriAddBrowserRight(in: session.id) - else { - XCTFail("Expected browser items") - return - } + XCTAssertNotNil(firstController) + XCTAssertNotNil(secondController) + XCTAssertFalse(firstController === secondController) + } - XCTAssertNotEqual(firstBrowserItemID, secondBrowserItemID) + func testNiriAddT3CodeReusesExistingTileAndFocusesIt() async throws { + let fixture = try Fixture() + let service = fixture.service - let layout = service.niriLayout(for: session.id) - let browserItems = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - if case .browser = item.ref { - return true - } - return false - } + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri T3 Reuse")).session + service.ensureNiriLayoutState(for: session.id) - XCTAssertEqual(browserItems.count, 2) + guard let firstT3ID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) else { + XCTFail("Expected T3 tile") + return } - func testNiriBrowserControllersArePerTile() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser Controllers")).session - service.ensureNiriLayoutState(for: session.id) - - guard let firstBrowserItemID = service.niriAddBrowserRight(in: session.id), - let secondBrowserItemID = service.niriAddBrowserRight(in: session.id) - else { - XCTFail("Expected browser items") - return - } - - let firstController = service.niriBrowserController(for: session.id, itemID: firstBrowserItemID) - let secondController = service.niriBrowserController(for: session.id, itemID: secondBrowserItemID) - - XCTAssertNotNil(firstController) - XCTAssertNotNil(secondController) - XCTAssertFalse(firstController === secondController) + _ = service.niriAddTerminalRight(in: session.id) + let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) + XCTAssertEqual(secondResult, firstT3ID) + + let layout = service.niriLayout(for: session.id) + let t3Items = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .filter { item in + item.ref.appID == NiriAppID.t3Code + } + XCTAssertEqual(t3Items.count, 1) + XCTAssertEqual(layout.camera.focusedItemID, firstT3ID) + } + + func testNiriAddVSCodeReusesExistingTileAndFocusesIt() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri VSCode Reuse")).session + service.ensureNiriLayoutState(for: session.id) + + guard let firstVSCodeID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) else { + XCTFail("Expected VS Code tile") + return } - func testNiriAddT3CodeReusesExistingTileAndFocusesIt() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri T3 Reuse")).session - service.ensureNiriLayoutState(for: session.id) - - guard let firstT3ID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) else { - XCTFail("Expected T3 tile") - return - } - - _ = service.niriAddTerminalRight(in: session.id) - let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) - XCTAssertEqual(secondResult, firstT3ID) - - let layout = service.niriLayout(for: session.id) - let t3Items = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - item.ref.appID == NiriAppID.t3Code - } - XCTAssertEqual(t3Items.count, 1) - XCTAssertEqual(layout.camera.focusedItemID, firstT3ID) + _ = service.niriAddTerminalRight(in: session.id) + let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) + XCTAssertEqual(secondResult, firstVSCodeID) + + let layout = service.niriLayout(for: session.id) + let vscodeItems = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .filter { item in + item.ref.appID == NiriAppID.vscode + } + XCTAssertEqual(vscodeItems.count, 1) + XCTAssertEqual(layout.camera.focusedItemID, firstVSCodeID) + } + + func testNiriAddExcalidrawReusesExistingTileAndFocusesIt() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Excalidraw Reuse")).session + service.ensureNiriLayoutState(for: session.id) + + guard let firstExcalidrawID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) else { + XCTFail("Expected Excalidraw tile") + return } - func testNiriAddVSCodeReusesExistingTileAndFocusesIt() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri VSCode Reuse")).session - service.ensureNiriLayoutState(for: session.id) - - guard let firstVSCodeID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) else { - XCTFail("Expected VS Code tile") - return - } - - _ = service.niriAddTerminalRight(in: session.id) - let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) - XCTAssertEqual(secondResult, firstVSCodeID) - - let layout = service.niriLayout(for: session.id) - let vscodeItems = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - item.ref.appID == NiriAppID.vscode - } - XCTAssertEqual(vscodeItems.count, 1) - XCTAssertEqual(layout.camera.focusedItemID, firstVSCodeID) + _ = service.niriAddTerminalRight(in: session.id) + let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) + XCTAssertEqual(secondResult, firstExcalidrawID) + + let layout = service.niriLayout(for: session.id) + let excalidrawItems = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .filter { item in + item.ref.appID == NiriAppID.excalidraw + } + XCTAssertEqual(excalidrawItems.count, 1) + XCTAssertEqual(layout.camera.focusedItemID, firstExcalidrawID) + } + + func testNiriAddOpenCodeReusesExistingTileAndFocusesIt() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri OpenCode Reuse")).session + service.ensureNiriLayoutState(for: session.id) + + guard let firstOpenCodeID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) else { + XCTFail("Expected OpenCode tile") + return } - func testNiriAddExcalidrawReusesExistingTileAndFocusesIt() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Excalidraw Reuse")).session - service.ensureNiriLayoutState(for: session.id) - - guard let firstExcalidrawID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) else { - XCTFail("Expected Excalidraw tile") - return - } - - _ = service.niriAddTerminalRight(in: session.id) - let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) - XCTAssertEqual(secondResult, firstExcalidrawID) - - let layout = service.niriLayout(for: session.id) - let excalidrawItems = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - item.ref.appID == NiriAppID.excalidraw - } - XCTAssertEqual(excalidrawItems.count, 1) - XCTAssertEqual(layout.camera.focusedItemID, firstExcalidrawID) + _ = service.niriAddTerminalRight(in: session.id) + let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) + XCTAssertEqual(secondResult, firstOpenCodeID) + + let layout = service.niriLayout(for: session.id) + let openCodeItems = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .filter { item in + item.ref.appID == NiriAppID.openCode + } + XCTAssertEqual(openCodeItems.count, 1) + XCTAssertEqual(layout.camera.focusedItemID, firstOpenCodeID) + } + + func testCloseNiriFocusedT3TileRemovesItemAndController() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close T3")).session + service.ensureNiriLayoutState(for: session.id) + guard let t3ItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) else { + XCTFail("Expected T3 tile") + return } - - func testNiriAddOpenCodeReusesExistingTileAndFocusesIt() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri OpenCode Reuse")).session - service.ensureNiriLayoutState(for: session.id) - - guard let firstOpenCodeID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) else { - XCTFail("Expected OpenCode tile") - return - } - - _ = service.niriAddTerminalRight(in: session.id) - let secondResult = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) - XCTAssertEqual(secondResult, firstOpenCodeID) - - let layout = service.niriLayout(for: session.id) - let openCodeItems = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .filter { item in - item.ref.appID == NiriAppID.openCode - } - XCTAssertEqual(openCodeItems.count, 1) - XCTAssertEqual(layout.camera.focusedItemID, firstOpenCodeID) + let initialT3Controller: T3TileController? = service.niriAppController( + for: session.id, + itemID: t3ItemID, + appID: NiriAppID.t3Code, + as: T3TileController.self + ) + XCTAssertNotNil(initialT3Controller) + + service.closeNiriFocusedItem(in: session.id) + let layout = service.niriLayout(for: session.id) + let stillExists = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .contains(where: { $0.id == t3ItemID }) + + XCTAssertFalse(stillExists) + let removedT3Controller: T3TileController? = service.niriAppController( + for: session.id, + itemID: t3ItemID, + appID: NiriAppID.t3Code, + as: T3TileController.self + ) + XCTAssertNil(removedT3Controller) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testCloseNiriFocusedVSCodeTileRemovesItemAndController() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close VSCode")).session + service.ensureNiriLayoutState(for: session.id) + guard let vscodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) else { + XCTFail("Expected VS Code tile") + return } - - func testCloseNiriFocusedT3TileRemovesItemAndController() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close T3")).session - service.ensureNiriLayoutState(for: session.id) - guard let t3ItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) else { - XCTFail("Expected T3 tile") - return - } - let initialT3Controller: T3TileController? = service.niriAppController( - for: session.id, - itemID: t3ItemID, - appID: NiriAppID.t3Code, - as: T3TileController.self - ) - XCTAssertNotNil(initialT3Controller) - - service.closeNiriFocusedItem(in: session.id) - let layout = service.niriLayout(for: session.id) - let stillExists = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .contains(where: { $0.id == t3ItemID }) - - XCTAssertFalse(stillExists) - let removedT3Controller: T3TileController? = service.niriAppController( - for: session.id, - itemID: t3ItemID, - appID: NiriAppID.t3Code, - as: T3TileController.self - ) - XCTAssertNil(removedT3Controller) - assertHasSingleTrailingEmptyWorkspace(layout) + let initialVSCodeController: VSCodeTileController? = service.niriAppController( + for: session.id, + itemID: vscodeItemID, + appID: NiriAppID.vscode, + as: VSCodeTileController.self + ) + XCTAssertNotNil(initialVSCodeController) + + service.closeNiriFocusedItem(in: session.id) + let layout = service.niriLayout(for: session.id) + let stillExists = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .contains(where: { $0.id == vscodeItemID }) + + XCTAssertFalse(stillExists) + let removedVSCodeController: VSCodeTileController? = service.niriAppController( + for: session.id, + itemID: vscodeItemID, + appID: NiriAppID.vscode, + as: VSCodeTileController.self + ) + XCTAssertNil(removedVSCodeController) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testCloseNiriFocusedExcalidrawTileRemovesItemAndController() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Excalidraw")).session + service.ensureNiriLayoutState(for: session.id) + guard let itemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) else { + XCTFail("Expected Excalidraw tile") + return } - - func testCloseNiriFocusedVSCodeTileRemovesItemAndController() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close VSCode")).session - service.ensureNiriLayoutState(for: session.id) - guard let vscodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) else { - XCTFail("Expected VS Code tile") - return - } - let initialVSCodeController: VSCodeTileController? = service.niriAppController( - for: session.id, - itemID: vscodeItemID, - appID: NiriAppID.vscode, - as: VSCodeTileController.self - ) - XCTAssertNotNil(initialVSCodeController) - - service.closeNiriFocusedItem(in: session.id) - let layout = service.niriLayout(for: session.id) - let stillExists = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .contains(where: { $0.id == vscodeItemID }) - - XCTAssertFalse(stillExists) - let removedVSCodeController: VSCodeTileController? = service.niriAppController( - for: session.id, - itemID: vscodeItemID, - appID: NiriAppID.vscode, - as: VSCodeTileController.self - ) - XCTAssertNil(removedVSCodeController) - assertHasSingleTrailingEmptyWorkspace(layout) + let initialController: ExcalidrawTileController? = service.niriAppController( + for: session.id, + itemID: itemID, + appID: NiriAppID.excalidraw, + as: ExcalidrawTileController.self + ) + XCTAssertNotNil(initialController) + + service.closeNiriFocusedItem(in: session.id) + let layout = service.niriLayout(for: session.id) + let stillExists = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .contains(where: { $0.id == itemID }) + + XCTAssertFalse(stillExists) + let removedController: ExcalidrawTileController? = service.niriAppController( + for: session.id, + itemID: itemID, + appID: NiriAppID.excalidraw, + as: ExcalidrawTileController.self + ) + XCTAssertNil(removedController) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testCloseNiriFocusedOpenCodeTileRemovesItemControllerAndArtifacts() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close OpenCode")).session + service.ensureNiriLayoutState(for: session.id) + guard let openCodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) else { + XCTFail("Expected OpenCode tile") + return } - func testCloseNiriFocusedExcalidrawTileRemovesItemAndController() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Excalidraw")).session - service.ensureNiriLayoutState(for: session.id) - guard let itemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) else { - XCTFail("Expected Excalidraw tile") - return - } - let initialController: ExcalidrawTileController? = service.niriAppController( - for: session.id, - itemID: itemID, - appID: NiriAppID.excalidraw, - as: ExcalidrawTileController.self - ) - XCTAssertNotNil(initialController) - - service.closeNiriFocusedItem(in: session.id) - let layout = service.niriLayout(for: session.id) - let stillExists = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .contains(where: { $0.id == itemID }) - - XCTAssertFalse(stillExists) - let removedController: ExcalidrawTileController? = service.niriAppController( - for: session.id, - itemID: itemID, - appID: NiriAppID.excalidraw, - as: ExcalidrawTileController.self - ) - XCTAssertNil(removedController) - assertHasSingleTrailingEmptyWorkspace(layout) + let initialController: OpenCodeTileController? = service.niriAppController( + for: session.id, + itemID: openCodeItemID, + appID: NiriAppID.openCode, + as: OpenCodeTileController.self + ) + XCTAssertNotNil(initialController) + + let paths = OpenCodeRuntimePaths(sessionID: session.id) + try paths.ensureBaseDirectories() + let marker = paths.sessionDirectory.appendingPathComponent("marker.txt", isDirectory: false) + try "marker".write(to: marker, atomically: true, encoding: .utf8) + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + + service.closeNiriFocusedItem(in: session.id) + let layout = service.niriLayout(for: session.id) + let stillExists = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .contains(where: { $0.id == openCodeItemID }) + XCTAssertFalse(stillExists) + + let removedController: OpenCodeTileController? = service.niriAppController( + for: session.id, + itemID: openCodeItemID, + appID: NiriAppID.openCode, + as: OpenCodeTileController.self + ) + XCTAssertNil(removedController) + XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testCloseSessionWithOpenCodeCleansSessionArtifacts() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Session OpenCode")).session + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) + + let paths = OpenCodeRuntimePaths(sessionID: session.id) + try paths.ensureBaseDirectories() + let marker = paths.sessionDirectory.appendingPathComponent("marker.txt", isDirectory: false) + try "marker".write(to: marker, atomically: true, encoding: .utf8) + XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) + + service.closeSession(session.id) + + XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) + } + + func testCloseNiriFocusedBrowserTileRemovesItem() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Browser")).session + service.ensureNiriLayoutState(for: session.id) + guard let browserItemID = service.niriAddBrowserRight(in: session.id) else { + XCTFail("Expected browser tile") + return } - func testCloseNiriFocusedOpenCodeTileRemovesItemControllerAndArtifacts() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close OpenCode")).session - service.ensureNiriLayoutState(for: session.id) - guard let openCodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) else { - XCTFail("Expected OpenCode tile") - return - } - - let initialController: OpenCodeTileController? = service.niriAppController( - for: session.id, - itemID: openCodeItemID, - appID: NiriAppID.openCode, - as: OpenCodeTileController.self - ) - XCTAssertNotNil(initialController) - - let paths = OpenCodeRuntimePaths(sessionID: session.id) - try paths.ensureBaseDirectories() - let marker = paths.sessionDirectory.appendingPathComponent("marker.txt", isDirectory: false) - try "marker".write(to: marker, atomically: true, encoding: .utf8) - XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) - - service.closeNiriFocusedItem(in: session.id) - let layout = service.niriLayout(for: session.id) - let stillExists = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .contains(where: { $0.id == openCodeItemID }) - XCTAssertFalse(stillExists) - - let removedController: OpenCodeTileController? = service.niriAppController( - for: session.id, - itemID: openCodeItemID, - appID: NiriAppID.openCode, - as: OpenCodeTileController.self - ) - XCTAssertNil(removedController) - XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) - assertHasSingleTrailingEmptyWorkspace(layout) + service.closeNiriFocusedItem(in: session.id) + let layout = service.niriLayout(for: session.id) + let stillExists = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .contains(where: { $0.id == browserItemID }) + + XCTAssertFalse(stillExists) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testCloseNiriFocusedTerminalTileClosesItsTab() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Terminal")).session + service.ensureNiriLayoutState(for: session.id) + let initialTabCount = service.tabs(for: session.id).count + guard let addedItemID = service.niriAddTerminalRight(in: session.id) else { + XCTFail("Expected terminal tile") + return } - - func testCloseSessionWithOpenCodeCleansSessionArtifacts() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Session OpenCode")).session - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) - - let paths = OpenCodeRuntimePaths(sessionID: session.id) - try paths.ensureBaseDirectories() - let marker = paths.sessionDirectory.appendingPathComponent("marker.txt", isDirectory: false) - try "marker".write(to: marker, atomically: true, encoding: .utf8) - XCTAssertTrue(FileManager.default.fileExists(atPath: marker.path)) - - service.closeSession(session.id) - - XCTAssertFalse(FileManager.default.fileExists(atPath: paths.sessionDirectory.path)) + XCTAssertEqual(service.tabs(for: session.id).count, initialTabCount + 1) + + service.closeNiriFocusedItem(in: session.id) + let layout = service.niriLayout(for: session.id) + let stillExists = layout.workspaces + .flatMap(\.columns) + .flatMap(\.items) + .contains(where: { $0.id == addedItemID }) + + XCTAssertFalse(stillExists) + XCTAssertEqual(service.tabs(for: session.id).count, initialTabCount) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testNiriColumnResizePersistsPreferredWidths() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Column Resize")).session + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddTerminalRight(in: session.id) + + var layout = service.niriLayout(for: session.id) + guard let workspaceIndex = niriActiveWorkspaceIndex(layout), + workspaceIndex < layout.workspaces.count, + layout.workspaces[workspaceIndex].columns.count >= 2 + else { + XCTFail("Expected at least two columns") + return } - func testCloseNiriFocusedBrowserTileRemovesItem() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Browser")).session - service.ensureNiriLayoutState(for: session.id) - guard let browserItemID = service.niriAddBrowserRight(in: session.id) else { - XCTFail("Expected browser tile") - return - } - - service.closeNiriFocusedItem(in: session.id) - let layout = service.niriLayout(for: session.id) - let stillExists = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .contains(where: { $0.id == browserItemID }) - XCTAssertFalse(stillExists) - assertHasSingleTrailingEmptyWorkspace(layout) + let workspace = layout.workspaces[workspaceIndex] + let left = workspace.columns[0] + let right = workspace.columns[1] + + service.niriSetColumnWidths( + sessionID: session.id, + workspaceID: workspace.id, + leftColumnID: left.id, + leftWidth: 1.6, + rightColumnID: right.id, + rightWidth: 0.9 + ) + + layout = service.niriLayout(for: session.id) + let resizedWorkspace = layout.workspaces[workspaceIndex] + let leftWidth = try XCTUnwrap(resizedWorkspace.columns[0].preferredWidth) + let rightWidth = try XCTUnwrap(resizedWorkspace.columns[1].preferredWidth) + XCTAssertEqual(leftWidth, 1.6, accuracy: 0.001) + XCTAssertEqual(rightWidth, 0.9, accuracy: 0.001) + } + + func testNiriItemResizePersistsPreferredHeights() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Item Resize")).session + service.ensureNiriLayoutState(for: session.id) + guard let _ = service.niriAddTaskBelow(in: session.id) else { + XCTFail("Expected second task item") + return } - func testCloseNiriFocusedTerminalTileClosesItsTab() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Close Terminal")).session - service.ensureNiriLayoutState(for: session.id) - let initialTabCount = service.tabs(for: session.id).count - guard let addedItemID = service.niriAddTerminalRight(in: session.id) else { - XCTFail("Expected terminal tile") - return - } - XCTAssertEqual(service.tabs(for: session.id).count, initialTabCount + 1) - - service.closeNiriFocusedItem(in: session.id) - let layout = service.niriLayout(for: session.id) - let stillExists = layout.workspaces - .flatMap(\.columns) - .flatMap(\.items) - .contains(where: { $0.id == addedItemID }) - - XCTAssertFalse(stillExists) - XCTAssertEqual(service.tabs(for: session.id).count, initialTabCount) - assertHasSingleTrailingEmptyWorkspace(layout) + var layout = service.niriLayout(for: session.id) + guard let workspaceIndex = niriActiveWorkspaceIndex(layout), + let columnIndex = niriActiveColumnIndex(layout, workspaceIndex: workspaceIndex), + layout.workspaces[workspaceIndex].columns[columnIndex].items.count >= 2 + else { + XCTFail("Expected column with two items") + return } - func testNiriColumnResizePersistsPreferredWidths() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Column Resize")).session - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddTerminalRight(in: session.id) - - var layout = service.niriLayout(for: session.id) - guard let workspaceIndex = niriActiveWorkspaceIndex(layout), - workspaceIndex < layout.workspaces.count, - layout.workspaces[workspaceIndex].columns.count >= 2 - else { - XCTFail("Expected at least two columns") - return - } - - let workspace = layout.workspaces[workspaceIndex] - let left = workspace.columns[0] - let right = workspace.columns[1] - - service.niriSetColumnWidths( - sessionID: session.id, - workspaceID: workspace.id, - leftColumnID: left.id, - leftWidth: 1.6, - rightColumnID: right.id, - rightWidth: 0.9 - ) - - layout = service.niriLayout(for: session.id) - let resizedWorkspace = layout.workspaces[workspaceIndex] - let leftWidth = try XCTUnwrap(resizedWorkspace.columns[0].preferredWidth) - let rightWidth = try XCTUnwrap(resizedWorkspace.columns[1].preferredWidth) - XCTAssertEqual(leftWidth, 1.6, accuracy: 0.001) - XCTAssertEqual(rightWidth, 0.9, accuracy: 0.001) + let workspace = layout.workspaces[workspaceIndex] + let column = workspace.columns[columnIndex] + let upper = column.items[0] + let lower = column.items[1] + + service.niriSetItemHeights( + sessionID: session.id, + workspaceID: workspace.id, + columnID: column.id, + upperItemID: upper.id, + upperHeight: 1.2, + lowerItemID: lower.id, + lowerHeight: 0.7 + ) + + layout = service.niriLayout(for: session.id) + let resizedColumn = layout.workspaces[workspaceIndex].columns[columnIndex] + let upperHeight = try XCTUnwrap(resizedColumn.items[0].preferredHeight) + let lowerHeight = try XCTUnwrap(resizedColumn.items[1].preferredHeight) + XCTAssertEqual(upperHeight, 1.2, accuracy: 0.001) + XCTAssertEqual(lowerHeight, 0.7, accuracy: 0.001) + } + + func testNiriMoveItemAcrossWorkspacesUpdatesCameraAndFocus() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move")).session + service.ensureNiriLayoutState(for: session.id) + guard let sourceItemID = service.niriLayout(for: session.id).camera.focusedItemID else { + XCTFail("Expected focused source item") + return } - func testNiriItemResizePersistsPreferredHeights() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Item Resize")).session - service.ensureNiriLayoutState(for: session.id) - guard let _ = service.niriAddTaskBelow(in: session.id) else { - XCTFail("Expected second task item") - return - } - - var layout = service.niriLayout(for: session.id) - guard let workspaceIndex = niriActiveWorkspaceIndex(layout), - let columnIndex = niriActiveColumnIndex(layout, workspaceIndex: workspaceIndex), - layout.workspaces[workspaceIndex].columns[columnIndex].items.count >= 2 - else { - XCTFail("Expected column with two items") - return - } - - let workspace = layout.workspaces[workspaceIndex] - let column = workspace.columns[columnIndex] - let upper = column.items[0] - let lower = column.items[1] - - service.niriSetItemHeights( - sessionID: session.id, - workspaceID: workspace.id, - columnID: column.id, - upperItemID: upper.id, - upperHeight: 1.2, - lowerItemID: lower.id, - lowerHeight: 0.7 - ) - - layout = service.niriLayout(for: session.id) - let resizedColumn = layout.workspaces[workspaceIndex].columns[columnIndex] - let upperHeight = try XCTUnwrap(resizedColumn.items[0].preferredHeight) - let lowerHeight = try XCTUnwrap(resizedColumn.items[1].preferredHeight) - XCTAssertEqual(upperHeight, 1.2, accuracy: 0.001) - XCTAssertEqual(lowerHeight, 0.7, accuracy: 0.001) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + var layout = service.niriLayout(for: session.id) + guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { + XCTFail("Expected destination workspace") + return } - func testNiriMoveItemAcrossWorkspacesUpdatesCameraAndFocus() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move")).session - service.ensureNiriLayoutState(for: session.id) - guard let sourceItemID = service.niriLayout(for: session.id).camera.focusedItemID else { - XCTFail("Expected focused source item") - return - } - - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - var layout = service.niriLayout(for: session.id) - guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { - XCTFail("Expected destination workspace") - return - } - - service.moveNiriItemToWorkspace( - sessionID: session.id, - itemID: sourceItemID, - toWorkspaceID: destinationWorkspaceID - ) - layout = service.niriLayout(for: session.id) - - let destinationContainsItem = layout.workspaces - .first(where: { $0.id == destinationWorkspaceID })? - .columns - .flatMap(\.items) - .contains(where: { $0.id == sourceItemID }) ?? false - - XCTAssertTrue(destinationContainsItem) - XCTAssertEqual(layout.camera.focusedItemID, sourceItemID) - XCTAssertEqual(layout.camera.activeWorkspaceID, destinationWorkspaceID) - assertHasSingleTrailingEmptyWorkspace(layout) + service.moveNiriItemToWorkspace( + sessionID: session.id, + itemID: sourceItemID, + toWorkspaceID: destinationWorkspaceID + ) + layout = service.niriLayout(for: session.id) + + let destinationContainsItem = layout.workspaces + .first(where: { $0.id == destinationWorkspaceID })? + .columns + .flatMap(\.items) + .contains(where: { $0.id == sourceItemID }) ?? false + + XCTAssertTrue(destinationContainsItem) + XCTAssertEqual(layout.camera.focusedItemID, sourceItemID) + XCTAssertEqual(layout.camera.activeWorkspaceID, destinationWorkspaceID) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testNiriMoveT3TileAcrossWorkspacesPreservesInvariants() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move T3")).session + service.ensureNiriLayoutState(for: session.id) + guard let t3ItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) else { + XCTFail("Expected T3 tile") + return } - func testNiriMoveT3TileAcrossWorkspacesPreservesInvariants() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move T3")).session - service.ensureNiriLayoutState(for: session.id) - guard let t3ItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) else { - XCTFail("Expected T3 tile") - return - } - - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - let layout = service.niriLayout(for: session.id) - guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { - XCTFail("Expected destination workspace") - return - } - - service.moveNiriItemToWorkspace( - sessionID: session.id, - itemID: t3ItemID, - toWorkspaceID: destinationWorkspaceID - ) - let updated = service.niriLayout(for: session.id) - let destinationContainsT3 = updated.workspaces - .first(where: { $0.id == destinationWorkspaceID })? - .columns - .flatMap(\.items) - .contains(where: { $0.id == t3ItemID }) ?? false - - XCTAssertTrue(destinationContainsT3) - XCTAssertEqual(updated.camera.focusedItemID, t3ItemID) - assertHasSingleTrailingEmptyWorkspace(updated) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + let layout = service.niriLayout(for: session.id) + guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { + XCTFail("Expected destination workspace") + return } - func testNiriMoveVSCodeTileAcrossWorkspacesPreservesInvariants() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move VSCode")).session - service.ensureNiriLayoutState(for: session.id) - guard let vscodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) else { - XCTFail("Expected VS Code tile") - return - } - - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - let layout = service.niriLayout(for: session.id) - guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { - XCTFail("Expected destination workspace") - return - } - - service.moveNiriItemToWorkspace( - sessionID: session.id, - itemID: vscodeItemID, - toWorkspaceID: destinationWorkspaceID - ) - let updated = service.niriLayout(for: session.id) - let destinationContainsVSCode = updated.workspaces - .first(where: { $0.id == destinationWorkspaceID })? - .columns - .flatMap(\.items) - .contains(where: { $0.id == vscodeItemID }) ?? false - - XCTAssertTrue(destinationContainsVSCode) - XCTAssertEqual(updated.camera.focusedItemID, vscodeItemID) - assertHasSingleTrailingEmptyWorkspace(updated) + service.moveNiriItemToWorkspace( + sessionID: session.id, + itemID: t3ItemID, + toWorkspaceID: destinationWorkspaceID + ) + let updated = service.niriLayout(for: session.id) + let destinationContainsT3 = updated.workspaces + .first(where: { $0.id == destinationWorkspaceID })? + .columns + .flatMap(\.items) + .contains(where: { $0.id == t3ItemID }) ?? false + + XCTAssertTrue(destinationContainsT3) + XCTAssertEqual(updated.camera.focusedItemID, t3ItemID) + assertHasSingleTrailingEmptyWorkspace(updated) + } + + func testNiriMoveVSCodeTileAcrossWorkspacesPreservesInvariants() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move VSCode")).session + service.ensureNiriLayoutState(for: session.id) + guard let vscodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) else { + XCTFail("Expected VS Code tile") + return } - func testNiriMoveExcalidrawTileAcrossWorkspacesPreservesInvariants() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move Excalidraw")).session - service.ensureNiriLayoutState(for: session.id) - guard let itemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) else { - XCTFail("Expected Excalidraw tile") - return - } - - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - let layout = service.niriLayout(for: session.id) - guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { - XCTFail("Expected destination workspace") - return - } - - service.moveNiriItemToWorkspace( - sessionID: session.id, - itemID: itemID, - toWorkspaceID: destinationWorkspaceID - ) - let updated = service.niriLayout(for: session.id) - let destinationContainsItem = updated.workspaces - .first(where: { $0.id == destinationWorkspaceID })? - .columns - .flatMap(\.items) - .contains(where: { $0.id == itemID }) ?? false - - XCTAssertTrue(destinationContainsItem) - XCTAssertEqual(updated.camera.focusedItemID, itemID) - assertHasSingleTrailingEmptyWorkspace(updated) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + let layout = service.niriLayout(for: session.id) + guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { + XCTFail("Expected destination workspace") + return } - func testNiriMoveOpenCodeTileAcrossWorkspacesPreservesInvariants() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move OpenCode")).session - service.ensureNiriLayoutState(for: session.id) - guard let openCodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) else { - XCTFail("Expected OpenCode tile") - return - } - - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - let layout = service.niriLayout(for: session.id) - guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { - XCTFail("Expected destination workspace") - return - } - - service.moveNiriItemToWorkspace( - sessionID: session.id, - itemID: openCodeItemID, - toWorkspaceID: destinationWorkspaceID - ) - let updated = service.niriLayout(for: session.id) - let destinationContainsOpenCode = updated.workspaces - .first(where: { $0.id == destinationWorkspaceID })? - .columns - .flatMap(\.items) - .contains(where: { $0.id == openCodeItemID }) ?? false - - XCTAssertTrue(destinationContainsOpenCode) - XCTAssertEqual(updated.camera.focusedItemID, openCodeItemID) - assertHasSingleTrailingEmptyWorkspace(updated) + service.moveNiriItemToWorkspace( + sessionID: session.id, + itemID: vscodeItemID, + toWorkspaceID: destinationWorkspaceID + ) + let updated = service.niriLayout(for: session.id) + let destinationContainsVSCode = updated.workspaces + .first(where: { $0.id == destinationWorkspaceID })? + .columns + .flatMap(\.items) + .contains(where: { $0.id == vscodeItemID }) ?? false + + XCTAssertTrue(destinationContainsVSCode) + XCTAssertEqual(updated.camera.focusedItemID, vscodeItemID) + assertHasSingleTrailingEmptyWorkspace(updated) + } + + func testNiriMoveExcalidrawTileAcrossWorkspacesPreservesInvariants() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move Excalidraw")).session + service.ensureNiriLayoutState(for: session.id) + guard let itemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) else { + XCTFail("Expected Excalidraw tile") + return } - func testNiriTrailingEmptyWorkspaceInvariantWithMixedTileTypesIncludingVSCode() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Mixed Invariant")).session - service.ensureNiriLayoutState(for: session.id) - - _ = service.niriAddBrowserRight(in: session.id) - _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) - _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) - _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) - _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) - _ = service.niriAddTerminalRight(in: session.id) - service.focusNiriWorkspaceDown(sessionID: session.id) - _ = service.niriAddTerminalRight(in: session.id) - service.moveNiriColumnToWorkspaceUp(sessionID: session.id) - - let layout = service.niriLayout(for: session.id) - assertHasSingleTrailingEmptyWorkspace(layout) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + let layout = service.niriLayout(for: session.id) + guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { + XCTFail("Expected destination workspace") + return } - func testNiriFocusedVSCodeTileRespondsToZoomAdjustments() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri VSCode Zoom")).session - service.ensureNiriLayoutState(for: session.id) - guard let vscodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode), - let controller: VSCodeTileController = service.niriAppController( - for: session.id, - itemID: vscodeItemID, - appID: NiriAppID.vscode, - as: VSCodeTileController.self - ) - else { - XCTFail("Expected VS Code tile and controller") - return - } - - controller.webView.pageZoom = 1.0 - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) - XCTAssertEqual(controller.webView.pageZoom, 1.1, accuracy: 0.0001) - - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: -0.2)) - XCTAssertEqual(controller.webView.pageZoom, 0.9, accuracy: 0.0001) + service.moveNiriItemToWorkspace( + sessionID: session.id, + itemID: itemID, + toWorkspaceID: destinationWorkspaceID + ) + let updated = service.niriLayout(for: session.id) + let destinationContainsItem = updated.workspaces + .first(where: { $0.id == destinationWorkspaceID })? + .columns + .flatMap(\.items) + .contains(where: { $0.id == itemID }) ?? false + + XCTAssertTrue(destinationContainsItem) + XCTAssertEqual(updated.camera.focusedItemID, itemID) + assertHasSingleTrailingEmptyWorkspace(updated) + } + + func testNiriMoveOpenCodeTileAcrossWorkspacesPreservesInvariants() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Move OpenCode")).session + service.ensureNiriLayoutState(for: session.id) + guard let openCodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) else { + XCTFail("Expected OpenCode tile") + return } - func testNiriFocusedExcalidrawTileRespondsToZoomAdjustments() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Excalidraw Zoom")).session - service.ensureNiriLayoutState(for: session.id) - guard let itemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw), - let controller: ExcalidrawTileController = service.niriAppController( - for: session.id, - itemID: itemID, - appID: NiriAppID.excalidraw, - as: ExcalidrawTileController.self - ) - else { - XCTFail("Expected Excalidraw tile and controller") - return - } - - controller.webView.pageZoom = 1.0 - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) - XCTAssertEqual(controller.webView.pageZoom, 1.1, accuracy: 0.0001) - - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: -0.2)) - XCTAssertEqual(controller.webView.pageZoom, 0.9, accuracy: 0.0001) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + let layout = service.niriLayout(for: session.id) + guard let destinationWorkspaceID = layout.camera.activeWorkspaceID else { + XCTFail("Expected destination workspace") + return } - func testNiriFocusedOpenCodeTileRespondsToZoomAdjustments() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri OpenCode Zoom")).session - service.ensureNiriLayoutState(for: session.id) - guard let openCodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode), - let controller: OpenCodeTileController = service.niriAppController( - for: session.id, - itemID: openCodeItemID, - appID: NiriAppID.openCode, - as: OpenCodeTileController.self - ) - else { - XCTFail("Expected OpenCode tile and controller") - return - } - - controller.webView.pageZoom = 1.0 - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) - XCTAssertEqual(controller.webView.pageZoom, 1.1, accuracy: 0.0001) - - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: -0.2)) - XCTAssertEqual(controller.webView.pageZoom, 0.9, accuracy: 0.0001) + service.moveNiriItemToWorkspace( + sessionID: session.id, + itemID: openCodeItemID, + toWorkspaceID: destinationWorkspaceID + ) + let updated = service.niriLayout(for: session.id) + let destinationContainsOpenCode = updated.workspaces + .first(where: { $0.id == destinationWorkspaceID })? + .columns + .flatMap(\.items) + .contains(where: { $0.id == openCodeItemID }) ?? false + + XCTAssertTrue(destinationContainsOpenCode) + XCTAssertEqual(updated.camera.focusedItemID, openCodeItemID) + assertHasSingleTrailingEmptyWorkspace(updated) + } + + func testNiriTrailingEmptyWorkspaceInvariantWithMixedTileTypesIncludingVSCode() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Mixed Invariant")).session + service.ensureNiriLayoutState(for: session.id) + + _ = service.niriAddBrowserRight(in: session.id) + _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.t3Code) + _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode) + _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw) + _ = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode) + _ = service.niriAddTerminalRight(in: session.id) + service.focusNiriWorkspaceDown(sessionID: session.id) + _ = service.niriAddTerminalRight(in: session.id) + service.moveNiriColumnToWorkspaceUp(sessionID: session.id) + + let layout = service.niriLayout(for: session.id) + assertHasSingleTrailingEmptyWorkspace(layout) + } + + func testNiriFocusedVSCodeTileRespondsToZoomAdjustments() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri VSCode Zoom")).session + service.ensureNiriLayoutState(for: session.id) + guard let vscodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.vscode), + let controller: VSCodeTileController = service.niriAppController( + for: session.id, + itemID: vscodeItemID, + appID: NiriAppID.vscode, + as: VSCodeTileController.self + ) + else { + XCTFail("Expected VS Code tile and controller") + return } - func testNiriZoomAdjustmentIgnoresFocusedTerminalTile() async throws { - let fixture = try Fixture() - let service = fixture.service + controller.webView.pageZoom = 1.0 + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) + XCTAssertEqual(controller.webView.pageZoom, 1.1, accuracy: 0.0001) - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Terminal Zoom Ignore")).session - service.ensureNiriLayoutState(for: session.id) + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: -0.2)) + XCTAssertEqual(controller.webView.pageZoom, 0.9, accuracy: 0.0001) + } - XCTAssertFalse(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) - } + func testNiriFocusedExcalidrawTileRespondsToZoomAdjustments() async throws { + let fixture = try Fixture() + let service = fixture.service - func testNiriFocusedTileZoomToggleTracksFocusedItem() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Focused Zoom Toggle")).session - service.ensureNiriLayoutState(for: session.id) - guard let focusedItemID = service.niriLayout(for: session.id).camera.focusedItemID else { - XCTFail("Expected focused tile") - return - } - - XCTAssertNil(service.niriFocusedTileZoomItemID(for: session.id)) - XCTAssertTrue(service.toggleNiriFocusedTileZoom(sessionID: session.id)) - XCTAssertEqual(service.niriFocusedTileZoomItemID(for: session.id), focusedItemID) - XCTAssertTrue(service.toggleNiriFocusedTileZoom(sessionID: session.id)) - XCTAssertNil(service.niriFocusedTileZoomItemID(for: session.id)) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Excalidraw Zoom")).session + service.ensureNiriLayoutState(for: session.id) + guard let itemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.excalidraw), + let controller: ExcalidrawTileController = service.niriAppController( + for: session.id, + itemID: itemID, + appID: NiriAppID.excalidraw, + as: ExcalidrawTileController.self + ) + else { + XCTFail("Expected Excalidraw tile and controller") + return } - func testNiriFocusedTileZoomClearsWhenZoomedItemIsRemoved() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Focused Zoom Clear")).session - service.ensureNiriLayoutState(for: session.id) - guard let browserItemID = service.niriAddBrowserRight(in: session.id) else { - XCTFail("Expected browser tile") - return - } + controller.webView.pageZoom = 1.0 + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) + XCTAssertEqual(controller.webView.pageZoom, 1.1, accuracy: 0.0001) - XCTAssertTrue(service.toggleNiriFocusedTileZoom(sessionID: session.id)) - XCTAssertEqual(service.niriFocusedTileZoomItemID(for: session.id), browserItemID) + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: -0.2)) + XCTAssertEqual(controller.webView.pageZoom, 0.9, accuracy: 0.0001) + } - service.closeNiriFocusedItem(in: session.id) + func testNiriFocusedOpenCodeTileRespondsToZoomAdjustments() async throws { + let fixture = try Fixture() + let service = fixture.service - XCTAssertNil(service.niriFocusedTileZoomItemID(for: session.id)) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri OpenCode Zoom")).session + service.ensureNiriLayoutState(for: session.id) + guard let openCodeItemID = service.niriAddSingletonAppRight(in: session.id, appID: NiriAppID.openCode), + let controller: OpenCodeTileController = service.niriAppController( + for: session.id, + itemID: openCodeItemID, + appID: NiriAppID.openCode, + as: OpenCodeTileController.self + ) + else { + XCTFail("Expected OpenCode tile and controller") + return } - func testNiriDefaultTileSizesApplyOnlyToNewTiles() async throws { - let fixture = try Fixture() - let service = fixture.service + controller.webView.pageZoom = 1.0 + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) + XCTAssertEqual(controller.webView.pageZoom, 1.1, accuracy: 0.0001) - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Default Tile Sizes")).session - service.ensureNiriLayoutState(for: session.id) + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: -0.2)) + XCTAssertEqual(controller.webView.pageZoom, 0.9, accuracy: 0.0001) + } - service.saveSettings { settings in - settings.niri.defaultNewColumnWidth = 0.9 - settings.niri.defaultNewTileHeight = 0.52 - } + func testNiriZoomAdjustmentIgnoresFocusedTerminalTile() async throws { + let fixture = try Fixture() + let service = fixture.service - guard let firstItemID = service.niriAddTerminalRight(in: session.id) else { - XCTFail("Expected first terminal tile") - return - } - - var layout = service.niriLayout(for: session.id) - guard let firstPath = service.findNiriItemPath(layout: layout, itemID: firstItemID) else { - XCTFail("Expected path for first tile") - return - } - let firstWidth = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].preferredWidth) - let firstHeight = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].items[firstPath.itemIndex].preferredHeight) - XCTAssertEqual(firstWidth, 0.9, accuracy: 0.001) - XCTAssertEqual(firstHeight, 0.52, accuracy: 0.001) - - service.saveSettings { settings in - settings.niri.defaultNewColumnWidth = 1.1 - settings.niri.defaultNewTileHeight = 0.64 - } + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Terminal Zoom Ignore")).session + service.ensureNiriLayoutState(for: session.id) - guard let secondItemID = service.niriAddTaskBelow(in: session.id), - let thirdItemID = service.niriAddTerminalRight(in: session.id) else { - XCTFail("Expected additional terminal tiles") - return - } + XCTAssertFalse(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.1)) + } - layout = service.niriLayout(for: session.id) - guard let secondPath = service.findNiriItemPath(layout: layout, itemID: secondItemID), - let thirdPath = service.findNiriItemPath(layout: layout, itemID: thirdItemID) - else { - XCTFail("Expected paths for new tiles") - return - } + func testNiriFocusedTileZoomToggleTracksFocusedItem() async throws { + let fixture = try Fixture() + let service = fixture.service - let unchangedFirstWidth = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].preferredWidth) - let unchangedFirstHeight = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].items[firstPath.itemIndex].preferredHeight) - let secondHeight = try XCTUnwrap(layout.workspaces[secondPath.workspaceIndex].columns[secondPath.columnIndex].items[secondPath.itemIndex].preferredHeight) - let thirdWidth = try XCTUnwrap(layout.workspaces[thirdPath.workspaceIndex].columns[thirdPath.columnIndex].preferredWidth) - let thirdHeight = try XCTUnwrap(layout.workspaces[thirdPath.workspaceIndex].columns[thirdPath.columnIndex].items[thirdPath.itemIndex].preferredHeight) - - XCTAssertEqual(unchangedFirstWidth, 0.9, accuracy: 0.001) - XCTAssertEqual(unchangedFirstHeight, 0.52, accuracy: 0.001) - XCTAssertEqual(secondHeight, 0.64, accuracy: 0.001) - XCTAssertEqual(thirdWidth, 1.1, accuracy: 0.001) - XCTAssertEqual(thirdHeight, 0.64, accuracy: 0.001) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Focused Zoom Toggle")).session + service.ensureNiriLayoutState(for: session.id) + guard let focusedItemID = service.niriLayout(for: session.id).camera.focusedItemID else { + XCTFail("Expected focused tile") + return } - func testNiriGenericAppSelectionEnsuresControllerViaDescriptorFactory() async throws { - let registry = NiriAppRegistry() - let fixture = try Fixture(niriAppRegistry: registry) - let service = fixture.service - let tracker = StubNiriAppTracker() - let appID = "stub-app" - - registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Select")).session - let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) - - service.niriSelectItem(sessionID: session.id, itemID: itemID) - - XCTAssertEqual(tracker.ensureCalls.count, 1) - XCTAssertEqual(tracker.ensureCalls.first?.sessionID, session.id) - XCTAssertEqual(tracker.ensureCalls.first?.itemID, itemID) - XCTAssertNotNil(tracker.controller(for: itemID)) + XCTAssertNil(service.niriFocusedTileZoomItemID(for: session.id)) + XCTAssertTrue(service.toggleNiriFocusedTileZoom(sessionID: session.id)) + XCTAssertEqual(service.niriFocusedTileZoomItemID(for: session.id), focusedItemID) + XCTAssertTrue(service.toggleNiriFocusedTileZoom(sessionID: session.id)) + XCTAssertNil(service.niriFocusedTileZoomItemID(for: session.id)) + } + + func testNiriFocusedTileZoomClearsWhenZoomedItemIsRemoved() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Focused Zoom Clear")).session + service.ensureNiriLayoutState(for: session.id) + guard let browserItemID = service.niriAddBrowserRight(in: session.id) else { + XCTFail("Expected browser tile") + return } - func testNiriGenericAppRetryDispatchesThroughGenericPath() async throws { - let registry = NiriAppRegistry() - let fixture = try Fixture(niriAppRegistry: registry) - let service = fixture.service - let tracker = StubNiriAppTracker() - let appID = "stub-app" - - registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + XCTAssertTrue(service.toggleNiriFocusedTileZoom(sessionID: session.id)) + XCTAssertEqual(service.niriFocusedTileZoomItemID(for: session.id), browserItemID) - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Retry")).session - let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) - service.niriSelectItem(sessionID: session.id, itemID: itemID) + service.closeNiriFocusedItem(in: session.id) - service.retryNiriAppTile(sessionID: session.id, itemID: itemID, appID: appID) + XCTAssertNil(service.niriFocusedTileZoomItemID(for: session.id)) + } - XCTAssertEqual(tracker.retryCalls.count, 1) - XCTAssertEqual(tracker.retryCalls.first?.sessionID, session.id) - XCTAssertEqual(tracker.retryCalls.first?.itemID, itemID) - XCTAssertEqual(tracker.controller(for: itemID)?.retryCount, 1) - } - - func testNiriGenericAppFocusedZoomDispatchesThroughController() async throws { - let registry = NiriAppRegistry() - let fixture = try Fixture(niriAppRegistry: registry) - let service = fixture.service - let tracker = StubNiriAppTracker() - let appID = "stub-app" - - registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + func testNiriDefaultTileSizesApplyOnlyToNewTiles() async throws { + let fixture = try Fixture() + let service = fixture.service - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Zoom")).session - let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) - service.niriSelectItem(sessionID: session.id, itemID: itemID) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Default Tile Sizes")).session + service.ensureNiriLayoutState(for: session.id) - XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.25)) - XCTAssertEqual(tracker.controller(for: itemID)?.zoomAdjustments, [0.25]) + service.saveSettings { settings in + settings.niri.defaultNewColumnWidth = 0.9 + settings.niri.defaultNewTileHeight = 0.52 } - func testNiriGenericAppCleanupRunsOnceWhenLastTileRemoved() async throws { - let registry = NiriAppRegistry() - let fixture = try Fixture(niriAppRegistry: registry) - let service = fixture.service - let tracker = StubNiriAppTracker() - let appID = "stub-app" - - registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Remove Cleanup")).session - let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) - service.niriSelectItem(sessionID: session.id, itemID: itemID) - - service.closeNiriFocusedItem(in: session.id) - - XCTAssertEqual(tracker.cleanupCallCount(for: session.id), 1) + guard let firstItemID = service.niriAddTerminalRight(in: session.id) else { + XCTFail("Expected first terminal tile") + return } - func testNiriGenericAppCleanupRunsOnceWhenSessionCloses() async throws { - let registry = NiriAppRegistry() - let fixture = try Fixture(niriAppRegistry: registry) - let service = fixture.service - let tracker = StubNiriAppTracker() - let appID = "stub-app" - - registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Session Cleanup")).session - _ = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) - - service.closeSession(session.id) - - XCTAssertEqual(tracker.cleanupCallCount(for: session.id), 1) + var layout = service.niriLayout(for: session.id) + guard let firstPath = service.findNiriItemPath(layout: layout, itemID: firstItemID) else { + XCTFail("Expected path for first tile") + return } - - func testVSCodeBrowserDebugLaunchConfigUpsertCreatesAttachConfig() throws { - let data = try SessionService.upsertVSCodeBrowserAttachConfigurationData( - existingData: nil, - configurationName: "Attach Chrome (idx-web)", - port: 9222, - urlFilter: "http://localhost:3000/*", - webRoot: "${workspaceFolder}/idx-web" - ) - let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) - XCTAssertEqual(root["version"] as? String, "0.2.0") - let configurations = try XCTUnwrap(root["configurations"] as? [[String: Any]]) - XCTAssertEqual(configurations.count, 1) - XCTAssertEqual(configurations[0]["type"] as? String, "pwa-chrome") - XCTAssertEqual(configurations[0]["request"] as? String, "attach") - XCTAssertEqual(configurations[0]["port"] as? Int, 9222) + let firstWidth = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].preferredWidth) + let firstHeight = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].items[firstPath.itemIndex].preferredHeight) + XCTAssertEqual(firstWidth, 0.9, accuracy: 0.001) + XCTAssertEqual(firstHeight, 0.52, accuracy: 0.001) + + service.saveSettings { settings in + settings.niri.defaultNewColumnWidth = 1.1 + settings.niri.defaultNewTileHeight = 0.64 } - func testVSCodeBrowserDebugLaunchConfigUpsertUpdatesExistingByName() throws { - let existing = """ - { - "version": "0.2.0", - "configurations": [ - { - "name": "Attach Chrome (idx-web)", - "type": "pwa-chrome", - "request": "attach", - "port": 9001, - "presentation": { "group": "idx0" } - } - ] - } - """.data(using: .utf8) - - let updatedData = try SessionService.upsertVSCodeBrowserAttachConfigurationData( - existingData: existing, - configurationName: "Attach Chrome (idx-web)", - port: 9222, - urlFilter: "http://localhost:3000/*", - webRoot: "${workspaceFolder}" - ) - let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any]) - let configurations = try XCTUnwrap(root["configurations"] as? [[String: Any]]) - XCTAssertEqual(configurations.count, 1) - XCTAssertEqual(configurations[0]["port"] as? Int, 9222) - XCTAssertEqual(configurations[0]["webRoot"] as? String, "${workspaceFolder}") - XCTAssertNotNil(configurations[0]["presentation"]) + guard let secondItemID = service.niriAddTaskBelow(in: session.id), + let thirdItemID = service.niriAddTerminalRight(in: session.id) + else { + XCTFail("Expected additional terminal tiles") + return } - func testVSCodeBrowserDebugLaunchConfigUpsertParsesJSONC() throws { - let existingJSONC = """ - // VS Code launch configs - { - "version": "0.2.0", - /* keep user configs */ - "configurations": [] - } - """.data(using: .utf8) - - let updatedData = try SessionService.upsertVSCodeBrowserAttachConfigurationData( - existingData: existingJSONC, - configurationName: "Attach Chrome (idx-web)", - port: 9222, - urlFilter: "http://localhost:3000/*", - webRoot: "${workspaceFolder}/idx-web" - ) - let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any]) - let configurations = try XCTUnwrap(root["configurations"] as? [[String: Any]]) - XCTAssertEqual(configurations.count, 1) + layout = service.niriLayout(for: session.id) + guard let secondPath = service.findNiriItemPath(layout: layout, itemID: secondItemID), + let thirdPath = service.findNiriItemPath(layout: layout, itemID: thirdItemID) + else { + XCTFail("Expected paths for new tiles") + return } - func testVSCodeWorkspaceDebugSettingsUpsertTurnsOffDebugByLink() throws { - let existing = """ + let unchangedFirstWidth = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].preferredWidth) + let unchangedFirstHeight = try XCTUnwrap(layout.workspaces[firstPath.workspaceIndex].columns[firstPath.columnIndex].items[firstPath.itemIndex].preferredHeight) + let secondHeight = try XCTUnwrap(layout.workspaces[secondPath.workspaceIndex].columns[secondPath.columnIndex].items[secondPath.itemIndex].preferredHeight) + let thirdWidth = try XCTUnwrap(layout.workspaces[thirdPath.workspaceIndex].columns[thirdPath.columnIndex].preferredWidth) + let thirdHeight = try XCTUnwrap(layout.workspaces[thirdPath.workspaceIndex].columns[thirdPath.columnIndex].items[thirdPath.itemIndex].preferredHeight) + + XCTAssertEqual(unchangedFirstWidth, 0.9, accuracy: 0.001) + XCTAssertEqual(unchangedFirstHeight, 0.52, accuracy: 0.001) + XCTAssertEqual(secondHeight, 0.64, accuracy: 0.001) + XCTAssertEqual(thirdWidth, 1.1, accuracy: 0.001) + XCTAssertEqual(thirdHeight, 0.64, accuracy: 0.001) + } + + func testNiriGenericAppSelectionEnsuresControllerViaDescriptorFactory() async throws { + let registry = NiriAppRegistry() + let fixture = try Fixture(niriAppRegistry: registry) + let service = fixture.service + let tracker = StubNiriAppTracker() + let appID = "stub-app" + + registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Select")).session + let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) + + service.niriSelectItem(sessionID: session.id, itemID: itemID) + + XCTAssertEqual(tracker.ensureCalls.count, 1) + XCTAssertEqual(tracker.ensureCalls.first?.sessionID, session.id) + XCTAssertEqual(tracker.ensureCalls.first?.itemID, itemID) + XCTAssertNotNil(tracker.controller(for: itemID)) + } + + func testNiriGenericAppRetryDispatchesThroughGenericPath() async throws { + let registry = NiriAppRegistry() + let fixture = try Fixture(niriAppRegistry: registry) + let service = fixture.service + let tracker = StubNiriAppTracker() + let appID = "stub-app" + + registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Retry")).session + let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) + service.niriSelectItem(sessionID: session.id, itemID: itemID) + + service.retryNiriAppTile(sessionID: session.id, itemID: itemID, appID: appID) + + XCTAssertEqual(tracker.retryCalls.count, 1) + XCTAssertEqual(tracker.retryCalls.first?.sessionID, session.id) + XCTAssertEqual(tracker.retryCalls.first?.itemID, itemID) + XCTAssertEqual(tracker.controller(for: itemID)?.retryCount, 1) + } + + func testNiriGenericAppFocusedZoomDispatchesThroughController() async throws { + let registry = NiriAppRegistry() + let fixture = try Fixture(niriAppRegistry: registry) + let service = fixture.service + let tracker = StubNiriAppTracker() + let appID = "stub-app" + + registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Zoom")).session + let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) + service.niriSelectItem(sessionID: session.id, itemID: itemID) + + XCTAssertTrue(service.adjustNiriFocusedWebTileZoom(for: session.id, delta: 0.25)) + XCTAssertEqual(tracker.controller(for: itemID)?.zoomAdjustments, [0.25]) + } + + func testNiriGenericAppCleanupRunsOnceWhenLastTileRemoved() async throws { + let registry = NiriAppRegistry() + let fixture = try Fixture(niriAppRegistry: registry) + let service = fixture.service + let tracker = StubNiriAppTracker() + let appID = "stub-app" + + registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Remove Cleanup")).session + let itemID = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) + service.niriSelectItem(sessionID: session.id, itemID: itemID) + + service.closeNiriFocusedItem(in: session.id) + + XCTAssertEqual(tracker.cleanupCallCount(for: session.id), 1) + } + + func testNiriGenericAppCleanupRunsOnceWhenSessionCloses() async throws { + let registry = NiriAppRegistry() + let fixture = try Fixture(niriAppRegistry: registry) + let service = fixture.service + let tracker = StubNiriAppTracker() + let appID = "stub-app" + + registry.register(makeStubNiriAppDescriptor(appID: appID, tracker: tracker)) + + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Generic Session Cleanup")).session + _ = addStubNiriAppTile(appID: appID, sessionID: session.id, service: service) + + service.closeSession(session.id) + + XCTAssertEqual(tracker.cleanupCallCount(for: session.id), 1) + } + + func testVSCodeBrowserDebugLaunchConfigUpsertCreatesAttachConfig() throws { + let data = try SessionService.upsertVSCodeBrowserAttachConfigurationData( + existingData: nil, + configurationName: "Attach Chrome (idx-web)", + port: 9222, + urlFilter: "http://localhost:3000/*", + webRoot: "${workspaceFolder}/idx-web" + ) + let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(root["version"] as? String, "0.2.0") + let configurations = try XCTUnwrap(root["configurations"] as? [[String: Any]]) + XCTAssertEqual(configurations.count, 1) + XCTAssertEqual(configurations[0]["type"] as? String, "pwa-chrome") + XCTAssertEqual(configurations[0]["request"] as? String, "attach") + XCTAssertEqual(configurations[0]["port"] as? Int, 9222) + } + + func testVSCodeBrowserDebugLaunchConfigUpsertUpdatesExistingByName() throws { + let existing = """ + { + "version": "0.2.0", + "configurations": [ { - "editor.tabSize": 2 + "name": "Attach Chrome (idx-web)", + "type": "pwa-chrome", + "request": "attach", + "port": 9001, + "presentation": { "group": "idx0" } } - """.data(using: .utf8) - - let updatedData = try SessionService.upsertVSCodeWorkspaceDebugSettingsData(existingData: existing) - let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any]) - XCTAssertEqual(root["debug.javascript.debugByLinkOptions"] as? String, "off") - XCTAssertEqual(root["editor.tabSize"] as? Int, 2) + ] } - - func testNiriLegacyCellsMigrateToWorkspaceModel() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Legacy")).session - service.ensureNiriLayoutState(for: session.id) - guard let selectedTabID = service.selectedTabID(for: session.id), - let secondTabID = service.createTab(in: session.id, activate: false) - else { - XCTFail("Expected existing and second tab IDs") - return - } - - let legacyLayout = NiriCanvasLayout( - workspaces: [], - camera: NiriCameraState(), - isOverviewOpen: false, - legacyCells: [ - NiriCanvasCell( - id: UUID(), - column: 0, - row: 0, - item: .terminal(tabID: selectedTabID) - ), - NiriCanvasCell( - id: UUID(), - column: 0, - row: 1, - item: .terminal(tabID: secondTabID) - ) - ] - ) - service.setNiriLayoutForTesting(sessionID: session.id, layout: legacyLayout) - - service.ensureNiriLayoutState(for: session.id) - let migrated = service.niriLayout(for: session.id) - - XCTAssertGreaterThanOrEqual(migrated.workspaces.count, 3) - XCTAssertEqual(migrated.workspaces[0].columns.count, 1) - XCTAssertEqual(migrated.workspaces[1].columns.count, 1) - XCTAssertTrue(migrated.legacyCells.isEmpty) - assertHasSingleTrailingEmptyWorkspace(migrated) + """.data(using: .utf8) + + let updatedData = try SessionService.upsertVSCodeBrowserAttachConfigurationData( + existingData: existing, + configurationName: "Attach Chrome (idx-web)", + port: 9222, + urlFilter: "http://localhost:3000/*", + webRoot: "${workspaceFolder}" + ) + let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any]) + let configurations = try XCTUnwrap(root["configurations"] as? [[String: Any]]) + XCTAssertEqual(configurations.count, 1) + XCTAssertEqual(configurations[0]["port"] as? Int, 9222) + XCTAssertEqual(configurations[0]["webRoot"] as? String, "${workspaceFolder}") + XCTAssertNotNil(configurations[0]["presentation"]) + } + + func testVSCodeBrowserDebugLaunchConfigUpsertParsesJSONC() throws { + let existingJSONC = """ + // VS Code launch configs + { + "version": "0.2.0", + /* keep user configs */ + "configurations": [] + } + """.data(using: .utf8) + + let updatedData = try SessionService.upsertVSCodeBrowserAttachConfigurationData( + existingData: existingJSONC, + configurationName: "Attach Chrome (idx-web)", + port: 9222, + urlFilter: "http://localhost:3000/*", + webRoot: "${workspaceFolder}/idx-web" + ) + let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any]) + let configurations = try XCTUnwrap(root["configurations"] as? [[String: Any]]) + XCTAssertEqual(configurations.count, 1) + } + + func testVSCodeWorkspaceDebugSettingsUpsertTurnsOffDebugByLink() throws { + let existing = """ + { + "editor.tabSize": 2 + } + """.data(using: .utf8) + + let updatedData = try SessionService.upsertVSCodeWorkspaceDebugSettingsData(existingData: existing) + let root = try XCTUnwrap(try JSONSerialization.jsonObject(with: updatedData) as? [String: Any]) + XCTAssertEqual(root["debug.javascript.debugByLinkOptions"] as? String, "off") + XCTAssertEqual(root["editor.tabSize"] as? Int, 2) + } + + func testNiriLegacyCellsMigrateToWorkspaceModel() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Legacy")).session + service.ensureNiriLayoutState(for: session.id) + guard let selectedTabID = service.selectedTabID(for: session.id), + let secondTabID = service.createTab(in: session.id, activate: false) + else { + XCTFail("Expected existing and second tab IDs") + return } + let legacyLayout = NiriCanvasLayout( + workspaces: [], + camera: NiriCameraState(), + isOverviewOpen: false, + legacyCells: [ + NiriCanvasCell( + id: UUID(), + column: 0, + row: 0, + item: .terminal(tabID: selectedTabID) + ), + NiriCanvasCell( + id: UUID(), + column: 0, + row: 1, + item: .terminal(tabID: secondTabID) + ), + ] + ) + service.setNiriLayoutForTesting(sessionID: session.id, layout: legacyLayout) + + service.ensureNiriLayoutState(for: session.id) + let migrated = service.niriLayout(for: session.id) + + XCTAssertGreaterThanOrEqual(migrated.workspaces.count, 3) + XCTAssertEqual(migrated.workspaces[0].columns.count, 1) + XCTAssertEqual(migrated.workspaces[1].columns.count, 1) + XCTAssertTrue(migrated.legacyCells.isEmpty) + assertHasSingleTrailingEmptyWorkspace(migrated) + } } From 325d500da93b97611ddb13f0bf6c26a8d7ebebb7 Mon Sep 17 00:00:00 2001 From: galz10 Date: Wed, 25 Mar 2026 21:48:09 -0700 Subject: [PATCH 12/13] Harden restricted launch test for CI sandbox variability --- idx0Tests/SessionServiceTests+Launch.swift | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/idx0Tests/SessionServiceTests+Launch.swift b/idx0Tests/SessionServiceTests+Launch.swift index 771b5ba..7e86510 100644 --- a/idx0Tests/SessionServiceTests+Launch.swift +++ b/idx0Tests/SessionServiceTests+Launch.swift @@ -178,12 +178,28 @@ extension SessionServiceTests { .path XCTAssertEqual(process.terminationStatus, 0) - XCTAssertTrue(FileManager.default.fileExists(atPath: sandboxProfilePath)) XCTAssertTrue(FileManager.default.fileExists(atPath: launchResultPath)) let launchResultData = try Data(contentsOf: URL(fileURLWithPath: launchResultPath)) let launchResult = try JSONDecoder().decode(LaunchHelperResult.self, from: launchResultData) - XCTAssertEqual(launchResult.enforcementState, .enforced) + switch launchResult.enforcementState { + case .enforced: + XCTAssertTrue(FileManager.default.fileExists(atPath: sandboxProfilePath)) + case .degraded: + let reason = launchResult.message ?? "" + XCTAssertFalse( + reason.contains("missing repo/worktree root"), + "Restricted launch unexpectedly lost write root: \(reason)" + ) + XCTAssertTrue( + reason.contains("sandbox-exec") + || reason.contains("Sandbox launch failed") + || reason.contains("Sandbox profile generation failed"), + "Unexpected degraded reason: \(reason)" + ) + case .unenforced: + XCTFail("Restricted profile launch was bypassed without degradation.") + } } func testGhosttyCommandEscapingPreservesPathsWithSpaces() throws { From 0baacadc7c30823ed2f400b6960f84ea2c3f8cfe Mon Sep 17 00:00:00 2001 From: galz10 Date: Thu, 26 Mar 2026 07:52:02 -0700 Subject: [PATCH 13/13] Improve test failure summaries and stabilize restricted launch test --- idx0Tests/SessionServiceTests+Launch.swift | 925 ++++++++++----------- scripts/presubmit.sh | 187 ++++- 2 files changed, 645 insertions(+), 467 deletions(-) diff --git a/idx0Tests/SessionServiceTests+Launch.swift b/idx0Tests/SessionServiceTests+Launch.swift index 7e86510..c6e6fc1 100644 --- a/idx0Tests/SessionServiceTests+Launch.swift +++ b/idx0Tests/SessionServiceTests+Launch.swift @@ -1,524 +1,517 @@ -import XCTest @testable import idx0 +import XCTest extension SessionServiceTests { - func testControllerPreflightMarksMissingLaunchFolder() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Needs Path")).session - let missingPath = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-missing-\(UUID().uuidString)", isDirectory: true) - .path - service.updateTerminalMetadata(session.id, cwd: missingPath, suggestedTitle: nil) - - service.relaunchSession(session.id) - let status = service.sessions.first(where: { $0.id == session.id })?.statusText - XCTAssertTrue(status?.contains("Launch folder missing") ?? false) + func testControllerPreflightMarksMissingLaunchFolder() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Needs Path")).session + let missingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-missing-\(UUID().uuidString)", isDirectory: true) + .path + service.updateTerminalMetadata(session.id, cwd: missingPath, suggestedTitle: nil) + + service.relaunchSession(session.id) + let status = service.sessions.first(where: { $0.id == session.id })?.statusText + XCTAssertTrue(status?.contains("Launch folder missing") ?? false) + } + + func testLaunchManifestIsPersistedWhenControllerIsPrepared() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-launch-manifest-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Manifest")).session + let controller = service.controller(for: session.id) + + let manifestPath = root + .appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent(session.id.uuidString, isDirectory: true) + .appendingPathComponent("launch-manifest.json", isDirectory: false) + .path + let helperPath = root + .appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent("idx0-session-launch-helper.sh", isDirectory: false) + .path + let wrapperPath = root + .appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent(session.id.uuidString, isDirectory: true) + .appendingPathComponent("launch-wrapper.sh", isDirectory: false) + .path + XCTAssertTrue(FileManager.default.fileExists(atPath: manifestPath)) + XCTAssertTrue(FileManager.default.fileExists(atPath: helperPath)) + XCTAssertTrue(FileManager.default.fileExists(atPath: wrapperPath)) + XCTAssertEqual(controller?.shellPath, wrapperPath) + } + + func testLaunchHelperWrapperExecutesManifestCommand() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-launch-wrapper-exec-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let shellScriptPath = root.appendingPathComponent("fake-shell.sh", isDirectory: false).path + let shellScript = "#!/bin/zsh\nexit 0\n" + try shellScript.write(toFile: shellScriptPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shellScriptPath) + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest( + title: "Wrapper Exec", + repoPath: nil, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: shellScriptPath + )).session + let controller = service.controller(for: session.id) + + guard let wrapperPath = controller?.shellPath else { + XCTFail("Expected launch wrapper path") + return } - func testLaunchManifestIsPersistedWhenControllerIsPrepared() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-launch-manifest-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Manifest")).session - let controller = service.controller(for: session.id) - - let manifestPath = root - .appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent(session.id.uuidString, isDirectory: true) - .appendingPathComponent("launch-manifest.json", isDirectory: false) - .path - let helperPath = root - .appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent("idx0-session-launch-helper.sh", isDirectory: false) - .path - let wrapperPath = root - .appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent(session.id.uuidString, isDirectory: true) - .appendingPathComponent("launch-wrapper.sh", isDirectory: false) - .path - XCTAssertTrue(FileManager.default.fileExists(atPath: manifestPath)) - XCTAssertTrue(FileManager.default.fileExists(atPath: helperPath)) - XCTAssertTrue(FileManager.default.fileExists(atPath: wrapperPath)) - XCTAssertEqual(controller?.shellPath, wrapperPath) + let process = Process() + process.executableURL = URL(fileURLWithPath: wrapperPath) + process.currentDirectoryURL = root + try process.run() + process.waitUntilExit() + + XCTAssertEqual(process.terminationStatus, 0) + } + + func testLaunchWrapperFallsBackToShellWhenHelperExitsEarly() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-launch-wrapper-fallback-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let shellScriptPath = root.appendingPathComponent("fake-shell.sh", isDirectory: false).path + let shellScript = "#!/bin/zsh\nexit 0\n" + try shellScript.write(toFile: shellScriptPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shellScriptPath) + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest( + title: "Wrapper Fallback", + repoPath: nil, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: shellScriptPath + )).session + let controller = service.controller(for: session.id) + + guard let wrapperPath = controller?.shellPath else { + XCTFail("Expected launch wrapper path") + return } - func testLaunchHelperWrapperExecutesManifestCommand() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-launch-wrapper-exec-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let shellScriptPath = root.appendingPathComponent("fake-shell.sh", isDirectory: false).path - let shellScript = "#!/bin/zsh\nexit 0\n" - try shellScript.write(toFile: shellScriptPath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shellScriptPath) - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest( - title: "Wrapper Exec", - repoPath: nil, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: shellScriptPath - )).session - let controller = service.controller(for: session.id) - - guard let wrapperPath = controller?.shellPath else { - XCTFail("Expected launch wrapper path") - return - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: wrapperPath) - process.currentDirectoryURL = root - try process.run() - process.waitUntilExit() - - XCTAssertEqual(process.terminationStatus, 0) + let helperPath = root + .appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent("idx0-session-launch-helper.sh", isDirectory: false) + .path + let failingHelper = "#!/bin/zsh\nexit 99\n" + try failingHelper.write(toFile: helperPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath) + + let process = Process() + process.executableURL = URL(fileURLWithPath: wrapperPath) + process.currentDirectoryURL = root + try process.run() + process.waitUntilExit() + + XCTAssertEqual(process.terminationStatus, 0) + } + + func testLaunchHelperRestrictedProfileBuildsSandboxAndExecutes() async throws { + guard FileManager.default.isExecutableFile(atPath: "/usr/bin/sandbox-exec") else { + throw XCTSkip("sandbox-exec is unavailable on this host") } - func testLaunchWrapperFallsBackToShellWhenHelperExitsEarly() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-launch-wrapper-fallback-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let shellScriptPath = root.appendingPathComponent("fake-shell.sh", isDirectory: false).path - let shellScript = "#!/bin/zsh\nexit 0\n" - try shellScript.write(toFile: shellScriptPath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shellScriptPath) - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest( - title: "Wrapper Fallback", - repoPath: nil, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: shellScriptPath - )).session - let controller = service.controller(for: session.id) - - guard let wrapperPath = controller?.shellPath else { - XCTFail("Expected launch wrapper path") - return - } + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-launch-restricted-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let shellScriptPath = root.appendingPathComponent("fake-shell.sh", isDirectory: false).path + let shellScript = "#!/bin/zsh\nexit 0\n" + try shellScript.write(toFile: shellScriptPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shellScriptPath) + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest( + title: "Restricted Wrapper Exec", + repoPath: root.path, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: shellScriptPath, + sandboxProfile: .worktreeWrite, + networkPolicy: .disabled + )).session + let controller = service.controller(for: session.id) + + guard let wrapperPath = controller?.shellPath else { + XCTFail("Expected launch wrapper path") + return + } - let helperPath = root - .appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent("idx0-session-launch-helper.sh", isDirectory: false) - .path - let failingHelper = "#!/bin/zsh\nexit 99\n" - try failingHelper.write(toFile: helperPath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: helperPath) - - let process = Process() - process.executableURL = URL(fileURLWithPath: wrapperPath) - process.currentDirectoryURL = root - try process.run() - process.waitUntilExit() - - XCTAssertEqual(process.terminationStatus, 0) + let process = Process() + process.executableURL = URL(fileURLWithPath: wrapperPath) + process.currentDirectoryURL = root + try process.run() + process.waitUntilExit() + + let sandboxProfilePath = root + .appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent(session.id.uuidString, isDirectory: true) + .appendingPathComponent("sandbox.sb", isDirectory: false) + .path + let launchResultPath = root + .appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent(session.id.uuidString, isDirectory: true) + .appendingPathComponent("launch-result.json", isDirectory: false) + .path + + XCTAssertEqual(process.terminationStatus, 0) + XCTAssertTrue(FileManager.default.fileExists(atPath: launchResultPath)) + + let launchResultData = try Data(contentsOf: URL(fileURLWithPath: launchResultPath)) + let launchResult = try JSONDecoder().decode(LaunchHelperResult.self, from: launchResultData) + switch launchResult.enforcementState { + case .enforced: + XCTAssertTrue(FileManager.default.fileExists(atPath: sandboxProfilePath)) + case .degraded: + let reason = launchResult.message ?? "" + XCTAssertTrue( + !reason.isEmpty, + "Expected degraded launch to include a diagnostic reason" + ) + case .unenforced: + XCTFail("Restricted profile launch was bypassed without degradation.") } + } - func testLaunchHelperRestrictedProfileBuildsSandboxAndExecutes() async throws { - guard FileManager.default.isExecutableFile(atPath: "/usr/bin/sandbox-exec") else { - throw XCTSkip("sandbox-exec is unavailable on this host") - } + func testGhosttyCommandEscapingPreservesPathsWithSpaces() throws { + let commandPath = "/Users/gal/Library/Application Support/idx0/temp/launchers/session/launch-wrapper.sh" + let escaped = GhosttyAppHost.shellEscapedCommand(commandPath) + XCTAssertEqual(escaped, "'\(commandPath)'") - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-launch-restricted-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let shellScriptPath = root.appendingPathComponent("fake-shell.sh", isDirectory: false).path - let shellScript = "#!/bin/zsh\nexit 0\n" - try shellScript.write(toFile: shellScriptPath, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shellScriptPath) - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest( - title: "Restricted Wrapper Exec", - repoPath: root.path, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: shellScriptPath, - sandboxProfile: .worktreeWrite, - networkPolicy: .disabled - )).session - let controller = service.controller(for: session.id) - - guard let wrapperPath = controller?.shellPath else { - XCTFail("Expected launch wrapper path") - return - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: wrapperPath) - process.currentDirectoryURL = root - try process.run() - process.waitUntilExit() - - let sandboxProfilePath = root - .appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent(session.id.uuidString, isDirectory: true) - .appendingPathComponent("sandbox.sb", isDirectory: false) - .path - let launchResultPath = root - .appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent(session.id.uuidString, isDirectory: true) - .appendingPathComponent("launch-result.json", isDirectory: false) - .path - - XCTAssertEqual(process.terminationStatus, 0) - XCTAssertTrue(FileManager.default.fileExists(atPath: launchResultPath)) - - let launchResultData = try Data(contentsOf: URL(fileURLWithPath: launchResultPath)) - let launchResult = try JSONDecoder().decode(LaunchHelperResult.self, from: launchResultData) - switch launchResult.enforcementState { - case .enforced: - XCTAssertTrue(FileManager.default.fileExists(atPath: sandboxProfilePath)) - case .degraded: - let reason = launchResult.message ?? "" - XCTAssertFalse( - reason.contains("missing repo/worktree root"), - "Restricted launch unexpectedly lost write root: \(reason)" - ) - XCTAssertTrue( - reason.contains("sandbox-exec") - || reason.contains("Sandbox launch failed") - || reason.contains("Sandbox profile generation failed"), - "Unexpected degraded reason: \(reason)" - ) - case .unenforced: - XCTFail("Restricted profile launch was bypassed without degradation.") - } - } + let output = try runBashScript("set -- \(escaped); printf '%s' \"$1\"") + XCTAssertEqual(output, commandPath) + } - func testGhosttyCommandEscapingPreservesPathsWithSpaces() throws { - let commandPath = "/Users/gal/Library/Application Support/idx0/temp/launchers/session/launch-wrapper.sh" - let escaped = GhosttyAppHost.shellEscapedCommand(commandPath) - XCTAssertEqual(escaped, "'\(commandPath)'") + func testTerminalStartupTemplateExpansionReplacesWorkdirAndSessionID() throws { + let fixture = try Fixture() + let service = fixture.service + let sessionID = UUID() + let workdir = "/tmp/idx0 folder" - let output = try runBashScript("set -- \(escaped); printf '%s' \"$1\"") - XCTAssertEqual(output, commandPath) + service.saveSettings { settings in + settings.terminalStartupCommandTemplate = "cd ${WORKDIR} && echo ${SESSION_ID}" } - func testTerminalStartupTemplateExpansionReplacesWorkdirAndSessionID() throws { - let fixture = try Fixture() - let service = fixture.service - let sessionID = UUID() - let workdir = "/tmp/idx0 folder" + let expanded = service.expandedTerminalStartupCommand( + for: sessionID, + launchDirectory: workdir + ) - service.saveSettings { settings in - settings.terminalStartupCommandTemplate = "cd ${WORKDIR} && echo ${SESSION_ID}" - } + XCTAssertEqual( + expanded, + "cd '\(workdir)' && echo \(sessionID.uuidString)" + ) + } - let expanded = service.expandedTerminalStartupCommand( - for: sessionID, - launchDirectory: workdir - ) + func testTerminalStartupTemplateExpansionReturnsNilForEmptyTemplate() throws { + let fixture = try Fixture() + let service = fixture.service - XCTAssertEqual( - expanded, - "cd '\(workdir)' && echo \(sessionID.uuidString)" - ) + service.saveSettings { settings in + settings.terminalStartupCommandTemplate = " " } - func testTerminalStartupTemplateExpansionReturnsNilForEmptyTemplate() throws { - let fixture = try Fixture() - let service = fixture.service - - service.saveSettings { settings in - settings.terminalStartupCommandTemplate = " " - } - - XCTAssertNil( - service.expandedTerminalStartupCommand( - for: UUID(), - launchDirectory: "/tmp" - ) - ) + XCTAssertNil( + service.expandedTerminalStartupCommand( + for: UUID(), + launchDirectory: "/tmp" + ) + ) + } + + func testRelaunchUsesPersistedManifestWhenSessionLaunchCwdIsStale() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-manifest-primary-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Manifest Primary")).session + _ = service.controller(for: session.id) + + let stalePath = root + .appendingPathComponent("stale-launch-dir-\(UUID().uuidString)", isDirectory: true) + .path + service.updateTerminalMetadata(session.id, cwd: stalePath, suggestedTitle: nil) + + try await Task.sleep(nanoseconds: 500_000_000) + + let sessionsFile = root.appendingPathComponent("sessions.json", isDirectory: false) + var payload = try SessionStore(url: sessionsFile).load() + guard let index = payload.sessions.firstIndex(where: { $0.id == session.id }) else { + XCTFail("Expected persisted session") + return } - func testRelaunchUsesPersistedManifestWhenSessionLaunchCwdIsStale() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-manifest-primary-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Manifest Primary")).session - _ = service.controller(for: session.id) - - let stalePath = root - .appendingPathComponent("stale-launch-dir-\(UUID().uuidString)", isDirectory: true) - .path - service.updateTerminalMetadata(session.id, cwd: stalePath, suggestedTitle: nil) - - try await Task.sleep(nanoseconds: 500_000_000) - - let sessionsFile = root.appendingPathComponent("sessions.json", isDirectory: false) - var payload = try SessionStore(url: sessionsFile).load() - guard let index = payload.sessions.firstIndex(where: { $0.id == session.id }) else { - XCTFail("Expected persisted session") - return - } - - let validLaunchDirectory = root.appendingPathComponent("valid-launch", isDirectory: true) - try FileManager.default.createDirectory(at: validLaunchDirectory, withIntermediateDirectories: true) - - let persisted = payload.sessions[index] - payload.sessions[index].lastLaunchManifest = SessionLaunchManifest( - sessionID: persisted.id, - cwd: validLaunchDirectory.path, - shellPath: persisted.shellPath, - repoPath: persisted.repoPath, - worktreePath: persisted.worktreePath, - sandboxProfile: persisted.sandboxProfile, - networkPolicy: persisted.networkPolicy, - tempRoot: persisted.sandboxProfile == .worktreeAndTemp - ? root.appendingPathComponent("launchers", isDirectory: true) - .appendingPathComponent(persisted.id.uuidString, isDirectory: true) - .appendingPathComponent("temp", isDirectory: true) - .path - : nil, - environment: [:], - projectID: nil, - ipcSocketPath: nil - ) - payload.sessions[index].lastLaunchCwd = stalePath - try SessionStore(url: sessionsFile).save(payload: payload) - - let restored = try Self.makeService(root: root) - let controller = restored.controller(for: session.id) - XCTAssertNil(controller?.launchBlockedReason) + let validLaunchDirectory = root.appendingPathComponent("valid-launch", isDirectory: true) + try FileManager.default.createDirectory(at: validLaunchDirectory, withIntermediateDirectories: true) + + let persisted = payload.sessions[index] + payload.sessions[index].lastLaunchManifest = SessionLaunchManifest( + sessionID: persisted.id, + cwd: validLaunchDirectory.path, + shellPath: persisted.shellPath, + repoPath: persisted.repoPath, + worktreePath: persisted.worktreePath, + sandboxProfile: persisted.sandboxProfile, + networkPolicy: persisted.networkPolicy, + tempRoot: persisted.sandboxProfile == .worktreeAndTemp + ? root.appendingPathComponent("launchers", isDirectory: true) + .appendingPathComponent(persisted.id.uuidString, isDirectory: true) + .appendingPathComponent("temp", isDirectory: true) + .path + : nil, + environment: [:], + projectID: nil, + ipcSocketPath: nil + ) + payload.sessions[index].lastLaunchCwd = stalePath + try SessionStore(url: sessionsFile).save(payload: payload) + + let restored = try Self.makeService(root: root) + let controller = restored.controller(for: session.id) + XCTAssertNil(controller?.launchBlockedReason) + } + + func testBrowserStatePersistsAcrossRelaunch() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-browser-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Browser")).session + service.toggleBrowserSplit(for: session.id) + service.setBrowserURL(for: session.id, urlString: "https://example.com") + service.setBrowserSplitSide(for: session.id, side: .bottom) + service.setBrowserSplitFraction(for: session.id, fraction: 0.33) + + try await Task.sleep(nanoseconds: 500_000_000) + + let restored = try Self.makeService(root: root) + let restoredSession = restored.sessions.first(where: { $0.id == session.id }) + + XCTAssertEqual(restoredSession?.browserState?.isVisible, true) + XCTAssertEqual(URL(string: restoredSession?.browserState?.currentURL ?? "")?.host, "example.com") + XCTAssertEqual(restoredSession?.browserState?.splitSide, .bottom) + XCTAssertEqual(restoredSession?.browserState?.splitFraction, 0.33) + } + + func testNiriBrowserURLPersistsAcrossRelaunchWithoutSplitState() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("idx0-service-niri-browser-url-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let service = try Self.makeService(root: root) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser URL")).session + service.saveSettings { $0.cleanupOnClose = false } + service.ensureNiriLayoutState(for: session.id) + + guard let browserItemID = service.niriAddBrowserRight(in: session.id), + let browserController = service.niriBrowserController(for: session.id, itemID: browserItemID) + else { + XCTFail("Expected browser tile controller") + return } - func testBrowserStatePersistsAcrossRelaunch() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-browser-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } + browserController.onURLChanged?("https://example.com/docs") - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Browser")).session - service.toggleBrowserSplit(for: session.id) - service.setBrowserURL(for: session.id, urlString: "https://example.com") - service.setBrowserSplitSide(for: session.id, side: .bottom) - service.setBrowserSplitFraction(for: session.id, fraction: 0.33) + let liveSession = service.sessions.first(where: { $0.id == session.id }) + XCTAssertNotNil(liveSession?.browserState) + XCTAssertEqual(URL(string: liveSession?.browserState?.currentURL ?? "")?.host, "example.com") + XCTAssertEqual(liveSession?.browserState?.isVisible, false) - try await Task.sleep(nanoseconds: 500_000_000) + service.prepareForTermination() - let restored = try Self.makeService(root: root) - let restoredSession = restored.sessions.first(where: { $0.id == session.id }) + let restored = try Self.makeService(root: root) + let restoredSession = restored.sessions.first(where: { $0.id == session.id }) + XCTAssertEqual(URL(string: restoredSession?.browserState?.currentURL ?? "")?.host, "example.com") - XCTAssertEqual(restoredSession?.browserState?.isVisible, true) - XCTAssertEqual(URL(string: restoredSession?.browserState?.currentURL ?? "")?.host, "example.com") - XCTAssertEqual(restoredSession?.browserState?.splitSide, .bottom) - XCTAssertEqual(restoredSession?.browserState?.splitFraction, 0.33) - } - - func testNiriBrowserURLPersistsAcrossRelaunchWithoutSplitState() async throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("idx0-service-niri-browser-url-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: root) } - - let service = try Self.makeService(root: root) - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser URL")).session - service.saveSettings { $0.cleanupOnClose = false } - service.ensureNiriLayoutState(for: session.id) - - guard let browserItemID = service.niriAddBrowserRight(in: session.id), - let browserController = service.niriBrowserController(for: session.id, itemID: browserItemID) - else { - XCTFail("Expected browser tile controller") - return - } - - browserController.onURLChanged?("https://example.com/docs") - - let liveSession = service.sessions.first(where: { $0.id == session.id }) - XCTAssertNotNil(liveSession?.browserState) - XCTAssertEqual(URL(string: liveSession?.browserState?.currentURL ?? "")?.host, "example.com") - XCTAssertEqual(liveSession?.browserState?.isVisible, false) - - service.prepareForTermination() - - let restored = try Self.makeService(root: root) - let restoredSession = restored.sessions.first(where: { $0.id == session.id }) - XCTAssertEqual(URL(string: restoredSession?.browserState?.currentURL ?? "")?.host, "example.com") - - let restoredBrowserItemID = restored.niriLayout(for: session.id) - .workspaces - .flatMap(\.columns) - .flatMap(\.items) - .first(where: { item in - if case .browser = item.ref { - return true - } - return false - })?.id - - XCTAssertNotNil(restoredBrowserItemID) - if let restoredBrowserItemID { - XCTAssertNotNil(restored.niriBrowserController(for: session.id, itemID: restoredBrowserItemID)) + let restoredBrowserItemID = restored.niriLayout(for: session.id) + .workspaces + .flatMap(\.columns) + .flatMap(\.items) + .first(where: { item in + if case .browser = item.ref { + return true } - } + return false + })?.id - func testRestrictedProfileWithoutWriteRootShowsDegradedState() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest( - title: "Restricted", - repoPath: nil, - createWorktree: false, - branchName: nil, - existingWorktreePath: nil, - shellPath: nil, - sandboxProfile: .worktreeWrite, - networkPolicy: .disabled - )).session - - _ = service.controller(for: session.id) - let updated = service.sessions.first(where: { $0.id == session.id }) - - XCTAssertEqual(updated?.sandboxEnforcementState, .degraded) - XCTAssertTrue(updated?.statusText?.contains("Restrictions unavailable") ?? false) + XCTAssertNotNil(restoredBrowserItemID) + if let restoredBrowserItemID { + XCTAssertNotNil(restored.niriBrowserController(for: session.id, itemID: restoredBrowserItemID)) } - - func testCreateInactiveTabDoesNotLaunchController() async throws { - let fixture = try Fixture() - let service = fixture.service - - let session = try await service.createSession(from: SessionCreationRequest(title: "Inactive Tab")).session - - for (controllerID, controller) in service.runtimeControllers { - controller.terminate() - service.clearLaunchTracking(for: controllerID) - } - service.runtimeControllers.removeAll() - service.ownerSessionIDByControllerID.removeAll() - - _ = service.createTab(in: session.id, activate: false) - - XCTAssertTrue(service.runtimeControllers.isEmpty) + } + + func testRestrictedProfileWithoutWriteRootShowsDegradedState() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest( + title: "Restricted", + repoPath: nil, + createWorktree: false, + branchName: nil, + existingWorktreePath: nil, + shellPath: nil, + sandboxProfile: .worktreeWrite, + networkPolicy: .disabled + )).session + + _ = service.controller(for: session.id) + let updated = service.sessions.first(where: { $0.id == session.id }) + + XCTAssertEqual(updated?.sandboxEnforcementState, .degraded) + XCTAssertTrue(updated?.statusText?.contains("Restrictions unavailable") ?? false) + } + + func testCreateInactiveTabDoesNotLaunchController() async throws { + let fixture = try Fixture() + let service = fixture.service + + let session = try await service.createSession(from: SessionCreationRequest(title: "Inactive Tab")).session + + for (controllerID, controller) in service.runtimeControllers { + controller.terminate() + service.clearLaunchTracking(for: controllerID) } + service.runtimeControllers.removeAll() + service.ownerSessionIDByControllerID.removeAll() - func testFocusedNiriBrowserDoesNotLaunchTerminalController() async throws { - let fixture = try Fixture() - let service = fixture.service - service.saveSettings { $0.niriCanvasEnabled = true } + _ = service.createTab(in: session.id, activate: false) - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser")).session - for (controllerID, controller) in service.runtimeControllers { - controller.terminate() - service.clearLaunchTracking(for: controllerID) - } - service.runtimeControllers.removeAll() - service.ownerSessionIDByControllerID.removeAll() + XCTAssertTrue(service.runtimeControllers.isEmpty) + } - service.ensureNiriLayoutState(for: session.id) - _ = service.niriAddBrowserRight(in: session.id) + func testFocusedNiriBrowserDoesNotLaunchTerminalController() async throws { + let fixture = try Fixture() + let service = fixture.service + service.saveSettings { $0.niriCanvasEnabled = true } - XCTAssertTrue(service.runtimeControllers.isEmpty) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Browser")).session + for (controllerID, controller) in service.runtimeControllers { + controller.terminate() + service.clearLaunchTracking(for: controllerID) } + service.runtimeControllers.removeAll() + service.ownerSessionIDByControllerID.removeAll() - func testFocusedNiriTerminalDoesNotLaunchWhenOverviewIsOpen() async throws { - let fixture = try Fixture() - let service = fixture.service - service.saveSettings { $0.niriCanvasEnabled = true } - - let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Overview Launch Guard")).session - service.ensureNiriLayoutState(for: session.id) + service.ensureNiriLayoutState(for: session.id) + _ = service.niriAddBrowserRight(in: session.id) - for (controllerID, controller) in service.runtimeControllers { - controller.terminate() - service.clearLaunchTracking(for: controllerID) - } - service.runtimeControllers.removeAll() - service.ownerSessionIDByControllerID.removeAll() - service.visibleTerminalControllerIDsBySession.removeAll() + XCTAssertTrue(service.runtimeControllers.isEmpty) + } - service.toggleNiriOverview(sessionID: session.id) - XCTAssertTrue(service.niriLayout(for: session.id).isOverviewOpen) + func testFocusedNiriTerminalDoesNotLaunchWhenOverviewIsOpen() async throws { + let fixture = try Fixture() + let service = fixture.service + service.saveSettings { $0.niriCanvasEnabled = true } - let launched = service.launchFocusedNiriTerminalIfVisible(sessionID: session.id) + let session = try await service.createSession(from: SessionCreationRequest(title: "Niri Overview Launch Guard")).session + service.ensureNiriLayoutState(for: session.id) - XCTAssertTrue(launched.isEmpty) - XCTAssertTrue(service.runtimeControllers.isEmpty) - XCTAssertFalse(service.shouldLaunchVisibleTerminals(for: session.id)) - XCTAssertNil(service.visibleTerminalControllerIDsBySession[session.id]) + for (controllerID, controller) in service.runtimeControllers { + controller.terminate() + service.clearLaunchTracking(for: controllerID) } + service.runtimeControllers.removeAll() + service.ownerSessionIDByControllerID.removeAll() + service.visibleTerminalControllerIDsBySession.removeAll() - func testHiddenRunningSessionReusesSameControllerOnReturn() async throws { - let fixture = try Fixture() - let service = fixture.service + service.toggleNiriOverview(sessionID: session.id) + XCTAssertTrue(service.niriLayout(for: session.id).isOverviewOpen) - let first = try await service.createSession(from: SessionCreationRequest(title: "First")).session - let second = try await service.createSession(from: SessionCreationRequest(title: "Second")).session + let launched = service.launchFocusedNiriTerminalIfVisible(sessionID: session.id) - service.focusSession(first.id) - guard let firstController = service.ensureController(for: first.id) else { - XCTFail("Expected first controller") - return - } - _ = service.requestLaunchForActiveTerminals(in: first.id, reason: .explicitAction) - - service.focusSession(second.id) - XCTAssertTrue(service.runtimeControllers[firstController.sessionID] === firstController) + XCTAssertTrue(launched.isEmpty) + XCTAssertTrue(service.runtimeControllers.isEmpty) + XCTAssertFalse(service.shouldLaunchVisibleTerminals(for: session.id)) + XCTAssertNil(service.visibleTerminalControllerIDsBySession[session.id]) + } - service.focusSession(first.id) - let returnedController = service.ensureController(for: first.id) - XCTAssertTrue(returnedController === firstController) - } + func testHiddenRunningSessionReusesSameControllerOnReturn() async throws { + let fixture = try Fixture() + let service = fixture.service - func testRelaunchAllSessionsStagesSelectedSessionFirst() async throws { - let fixture = try Fixture() - let service = fixture.service + let first = try await service.createSession(from: SessionCreationRequest(title: "First")).session + let second = try await service.createSession(from: SessionCreationRequest(title: "Second")).session - let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session - let second = try await service.createSession(from: SessionCreationRequest(title: "Two")).session - let third = try await service.createSession(from: SessionCreationRequest(title: "Three")).session + service.focusSession(first.id) + guard let firstController = service.ensureController(for: first.id) else { + XCTFail("Expected first controller") + return + } + _ = service.requestLaunchForActiveTerminals(in: first.id, reason: .explicitAction) - service.focusSession(second.id) - for (controllerID, controller) in service.runtimeControllers { - controller.terminate() - service.clearLaunchTracking(for: controllerID) - } - service.runtimeControllers.removeAll() - service.ownerSessionIDByControllerID.removeAll() - service.visibleTerminalControllerIDsBySession.removeAll() + service.focusSession(second.id) + XCTAssertTrue(service.runtimeControllers[firstController.sessionID] === firstController) - service.relaunchAllSessions() + service.focusSession(first.id) + let returnedController = service.ensureController(for: first.id) + XCTAssertTrue(returnedController === firstController) + } - try await Task.sleep(nanoseconds: 60_000_000) - XCTAssertNotNil(service.runtimeControllers[second.id]) - XCTAssertEqual(service.runtimeControllers.count, 1) + func testRelaunchAllSessionsStagesSelectedSessionFirst() async throws { + let fixture = try Fixture() + let service = fixture.service - try await Task.sleep(nanoseconds: 550_000_000) - XCTAssertNotNil(service.runtimeControllers[first.id]) - XCTAssertNotNil(service.runtimeControllers[third.id]) - XCTAssertEqual(service.runtimeControllers.count, 3) - } + let first = try await service.createSession(from: SessionCreationRequest(title: "One")).session + let second = try await service.createSession(from: SessionCreationRequest(title: "Two")).session + let third = try await service.createSession(from: SessionCreationRequest(title: "Three")).session - func testSwipeTrackerProjectsForwardWithPositiveVelocity() { - var tracker = SwipeTracker(historyLimit: 0.150, deceleration: 0.997) - tracker.push(delta: 12, at: 0.00) - tracker.push(delta: 14, at: 0.04) - tracker.push(delta: 13, at: 0.08) - - XCTAssertGreaterThan(tracker.velocity(), 0) - XCTAssertGreaterThan(tracker.projectedEndPosition(), tracker.position) + service.focusSession(second.id) + for (controllerID, controller) in service.runtimeControllers { + controller.terminate() + service.clearLaunchTracking(for: controllerID) } - + service.runtimeControllers.removeAll() + service.ownerSessionIDByControllerID.removeAll() + service.visibleTerminalControllerIDsBySession.removeAll() + + service.relaunchAllSessions() + + try await Task.sleep(nanoseconds: 60_000_000) + XCTAssertNotNil(service.runtimeControllers[second.id]) + XCTAssertEqual(service.runtimeControllers.count, 1) + + try await Task.sleep(nanoseconds: 550_000_000) + XCTAssertNotNil(service.runtimeControllers[first.id]) + XCTAssertNotNil(service.runtimeControllers[third.id]) + XCTAssertEqual(service.runtimeControllers.count, 3) + } + + func testSwipeTrackerProjectsForwardWithPositiveVelocity() { + var tracker = SwipeTracker(historyLimit: 0.150, deceleration: 0.997) + tracker.push(delta: 12, at: 0.00) + tracker.push(delta: 14, at: 0.04) + tracker.push(delta: 13, at: 0.08) + + XCTAssertGreaterThan(tracker.velocity(), 0) + XCTAssertGreaterThan(tracker.projectedEndPosition(), tracker.position) + } } diff --git a/scripts/presubmit.sh b/scripts/presubmit.sh index ea06415..9ac412d 100755 --- a/scripts/presubmit.sh +++ b/scripts/presubmit.sh @@ -190,8 +190,193 @@ run_link_check() { run_step "Lychee link check" lychee --config .lychee.toml "${md_files[@]}" } +normalize_failed_test_name() { + local value="$1" + local trimmed inside class_part method_part class_name + + trimmed="${value#"${value%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + trimmed="${trimmed%\(\)}" + if [[ -z "$trimmed" ]]; then + return + fi + + if [[ "$trimmed" == "-["*"]"* ]]; then + inside="${trimmed#*-[}" + inside="${inside%%]*}" + class_part="${inside%% *}" + method_part="${inside#* }" + class_name="${class_part##*.}" + if [[ -n "$class_name" && -n "$method_part" && "$method_part" != "$inside" ]]; then + echo "${class_name}.${method_part}" + return + fi + fi + + # Convert module-qualified names (Module.Class.testName) into Class.testName. + if [[ "$trimmed" == *.*.* ]]; then + trimmed="${trimmed#*.}" + fi + + echo "$trimmed" +} + +extract_failed_tests() { + local log_file="$1" + local line in_failing_block normalized + + in_failing_block=0 + while IFS= read -r line; do + if [[ "$line" == "Failing tests:"* ]]; then + in_failing_block=1 + continue + fi + + if [[ "$in_failing_block" -eq 1 ]]; then + if [[ -z "${line//[[:space:]]/}" ]]; then + in_failing_block=0 + continue + fi + + normalized="$(normalize_failed_test_name "$line")" + [[ -n "$normalized" ]] && echo "$normalized" + continue + fi + + if [[ "$line" == *"Test Case "*"' failed"* ]]; then + normalized="$(normalize_failed_test_name "$line")" + [[ -n "$normalized" ]] && echo "$normalized" + fi + done < "$log_file" | awk '!seen[$0]++' +} + +extract_xcresult_path() { + local log_file="$1" + local line next_is_path + + next_is_path=0 + while IFS= read -r line; do + if [[ "$next_is_path" -eq 1 ]]; then + line="${line#"${line%%[![:space:]]*}"}" + [[ -n "$line" ]] && echo "$line" + return + fi + + if [[ "$line" == "Test session results, code coverage, and logs:"* ]]; then + next_is_path=1 + fi + done < "$log_file" +} + +lookup_failure_reason_for_test() { + local log_file="$1" + local test_name="$2" + local class_name method_name reason_line failed_line_number start_line reason + + class_name="${test_name%%.*}" + method_name="${test_name#*.}" + method_name="${method_name%\(\)}" + + reason_line="$( + grep -F ".${class_name} ${method_name}]" "$log_file" 2>/dev/null \ + | grep "error:" 2>/dev/null \ + | head -n 1 \ + || true + )" + + if [[ -z "$reason_line" ]]; then + reason_line="$( + grep -F "${class_name}.${method_name}" "$log_file" 2>/dev/null \ + | grep "error:" 2>/dev/null \ + | head -n 1 \ + || true + )" + fi + + if [[ -z "$reason_line" ]]; then + failed_line_number="$( + grep -nF ".${class_name} ${method_name}]' failed" "$log_file" 2>/dev/null \ + | head -n 1 \ + | cut -d: -f1 \ + || true + )" + + if [[ -n "$failed_line_number" ]]; then + start_line=$((failed_line_number > 25 ? failed_line_number - 25 : 1)) + reason_line="$( + sed -n "${start_line},${failed_line_number}p" "$log_file" \ + | grep -E "error:|Assertion failed:|fatal error:|XCTAssert" 2>/dev/null \ + | tail -n 1 \ + || true + )" + fi + fi + + if [[ -z "$reason_line" ]]; then + echo "No explicit assertion/error line found in xcodebuild output." + return + fi + + reason="$(printf '%s' "$reason_line" | sed -E 's/^[[:space:]]+//')" + reason="$(printf '%s' "$reason" | sed -E 's#^.*error:[[:space:]]*-\[[^]]+\][[:space:]]*:[[:space:]]*##')" + if [[ -z "$reason" ]]; then + reason="$(printf '%s' "$reason_line" | sed -E 's/^[[:space:]]+//')" + fi + + echo "$reason" +} + +summarize_xcode_test_failures() { + local log_file="$1" + local -a failed_tests=() + local test_name reason index xcresult_path + + while IFS= read -r test_name; do + [[ -z "$test_name" ]] && continue + failed_tests+=("$test_name") + done < <(extract_failed_tests "$log_file") + + xcresult_path="$(extract_xcresult_path "$log_file")" + + echo + echo "==> Test failure summary" + + if [[ "${#failed_tests[@]}" -eq 0 ]]; then + echo "No individual failing XCTest cases were parsed." + echo "Top error lines:" + grep -E "error:|\\*\\* TEST FAILED \\*\\*|BUILD FAILED" "$log_file" 2>/dev/null \ + | head -n 12 \ + | sed 's/^/ - /' \ + || true + else + index=1 + for test_name in "${failed_tests[@]}"; do + reason="$(lookup_failure_reason_for_test "$log_file" "$test_name")" + echo " $index. $test_name" + echo " reason: $reason" + index=$((index + 1)) + done + fi + + if [[ -n "$xcresult_path" ]]; then + echo "xcresult: $xcresult_path" + fi +} + run_tests() { - run_step "xcodebuild tests" xcodebuild -project idx0.xcodeproj -scheme idx0 -destination 'platform=macOS' test + local log_file + + log_file="$(mktemp -t idx0-presubmit-tests)" + echo "==> xcodebuild tests" + + if xcodebuild -project idx0.xcodeproj -scheme idx0 -destination 'platform=macOS' test 2>&1 | tee "$log_file"; then + rm -f "$log_file" + return + fi + + summarize_xcode_test_failures "$log_file" + echo "==> Full xcodebuild log: $log_file" + return 1 } run_maintainability() {